Contracts

context-decomposition-parity.md

Contract: context.py decomposition parity & completion (US3 / FR-007…FR-009, NFR-001/002/005, SC-004)

Behaviour-preserving surface

build_charter_context, build_charter_context_include, build_charter_context_json produce byte-identical output before/after decomposition.

Non-fakeable parity corpus (NFR-001)

The golden fixture MUST include, at minimum, one input each that traverses:

Enforcement (not just prose): the parity test enumerates the three named cases and independently asserts each hit its distinguishing marker (over-budget → fetch-stanza swap line present; miss → structured miss stanza present; first-load → state file written). Deleting any input reds the suite — this is what makes "0 diffs" meaningful (a no-op passes byte-parity over any corpus).

  • token-budget substitution (over-budget body → fetch-stanza swap),
  • catalog-miss fall-through (missing artefact → structured miss stanza),
  • first-load state bookkeeping (state-file write path).

Baseline provenance (Decision 10)

The parity-baseline-capture WP depends on the US1 (empty-charter) + US2 (prose) WPs being approved, and the captured golden must include the empty-charter input producing the generic-agent / empty-Directive-IDs output — proving the baseline was generated post-US1 (guards the US1∩US3 collision, since all WPs share one mission with no inter-workstream merge).

Completion signal (SC-004) — WIRED, non-fakeable

A dedicated test tests/charter/test_context_decomposition_completion.py (owned by the last US3 WP) asserts BOTH: 1. Primary (un-fakeable): seam-existence manifest — each named seam module (see data-model.md) exists AND is imported by ≥1 non-context caller (imported from the seam, not only re-exported through context.py). 2. Secondary: LOC gatewc -l src/charter/context.py ≤ 600 (grounded floor ≈500–540; ≤500 is the aspirational stretch; the earlier 400 is dropped). If context.py cannot reach ≤600, that is a BLOCKER requiring explicit operator re-sign-off — NOT an implementer-side ceiling adjustment.

Regression guards (already green on the monolith — NOT completion signals)

  • test_layer_rules.py, test_runtime_charter_doctrine_boundary.py — no specify_cli import under src/charter/.
  • test_no_dead_symbols.py — every src/charter/ module declares __all__, every export called.

Cycle dissolution (NFR-001 acyclicity)

The 4 symbols profile_sections.py function-locally imports from charter.context (_render_fetch_stanza, _budget_estimate, _diagnose_catalog_miss, _PROFILE_INLINE_BODY_LIMIT_CHARS) move to LEAF homes; profile_sections.py imports them at top level; both # noqa: PLC0415 are deleted; no new cycle.

FR-009 preserved surface

Every private symbol imported from charter.context by tests (test-only; ~40 incl. _reset_agent_profile_cache used by 4 files) stays importable via a # FR-009 preserved surface re-export block in context.py.

Ordering (Decision 8)

US3 parity baseline captured only after US1+US2 merge; profile_resolution + doctrine_service_builder seams extracted LAST against the frozen US1 wrapper region.

empty-charter-fallback.md

Contract: Empty-charter dispatch fallback (US1 / FR-002…FR-006)

Seam

invocation/executor.py auto-route branch (:255-259, the only auto-route entry point — verified) calls invocation/empty_charter.py::resolve_generic_fallback(repo_root, request_text) -> RouterDecision | None.

Predicate (composite — ALL charter-activatable dimensions)

pc = PackContext.from_config(repo_root)
is_empty = (charter_activated_urns(repo_root) == set())     # 6 URN kinds
           and pc.activated_agent_profiles is None
           and pc.activated_mission_step_contracts is None
           and pc.activated_glossary_packs is None
           and pc.org_roots == ()                           # no org/project packs
# anti_pattern is NOT charter-activatable → excluded.

Predicate truth table (all MUST be tested)

Charter stateis_empty
nothing activated, no org packsTrue → fallback
any URN kind activatedFalse
activated_agent_profiles non-None (incl. [])False
activated_mission_step_contracts non-NoneFalse
activated_glossary_packs non-NoneFalse
org/project pack present (org_roots != ())False

Routing behaviour

GivenWhenThen
is_empty, no --profile hintdispatch auto-routesRouterDecision(profile_id="generic-agent", action=<derived from verb>, confidence="generic_fallback"); InvocationPayload.empty_charter_fallback = True
is_empty, explicit --profile architect-alphonsodispatchspecialist resolves normally (fallback returns None)
any activation presentdispatchresolve_generic_fallback returns None; existing routing unchanged
is_emptysoftware-dev mission type requestedstill available (always-on) — assert explicitly

Governance-context agreement (NOT free — US1 scope)

Under an empty charter, render_compact_view (compact.py:216-230) merges resolver_directives from resolve_project_governance, and _resolve_directives_selection (resolver.py:233-260) catalog-falls-back to ALL built-in DIR-### when nothing is selected. So the generic-agent dispatch would leak the full directive canon.

  • Requirement: on the empty_charter_fallback path, the governance block must NOT merge the project catalog-fallback directives (preferred: build_charter_context(..., suppress_project_resolver=True) or a scoped minimal-governance render for the fallback; do NOT globally change _resolve_directives_selection).
  • Red-first agreement test: dispatch under a wholly-empty charter; assert payload.profile_id == "generic-agent", the governance Directive IDs: block is empty (or exactly generic-agent's own cited directives), no specialist marker, empty_charter_fallback is True. RED before the scoping lands.

Warning surface

cli/commands/dispatch.py::_render_rich_payload (lines 59-91), gated on getattr(payload, "empty_charter_fallback", False) (payload uses dynamic __init__ — read defensively; always thread the kwarg at the single construction site). One yellow panel; --json exposes the boolean.

Default charter asset (FR-005)

Ship src/doctrine/assets/built-in/charter_scaffold_minimal.yml + .asset.yaml (id: common-charter-scaffold-minimal, mime: application/yaml). Resolve via spec-kitty doctrine asset path common-charter-scaffold-minimal. Done-line: (a) resolves; (b) pack_validator mime guard test; (c) activatability test — activate the scaffold in a temp repo, assert the resulting charter validates, no user charter touched.

Untouched (regression proof — MUST stay green, NOT owned)

test_doctrine_service_factory.py::, test_registry_builtin_activation_parity.py::{test_excluded_builtin_absent_from_routing_and_context, test_no_activation_key_admits_all_builtins_in_routing}, tests/charter/test_activation_authority.py::.

New coverage (owned)

tests/specify_cli/invocation/test_empty_charter_fallback.py (predicate truth table, decision shape, action-derivation preserved, RouterDecision Literal), tests/specify_cli/invocation/cli/test_dispatch.py (auto-route→generic-agent + warning + governance-agreement (Directive IDs empty) + --json flag + explicit-profile bypass + software-dev availability), plus the asset activatability + mime tests.

fetch-stanza-normalization.md

Contract: Fetch-stanza when-clause normalization (US2 / FR-001, NFR-003)

Site

context_renderers/fetch_stanza.py::fetch_stanza_lines (line ~133) — the single composition choke-point for When you {clause}, run this command and apply the returned rule.

Pinned wire contract (must keep matching, per-stanza)

(tests/specify_cli/next/test_wp_prompt_governance_contract.py:221.)

  • _FETCH_CMD_RE — the Run: spec-kitty charter context --include <selector> line.
  • _WHEN_DOING_RE = r"when\s+you\s+(are\s+about\s+to|need\s+to|encounter|introduce|rename|review)"closed 6-verb set.

Behaviour

Given clause shapeThen emitted line
leading gerund (designing or reviewing significant code changes)grammatical, headed by a closed lead-in (e.g. are about to …), matches _WHEN_DOING_RE
full sentence w/ trailing period (STATED_DEFAULT_WHEN fallback)no When you <sentence>., … doubling; grammatical; matches
already well-formed (are about to apply a code change)byte-unchanged from today

Assertion upgrade

NFR-003 is asserted per rendered stanza via a dedicated helper, not the current whole-prompt _WHEN_DOING_RE.search(prompt) (which passes if any single line matches).

Escape hatch

If a clause genuinely cannot be mapped into the closed set, _WHEN_DOING_RE is widened as a deliberate, documented contract change kept in sync with kitty-specs/wp-prompt-governance-payload-01KRR8HS/contracts/charter-context-resolver.md. Default is to normalize, not widen.