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_...) — viaMissionTypeRepository.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) — preservesoftware_devalias
Test obligations
- Adding a synthetic
mission_types/*.yamlmakes 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;
--jsonoutput 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 --jsonshape + 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 state | Result |
|---|---|
| File missing | ActionIndex(action=action) — silent fallback (UNCHANGED) |
| Present, well-formed, empty content | empty-content ActionIndex — no raise |
| Present, root is not a mapping | raise ActionIndexError |
| Present, an artifact-kind field is not a list | raise ActionIndexError |
| Present, YAML unparseable | raise 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_listand the non-dict-root test topytest.raises(ActionIndexError) - New: missing-file-stays-fallback; empty-but-well-formed-index returns empty content without raising;