Research — Mission-Type Single-Source + Gate Wiring
The design is locked by the spec + operator decisions; this file consolidates the confirmed decisions and the pre-spec squad's verified facts (paula-patterns + python-pedro, verified against HEAD 4e1e8ed34). No open [NEEDS CLARIFICATION] remain.
D-1 — Where the canonical accessor lives (layer)
(e.g. builtin_mission_type_ids() -> tuple[str, ...] + a frozenset convenience), wrapping MissionTypeRepository.default().ids().
action_grain.py/mission_type_profiles.py); doctrine must NOT import charter, so the authority cannot live in charter. MissionTypeRepository.default() already resolves its own root via importlib.resources, so the accessor needs no charter-side root.
unification (charter Governing Principle / gotcha 6). (b) compute at charter import time — rejected: reintroduces import-time filesystem I/O into hot charter modules (NFR-001).
- Decision: A cached, module-level accessor in the doctrine layer, next to
MissionTypeRepository - Rationale:
charter → doctrineis the legal import direction (already used lazily in - Alternatives considered: (a) keep the literals + a drift-guard test — rejected: that is parity, not
D-2 — Avoiding import-time I/O in charter
the existing # noqa: PLC0415 cycle-guard convention), never at module top-level. @functools.cache on the accessor bounds it to ≤1 scan/process.
so swapping the module constant for a call there is a natural fit. NFR-001 forbids any new import-time mission_types/ read.
- Decision: Charter consumers call the accessor lazily inside function bodies (function-local import,
- Rationale:
pack_context._read_activated_mission_typesalready reads its constant inside a function,
D-3 — Frozenset vs sorted-list per consumer
(_BUILTIN_MISSION_TYPE_IDS) uses the frozenset form (its return type is frozenset[str] and callers do frozenset equality); Rosters A/C/D/E use whichever matches (A tuple/iteration, C list, D frozenset+sentinels, E dict-keys+alias).
types. One accessor, two thin projections, avoids re-globbing per shape.
- Decision: Accessor exposes both an ordered (sorted tuple) form and a frozenset form. Roster B
- Rationale:
MissionTypeRepository.ids()returns a sorted list; consumers need different container
D-4 — Canonical order
deliberate software-dev-first tuple, but the squad found zero functional src consumers depend on that order (all set-membership/iteration). Adopting sorted order is a safe simplification; an AC audit-confirms no order-dependent consumer.
- Decision: The accessor returns sorted (lexicographic) order.
CANONICAL_MISSION_TYPESwas a - Rationale: Avoids inventing a bespoke ordering authority; the source-of-truth ordering is the file set.
D-5 — load_action_index fail-loud boundary (#2667)
raises a co-located ActionIndexError(ValueError) — covering (1) non-mapping root, (2) non-list artifact-kind field value, (3) syntactically-unparseable YAML. A genuinely-missing file keeps the silent fallback; an intentionally-empty-but-well-formed index is empty content (no raise).
operator chose the broad line (including unparseable YAML) over the issue's narrower "parseable" wording.
offending key, and the found type. Doctrine-layer convention is a named *Error(ValueError) co-located with the module (cf. MissionTypeNotAnArtifactKind, OrgPackSchemaError).
- Decision (operator): Present ⇒ must be well-formed; absent ⇒ empty. A present-but-invalid index
- Rationale: Fully closes the FR-013 false-pass class (a broken index silently dropping a grain). The
- Exception shape: Mirror
MissionTypeRepository._load'sValueErrorphrasing — name the path, the
D-6 — Runtime surface for the scan (#2666)
(src/specify_cli/cli/commands/doctor.py), catching CrossGrainDoubleDeclarationError → unhealthy report + RC=1 + structured --json finding; add a CI structural gate. Re-add the symbol to __all__ in action_grain.py, coupled with this src caller (C-003).
"loud findings" renderer (_render_unsanctioned_override_findings) to mirror. The dead-symbol gate (test_no_dead_symbols.py) only passes once a real src importer exists — hence the coupling.
action_grain.py declares out of scope → tracked follow-up, not this mission.
- Decision: Wire
scan_builtin_cross_grain_duplicatesintodoctrine_check() - Rationale:
doctor doctrinealready runsreport.healthy → exit_codeand has a--jsonpath plus a - Boundary: Project/org override collision coverage needs the multi-root action-index engine that
D-7 — Migration live-read (#2669 Roster C, C-004)
time.
drift-guard test, preserving historical migration determinism. The operator chose the live-read: a project replaying rc35 after a 5th type ships will now get the 5th type written. Accepted deliberately for automatic new-type pickup and single-source coherence.
- Decision (operator): The rc35 migration reads
MissionTypeRepository.default().ids()atapply() - Trade-off (recorded in design-decisions tracer): The squad's alternative was a frozen literal + a
D-8 — #2668 scope
the same private _default_built_in_dir but call it intra-class with no bypass — leave them (C-005).
scope.
- Decision: Promote only
MissionTypeProfileRepository's accessor. The 11 sibling doctrine repos have - Rationale: Only this class has cross-class
# noqa: SLF001consumers; a cross-repo sweep is unrelated