Contracts

canonical-accessor.md

Contract — Canonical mission-type accessor (IC-1a)

Location: doctrine layer, adjacent to MissionTypeRepository (src/doctrine/missions/mission_type_repository.py or a sibling module).

Interface

@functools.cache
def builtin_mission_type_ids() -> tuple[str, ...]: ...       # sorted, cached
def builtin_mission_type_id_set() -> frozenset[str]: ...      # frozenset convenience
# builtin_mission_type_ids.cache_clear() is the test seam (auto-provided by functools.cache)

Guarantees

1. Single source. Values derive from MissionTypeRepository.default().ids() (the mission_types/.yaml tree). No hardcoded roster. 2. Loud-fail transitivity. If MissionTypeRepository raises on an id≠stem mismatch or invalid schema, the accessor raises (does not swallow). 3. Caching. ≤1 filesystem scan per process; repeated calls return identical, cheap results. 4. Layer-clean. Lives in doctrine; imports no charter/specify_cli. Charter consumers import it lazily inside functions for import-time-I/O timing (NFR-001) — NOT for cycle avoidance (there is no cycle: doctrine never imports charter; the # noqa: PLC0415 guards in action_grain.py protect a different*, intra-charter cycle).

Test seam (C-010) — cache vs SC-001 synthetic-type test

A parameterless @functools.cache accessor bound to MissionTypeRepository.default() would otherwise make the SC-001 "add a synthetic type → universal pickup" test impossible:

  • writing the synthetic YAML to a tmp dir is invisible (no injection seam);
  • writing to the real bundled dir is defeated by the cache AND races xdist workers (shared repo tree).

Contract: the SC-001 guard test monkeypatches the resolved default root to a tmp dir (or monkeypatches MissionTypeRepository.default), then calls builtin_mission_type_ids.cache_clear() before asserting pickup. The test MUST NOT mutate src/doctrine/missions/mission_types/. Production never adds/removes built-in type YAMLs mid-process, so the cache is safe in production (NFR-002).

Consumers (must all resolve through this contract)

  • Roster A CANONICAL_MISSION_TYPES (charter/mission_type_profiles.py)
  • Roster B _BUILTIN_MISSION_TYPE_IDS (charter/pack_context.py) — frozenset form
  • Roster C migration (m_3_2_0rc35_...) — via MissionTypeRepository.default().ids() at apply()-time
  • Roster D ALLOWED_MISSION_TYPES (charter/activations.py) — union {any, generic}
  • Roster E _MISSION_IDENTIFIER_ANSWERS (charter/synthesizer/interview_mapping.py) — preserve software_dev alias

Test obligations

  • Adding a synthetic mission_types/*.yaml makes every roster reflect it (single-source guard, SC-001).
  • Importing charter modules triggers zero accessor calls / zero mission_types/ reads (SC-005 / NFR-001).
  • Frozenset-equality contract of Roster B preserved.

doctor-doctrine-integrity.md

Contract — doctor doctrine cross-grain integrity check (IC-3 / #2666)

Location: src/specify_cli/cli/commands/doctor.py (doctrine_check), consuming charter.action_grain.scan_builtin_cross_grain_duplicates.

Behavior

1. doctrine_check calls scan_builtin_cross_grain_duplicates() before computing the exit code. 2. On CrossGrainDoubleDeclarationError:

3. On success: the check contributes a healthy result; exit code unchanged.

  • the doctrine report is marked unhealthy;
  • the command exits RC=1;
  • --json output carries a structured finding (mission type + colliding artifact URN);
  • human output renders a loud finding (mirroring _render_unsanctioned_override_findings).

__all__ coupling (C-003)

scan_builtin_cross_grain_duplicates is re-added to __all__ in src/charter/action_grain.py in the SAME change as this wiring — the CI dead-symbol gate (tests/architectural/test_no_dead_symbols.py::test_no_public_symbol_in_all_is_unimported, arch_shard_1) only passes once a real src/ importer exists.

CI structural gate (FR-011)

A structural test asserts the built-in tree is cross-grain-disjoint, independent of the broad pytest run. tests/doctrine/drg/test_cross_grain_integrity.py remains the structural home.

Scope boundary

Built-in tree only. Project/org override collision coverage is a tracked follow-up (blocked on the multi-root action-index engine action_grain.py declares out of scope).

Test obligations

MissionTypeProfileRepository(built_in_dir=tmp) as the integrity-gate twin does).

  • CLI test: doctor doctrine --json shape + RC=1 on a synthetic collision (constructed via
  • Dead-symbol gate auto-verifies the __all__ re-add.

fail-loud-action-index.md

Contract — load_action_index fail-loud (IC-2 / #2667)

Location: src/doctrine/missions/action_index.py

New exception

class ActionIndexError(ValueError):
    """A present action-index file is not a well-formed ActionIndex."""

Message names: the index path, the offending key (or <root>), and the found type.

Decision table (the contract)

Input stateResult
File missingActionIndex(action=action) — silent fallback (UNCHANGED)
Present, well-formed, empty contentempty-content ActionIndexno raise
Present, root is not a mappingraise ActionIndexError
Present, an artifact-kind field is not a listraise ActionIndexError
Present, YAML unparseableraise ActionIndexError

Propagation

Sole src/ caller is aggregate_action_grain (action_grain.py, no try/except) → the raise propagates to scan_builtin_cross_grain_duplicates, resolve_mission_type_context, and the new doctor doctrine wiring. This is the desired loud path; the FR-013 union can no longer pass falsely over a silently-dropped grain.

Test obligations

(stale-contract re-pin, NOT deletion).

unparseable-YAML raises.

  • Re-pin test_non_list_field_value_returns_empty_list and the non-dict-root test to pytest.raises(ActionIndexError)
  • New: missing-file-stays-fallback; empty-but-well-formed-index returns empty content without raising;