Contracts
active-doctrine-resolution.md
Contract: Active-doctrine resolution flow (config → charter.yaml → pack.yaml → active DRG)
How the runtime loop calls the charter module to obtain a consistent active DRG, under the post-inversion design. The charter module aggregates every relevant doctrine artefact (the project charter's activation + overrides, layered over the offered pack catalogue) and filters the full doctrine graph down to exactly what is active.
Grounded surfaces (canonical):
- Consume sites:
charter/context.py:925(build_charter_context/_build_doctrine_service),charter/compiler.py:826,charter/reference_resolver.py:67,charter/consistency_check.py:300,specify_cli/mission_step_contracts/executor.py,doctrine/drg/merge.py. - Charter module:
charter/pack_context.py:152 PackContext.from_config,charter/default_pack.py:44 load_default_pack_activation_ids,charter/pack_manager.py:703 merge_defaults,charter/drg.py:323 filter_graph_by_activation. - Post-inversion change: the activation state (
activated_*/activated_kinds/mission_type_activations) is read fromcharter.yaml(notconfig.yaml);config.yamlholds a one-linecharter:pointer the resolver follows.
Sequence
sequenceDiagram
autonumber
participant RL as Runtime loop<br/>(build_charter_context / executor / compiler)
participant CM as Charter module<br/>(PackContext + drg filter)
participant CFG as .kittify/config.yaml<br/>(pointer + non-doctrine)
participant CHY as charter.yaml<br/>(project charter)
participant PK as default.yaml + org packs<br/>(offered catalogue)
participant DRG as DRG graph.yaml<br/>(full doctrine graph)
RL->>CM: resolve active doctrine<br/>PackContext.from_config(repo_root) + full DRG
activate CM
CM->>CFG: read config
CFG-->>CM: charter: pointer + org_packs<br/>(NO activated_* here anymore)
CM->>CHY: load charter.yaml (via pointer)
Note right of CHY: fail-loud if pointer dangles<br/>or file corrupt (#2530) — no fallback
CHY-->>CM: activation {activated_kinds,<br/>mission_type_activations, activated_*}<br/>+ overrides + governance + directives + catalog
CM->>PK: load layer-0 default pack + org packs<br/>load_default_pack_activation_ids()
PK-->>CM: offered catalogue<br/>(all built-in artefact IDs, per kind)
Note over CM: AGGREGATE — resolve the active set (tiered)<br/>higher-order charters (org⊆team) form the BASE;<br/>charter.yaml EXPANDS it → superset of inherited tiers,<br/>subset of what packs OFFER<br/>· absent per-kind key → default pack<br/>· explicit empty list → fail-closed frozenset()<br/>· non-empty → that exact set · + overrides
CM->>DRG: load full DRG (graph.yaml)
DRG-->>CM: full graph (all offered nodes/edges)
Note over CM: filter_graph_by_activation(full_drg, pack_context)<br/>gate by activated_kinds + mission_type_activations<br/>+ per-kind activated-ID sets → consistent active DRG
CM-->>RL: active DRG<br/>(only activated doctrine elements)
deactivate CM
RL->>RL: consume ONLY the active DRG
Charter tiers & set semantics
A resolved charter.yaml sits between two bounds:
- SUBSET of all available doctrine. Packs offer the universe of artefacts; a charter activates a subset of them.
charter.yaml ⊆ (default pack ∪ org packs ∪ project packs ∪ local doctrine). - SUPERSET of the higher-order charters it inherits. Higher tiers set an enforced base; lower tiers expand it (add, never subtract the inherited base). Accumulating downward:
org_active ⊆ team/project_active ⊆ repo_active (the resolved charter.yaml) ⊆ all_offered_doctrine
> ⚠ Status: the subset bound is shipped; the tier accumulation below is the TARGET model, not what this mission builds. Today activation is a single flat set resolved from one file; the existing pack_roots / merge_three_layers overlay applies to artifact definitions (the offered universe), not to activation tiers (paula MAJOR-4). The org⊆team⊆repo charter accumulation is future work fenced OUT by C-008 (ADR 2026-07-15-1). This mission relocates the single flat activation surface onto charter.yaml; the diagram documents the intended end-state so the schema is authored to be forward-compatible.
Layering (how the "consistent active DRG" is composed)
flowchart TB
subgraph OFFER["Available doctrine — packs OFFER the universe"]
DP["default pack<br/>(built-ins)"]
OP["org pack<br/>(e.g. lynn-cole culture)"]
PP["project pack<br/>(e.g. flashheart code style)"]
LP["repo/local doctrine"]
end
OC["Org charter<br/>(BASE — activates lynn-cole)"]
PC["Team/project charter<br/>(EXPANDS — activates flashheart)"]
RC["Repo charter<br/>(EXPANDS — local activations)"]
RES["Resolved charter.yaml<br/>subset of OFFER · superset of OC⊆PC⊆RC"]
F["filter_graph_by_activation"]
G["Full DRG (graph.yaml)"]
A["Consistent ACTIVE DRG"]
OC --> PC --> RC --> RES
OFFER -. "each tier activates a subset it offers" .-> RES
RES --> F
G --> F
F --> A
Grounded user journey — org base + project expansion
1. Org tier. The lead architect mandates lynn-cole engineering culture for all projects → authors an org-level charter (activating the lynn-cole doctrine artefacts from the org pack). This is the enforced base. 2. Project tier. The "project flashpoint" team lead wants a specific code style for all their repos → the tech lead authors the flashheart code style doctrine artefacts, adds them to the flashheart doctrine pack, and authors a flashheart charter that expands the org base. 3. Repo tier. A developer creates a new repository and configures it to pull in the project pack (flashheart) + the org pack. The repo's resolved charter.yaml inherits the org base and the project expansion → the repository adheres to *lynn-cole culture and flashheart code style* (a superset of both higher-order charters, and a subset of everything the pulled-in packs offer).
Guarantees
- AR1: the runtime loop consumes ONLY the active DRG — unactivated doctrine is invisible (ADR 2026-07-15-1 claim 3, on the surface this mission touches).
- AR2: activation (flat root keys) is resolved from
charter.yaml, located via theconfig.yamlcharter:pointer;default.yamlsupplies the absent-key fallback/seed.config.yamlno longer carriesactivated_*but DOES retainorg_packs→from_configis a two-file read (config for pointer + org_packs; charter.yaml for activation). - AR6 (set bounds): the subset bound is a REAL, preserved invariant — a resolved
charter.yamlactivation set is⊆ (default ∪ org ∪ project ∪ local)offered doctrine;filter_graph_by_activationgates the offered universe down to the activated set. The superset / tier-accumulation bound (org_active ⊆ team ⊆ repo) is FORWARD-INTENT, not current behavior — today activation is a single flat set from one file, andpack_rootsoverlays artifact definitions (the offered universe), NOT activation tiers (paula MAJOR-4). This mission relocates the single flat activation surface; the charter-tier accumulation is future work fenced OUT by C-008 (below). - AR3: fail-closed is preserved — absent per-kind key → default pack; explicit empty list →
frozenset()(never re-expand); corrupt charter / dangling pointer → raise (#2530), never fall back to a legacy file. - AR4: the charter module is the single aggregation seam producing the active DRG — one authority, no split-brain between a "hash file" and a "parity file".
- AR5 (fence, C-008): this mission relocates the activation surface and keeps
filter_graph_by_activationbehavior byte-preserved; the broader ADR 2026-07-15-1 runtime-gating / new-DRG-node restructure is OUT.
Note — current vs. target
The call chain (PackContext.from_config → filter_graph_by_activation → active DRG) is unchanged in shape. This mission moves only the source of activation (config.yaml → charter.yaml, reached via the pointer) and unifies governance/directives/catalog into the same charter.yaml. The behavior-preserving guarantee (SC-008/INV-4) is exactly that the resolved active DRG is identical before and after the relocation.
charter-yaml-schema.md
Contract: charter.yaml structured schema (v2.0.0)
charter.yaml is the git-tracked, authorable project charter. Full field table in data-model.md. This contract fixes the observable guarantees consumers may rely on.
Shape (top-level keys)
A concrete, round-trippable instance (executed against charter.schemas.CharterYaml by the FR-140 round-trip gate). governance/directives/overrides are shown at their empty defaults; the FLAT root activation lists carry sample values.
# pydantic_model: charter.schemas.CharterYaml
# expect: valid
schema_version: "2.0.0"
governance: {} # GovernanceConfig — AUTHORED
directives: # DirectivesConfig — AUTHORED
directives: []
catalog: # DERIVED-but-committed projection
mission: consolidate-charter-bundle
template_set: default
languages:
- python
references:
- id: DIRECTIVE_001
kind: directive
title: Single canonical authority
summary: Illustrative catalog reference item.
source_path: .kittify/charter/charter.md
local_path: .kittify/doctrine/directives/DIRECTIVE_001.md
# --- activation: FLAT root keys (NOT nested), identical shape to packs/default.yaml ---
activated_kinds:
- directive
- tactic
mission_type_activations:
- software-dev
activated_directives:
- DIRECTIVE_001
activated_tactics: []
# ... one flat root list per kind (styleguides/toolguides/paradigms/procedures/agent_profiles/mission_step_contracts)
activated_styleguides: []
activated_toolguides: []
activated_paradigms: []
activated_procedures: []
activated_agent_profiles: []
activated_mission_step_contracts: []
overrides: {} # AUTHORED — project doctrine overrides (forward-compat)
metadata:
generated_at: "2026-01-01T00:00:00+00:00"
bundle_schema_version: 2
> ⚠ Activation keys are FLAT at the charter.yaml root (matching src/charter/packs/default.yaml:5-38), NOT nested under an activation: mapping — so pack_context._read_activated_* / _read_list_key and activation_engine.commit_plan read/write them unchanged, and pack overlay works (paula BLOCKER-1).
Guarantees
- G1:
governanceanddirectivesdeserialize into the existingGovernanceConfig/DirectivesConfigmodels unchanged (nested inCharterYaml). - G2:
catalogis byte-equivalent in content to the retiredreferences.yamlbody (same keys), so parity/resolving consumers see identical data. - G3: activation is a single flat set resolved by
PackContext.from_config; an explicit empty list stays fail-closed (frozenset()); an absent key resolves the default pack fallback (load_default_pack_activation_ids, never a re-expanded_BUILTIN_*).default.yamlis the absent-key fallback/seed, not a live per-artifact tiered activation merge (see the note on tier accumulation inactive-doctrine-resolution.md). - G7 (two-file read):
PackContext.from_configreadsconfig.yamlfor thecharter:pointer +org_packs, andcharter.yamlfor the flat activation.org_packs(pack roots) intentionally stay inconfig.yaml. - G4:
metadatacarriesbundle_schema_version: 2(read byversioning.py); it MUST NOT carry a self-referentialcharter_hash. - G5: the file is git-tracked; a compile updates
catalog/metadata.generated_atdeterministically (stable ordering) — an unchanged charter produces a byte-identical file (content-identity, #2732).
Resolution via config pointer
- G6: the active
charter.yamlis located through the singlecharter:pointer in.kittify/config.yaml(default.kittify/charter/charter.yaml). The pointer may redirect to a sibling / shared / cross-project charter; a swap is a one-line config change. The resolver reads the pointer, then loads that file.
Fail-closed
- Corrupt/unreadable
charter.yaml→ raise the re-homedReferenceCatalogError/ReferencesCorruptError(#2530), never fall back to a legacy file. - A
charter:pointer to a missing/unreadable file → fail loud (C-003); never fall back.
manifest-v2.md
Contract: CharterBundleManifest v2 (src/charter/bundle.py)
Changes
SCHEMA_VERSION:"1.0.0"→"2.0.0"(single bump; C-004).BUNDLE_CONTENT_HASH_FILES:("governance.yaml","directives.yaml","references.yaml","metadata.yaml")→("charter.yaml",).CANONICAL_MANIFEST.tracked_files:[charter.md]→[charter.md, charter.yaml].content_hash_files(NEW field, sourced fromBUNDLE_CONTENT_HASH_FILES):[charter.yaml]— a field distinct fromderived_files.derived_files/derivation_sources:[]/{}— nothing generated-and-gitignored remains in the charter bundle.charter.yamlis NOT placed inderived_files.gitignore_required_entries: the four bundle files removed.
Guarantees
- M1: the content-hash input set is
content_hash_files == {charter.yaml}, held in a field distinct fromderived_files(they were always distinct — that distinction is literally the historic 4-vs-3 mismatch).derived_files == {}. - M2:
CharterBundleManifest._validate'stracked ∩ derived = ∅invariant is preserved untouched —charter.yamlappears only intracked_files, never inderived_files. (Do NOT relax the disjointness rule; keeping "authored ≠ generated" meaningful is the point.) - M3:
compute_bundle_content_hashhashes exactly thecontent_hash_files(charter.yaml) via the unchanged per-file recipe (BOM-strip/CRLF); write-side stampers (write_pipeline.py:685,resynthesize_pipeline.py:205) and the freshness reader (computer.py) route the same single recipe — minimal call-site edits (NFR-001). - M4: the shared
charter.yamlfilename constant is the single source consumed by all charter modules + the migration. - M5: all writes to
charter.yamlgo through the single sharedload→mutate-owned-section→round-trip-savehelper (INV-9), preserving non-owned sections byte-for-byte.
Anti-requirements
- Do NOT introduce a parallel/hand-rolled manifest model.
- Do NOT carry
references.yaml/governance.yaml/directives.yaml/metadata.yamlin any v2 file list except the migration's input enumeration.
migration-contract.md
Contract: legacy → charter.yaml migration
New migration in src/specify_cli/upgrade/migrations/m_.py. Body pattern = src/doctrine/versioning.py:299 migrate_v1_to_v2 (yaml→yaml write-and-stamp), NOT the rc35 refresh-only shape. Registered via @MigrationRegistry.register; runs_on_worktrees = False; lazy charter. imports (C-002).
Inputs (read, safe-load)
.kittify/charter/governance.yaml,directives.yaml,metadata.yaml,references.yaml.kittify/config.yamlactivated_*section (relocated)
Output
.kittify/charter/charter.yamlcomposed from: governance ← governance.yaml; directives ← directives.yaml; catalog ← references.yaml body; activation ← config.yamlactivated_*; metadata.bundle_schema_version ← 2.- Deletes the four legacy bundle files.
- Removes
activated_*from.kittify/config.yamland adds a singlecharter: .kittify/charter/charter.yamlpointer (preservesagents:+ non-doctrine keys; comment-preserving round-trip). The resolver locatescharter.yamlvia this pointer. - Updates
.gitignore(remove the four entries; charter.yaml tracked).
Guarantees
- MG1 (deterministic, NFR-003): pure yaml→yaml transform; no prose parsing; stable key ordering. Activation lists copied VERBATIM — an absent key stays absent (NEVER converted to
[], which would flip all-active→none), an explicit[]stays[](paula MINOR-2 / SC-008). - MG2 (idempotent, NFR-003): state
charter.yaml present + four absent + config has no activated_*→ the migration reports 0 changes and exits success. Idempotent even against re-seeding by the existing seed migrations (see MG6). - MG3 (fail-loud, C-003): a charter operation on
four-present / no-charter.yamlraises the re-homed #2530 fail-closed error with a single actionable "run the migration" message; no silent legacy-file read. - MG4 (identity-safe): touches ONLY
.kittify/charter/metadata.yaml; NEVER.kittify/metadata.yaml(project identity —schema_version/project_uuid). - MG5 (behavior-preserving activation): post-migration activation resolution (parity + DRG filter) is byte-identical to pre-migration (INV-4).
- MG6 (migration ordering — paula MAJOR-3): the fold sequences strictly AFTER the existing activation-seed migrations that write
activated_INTO config.yaml —m_unify_charter_activation.py(whose "config is the activation authority" invariant is now REVERSED — annotate/reconcile it) and the rc35 pair (m_3_2_0rc35_default_charter_pack.py,m_3_2_0rc35_activate_builtin_mission_types.py, bothdetect()on absence). Their post-state (config hasactivated_) is the fold's pre-state; the fold relocates then removes those keys (INV-2). The fold must not perpetually re-fire against a re-seeded config.
Test obligations
- Legacy fixture (four files + config.activated_, no charter.yaml) → fail-loud before; migrate; assert charter.yaml composed (flat activation) + four gone + config.activated_ gone +
charter:pointer added; re-run → 0 changes. - Absent-key fidelity: a config with an absent per-kind key migrates to charter.yaml with that key still absent (not
[]). - Ordering: after the rc35/unify seed migrations run, the fold relocates their output and leaves config activation-free.
- Reuse/extend
tests/upgrade/test_unified_bundle_migration.py,test_charter_rename_migration.py,tests/specify_cli/test_state_contract.py,test_state_gitignore_migration.py.