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 from charter.yaml (not config.yaml); config.yaml holds a one-line charter: 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 the config.yaml charter: pointer; default.yaml supplies the absent-key fallback/seed. config.yaml no longer carries activated_* but DOES retain org_packsfrom_config is 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.yaml activation set is ⊆ (default ∪ org ∪ project ∪ local) offered doctrine; filter_graph_by_activation gates 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, and pack_roots overlays 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_activation behavior 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_configfilter_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: governance and directives deserialize into the existing GovernanceConfig / DirectivesConfig models unchanged (nested in CharterYaml).
  • G2: catalog is byte-equivalent in content to the retired references.yaml body (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.yaml is the absent-key fallback/seed, not a live per-artifact tiered activation merge (see the note on tier accumulation in active-doctrine-resolution.md).
  • G7 (two-file read): PackContext.from_config reads config.yaml for the charter: pointer + org_packs, and charter.yaml for the flat activation. org_packs (pack roots) intentionally stay in config.yaml.
  • G4: metadata carries bundle_schema_version: 2 (read by versioning.py); it MUST NOT carry a self-referential charter_hash.
  • G5: the file is git-tracked; a compile updates catalog/metadata.generated_at deterministically (stable ordering) — an unchanged charter produces a byte-identical file (content-identity, #2732).

Resolution via config pointer

  • G6: the active charter.yaml is located through the single charter: 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-homed ReferenceCatalogError/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 from BUNDLE_CONTENT_HASH_FILES): [charter.yaml] — a field distinct from derived_files.
  • derived_files / derivation_sources: [] / {} — nothing generated-and-gitignored remains in the charter bundle. charter.yaml is NOT placed in derived_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 from derived_files (they were always distinct — that distinction is literally the historic 4-vs-3 mismatch). derived_files == {}.
  • M2: CharterBundleManifest._validate's tracked ∩ derived = ∅ invariant is preserved untouchedcharter.yaml appears only in tracked_files, never in derived_files. (Do NOT relax the disjointness rule; keeping "authored ≠ generated" meaningful is the point.)
  • M3: compute_bundle_content_hash hashes exactly the content_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.yaml filename constant is the single source consumed by all charter modules + the migration.
  • M5: all writes to charter.yaml go through the single shared load→mutate-owned-section→round-trip-save helper (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.yaml in 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.yaml activated_* section (relocated)

Output

  • .kittify/charter/charter.yaml composed from: governance ← governance.yaml; directives ← directives.yaml; catalog ← references.yaml body; activation ← config.yaml activated_*; metadata.bundle_schema_version ← 2.
  • Deletes the four legacy bundle files.
  • Removes activated_* from .kittify/config.yaml and adds a single charter: .kittify/charter/charter.yaml pointer (preserves agents: + non-doctrine keys; comment-preserving round-trip). The resolver locates charter.yaml via 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.yaml raises 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, both detect() on absence). Their post-state (config has activated_) 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.