Phase 0 — Research
Mission mission-resolver-port-01KX1C05 · #2173 Phase-2 MissionResolver port. Primary source: docs/plans/engineering-notes/2173-infra-logic-separation/00-SYNTHESIS.md. Grounding established by the 3-lens pre-spec squad (architect-alphonso, paula-patterns, researcher-robbie) — full findings in the tracer-*.md files. This file records the decisions the plan rests on.
D-Q1 — Which resolve_mission is the canonical single walk?
walks kitty-specs/ via _build_index). This is the identity authority the port faces.
template/config paths (resolve_command_template_path, resolve_content_template_path, resolve_mission_config_path). The scout's "two resolve_mission functions" was imprecise; they are different concerns, not rivals. The _read_path_resolver canonicalizer already delegates to the context resolver (_read_path_resolver.py:503), confirming it is the single identity walk.
and concern (path vs identity); merging would create the exact god-resolver the synthesis warns against.
- Decision:
src/specify_cli/context/mission_resolver.py::resolve_mission(returnsResolvedMission; - Rationale:
src/specify_cli/runtime/resolver.pyhas noresolve_mission— it resolves - Alternatives considered: unifying with the runtime path resolver — rejected: different return type
D-Q2 — Where does the port live? (REVISED post-squad — ledger blocker)
mission_runtime/mission_resolver_port.py); FsMissionResolver + FakeMissionResolver adapters in src/specify_cli/context/mission_resolver.py beside the walk they wrap.
(TestMissionRuntimeBoundary): _MISSION_RUNTIME_ALLOWED_SPECIFY_CLI (:76-95) does not list "context", and mission_runtime currently imports zero specify_cli.context. The planned shell import (even lazy/in-function — the gate does a full ast.walk) would create a new ledger edge — which is doubly wrong because #2173's charter is to drain that ledger. The scout conflated direction with subpackage: the established edges are core/missions/coordination, not context.
import). Adapters import the Protocol via the allowed downward specify_cli → mission_runtime (package-root) direction. The default FsMissionResolver is constructed at the CLI/specify_cli entry boundary and threaded down; mission_runtime shell functions accept a Protocol-typed resolver and pass it into the already-ledgered specify_cli.missions canonicalizer.
"context" to the ledger with rationale) requires an explicit operator note.
- Decision: Protocol
MissionResolverinmission_runtime(a new small module - Why revised: the original "everything in
specify_cli.context" plan redstest_layer_rules.py - How the revision dodges it: the shell references only the local Protocol (no
specify_cli.context - Acceptance criterion:
test_layer_rules.pygreen with zero new ledger edge. Fallback (add
D-08 — Full trunk, not a 7th parallel path (operator ruling post-squad)
resolver param and is threaded end-to-end; all 8 free-function callers (audit/engine.py:87, selector_resolution.py:218, retrospect.py:124, agent_retrospect.py:72, mission_type.py:1051, runtime/show_origin.py:231, acceptance/__init__.py:910, _read_path_resolver.py:503) + the canonicalizer route through it or are explicitly documented.
inside the canonicalizer, bypassing an assembler-injected port. A scoped port would be a 7th path and leave the split-brain open — contradicting "unification, not parity." The AST gate bans raw iterdir but NOT free-fn calls, so the trunk needs the caller audit too.
- Decision: the port is the single walk trunk. The free
resolve_missiongains an optional - Rationale: the squad showed the dominant read path reaches the walk via the free
resolve_mission
D-01 — Seam location (REVISED post-squad — inject at callers, thread down)
_resolve_mission_slug (resolve_action_context :1384, mission_context_for, resolve_placement_only ~:866) through the canonicalizer chain (_read_path_resolver) down to the free resolve_mission. Never on build_execution_context (pure door) and never on the frozen context.
its output is an input to the assembler — so injecting inside the assembler is structurally incoherent. The seam belongs at the callers. Threading preserves the canonicalizer + topology-aware (coord/primary) read that _resolve_mission_id/_resolve_mission_slug carry today.
(ADR 2026-06-26-1, "context is a proto-DI container" = category error).
- Decision: thread
resolver: MissionResolver | Nonefrom the shell callers of - Why revised: the squad showed
_resolve_mission_slugruns before_assemble_core_fragmentsand - Rationale (unchanged): a frozen value object carrying a mutable I/O collaborator breaks immutability
D-07 — Legacy-<slug> bootstrap sentinel is a carve-out, not a fallback (post-squad)
sentinel for pre-identity/bootstrap/scaffold missions. This path is an explicit, documented pre-identity carve-out that does NOT flow through the fail-closed resolve(); a regression test pins it.
branch through it would break mission-create/scaffold. This is NOT the forbidden is None fallback (D-05) — it is a distinct operation (mint an id for a mission with none yet), not resolution of an existing mission.
- Decision:
_resolve_mission_id(resolution.py:944) deliberately degrades to alegacy-<slug> - Rationale: the port's
resolve()is fail-closed-loud (raises). Silently routing the bootstrap
D-09 — NFR-001 scoped precisely (post-squad — overclaim fix)
FakeMissionResolver — NOT that the whole assembler is FS-free.
(get_main_repo_root, _resolve_coordination_branch, _resolve_status_surface_dir, topology) and that build_execution_context is already FS-free. Those remaining legs are separate ports deferred to later #2173 phases; the spec/test wording says so.
- Decision: NFR-001 proves the identity-resolution leg of the builder is FS-free via
- Rationale: the squad noted
_assemble_core_fragmentshas 4+ other FS/git legs
Census expansions (post-squad — the counts undershot)
(glossary/events.py:215, runtime/next/_internal_runtime/retrospective_terminus.py:63) that must be triaged (shared home or OUT-with-rationale — never a silent stop at 12). Preserve the 2 stamp + 2 datetime helpers. ADJACENT S1192: the literal "%Y-%m-%dT%H:%M:%SZ" recurs 18× with 4 redundant constant defs — a separate stamp-consolidation cleanup; only the SAFE mission_parsing.py:259 hardcoded literal folds here.
triage the dataclass-hydration KeyError reads (context/models.py:83, lanes/models.py:200) as OUT.
day-one or the gate reds on introduction. (This many walkers validates the bind-by-construction thesis.)
- Clock: 12 byte-identical isoformat copies in
specify_cli+ 2 cross-package copies - #2139: ≥9 non-migration readers (not 4). Route all onto
read_target_branch_from_metaor explicitly - AST-gate allowlist: ~16 live non-migration
kitty-specs/walkers exist — seed the full census
D-02 — No cache in Phase-2 (from squad, binding)
@lru_cache is forbidden (C-005).
dashboard daemon is long-lived — a persistent cache would serve stale indexes (a correctness bug). The Phase-2 value is the seam (Fake → FS-free builder test), not perf.
- Decision: the resolver is request-scoped; any memoization is instance-lifetime only. Module/process
- Rationale:
merge/ordering.pymutates the scanned surface under the global merge lock, and the
D-03 — #2139 is a sibling reconcile, not a resolver method (from squad, binding)
(core/paths.py:655) and delete the divergent "main"/""/KeyError defaults; deliver as its own WP.
granularity from handle→mission resolution. Making it a resolver method widens the port past its concern (violates "one adapter per port").
- Decision: route the 4 stragglers onto the existing
read_target_branch_from_metaauthority - Rationale:
target_branchextraction is a field read on an already-resolved mission — a different
D-04 — Clock is 3 helpers, not 1 (from squad, binding)
format-preserving stamp helper for the 2 %Y-%m-%dT%H:%M:%SZ callers and a now_utc() -> datetime helper for the 2 decisions/* callers.
decisions callers return datetime; folding them would change on-disk timestamps (NFR-004).
- Decision: collapse the 12 byte-identical isoformat copies into one
now_utc_iso() -> str; keep a - Rationale: the stamp callers serialize to a different, second-precision
...Zformat and the
D-05 — Fail-closed, no legacy branch (from squad + ADR 2026-07-01-1, binding)
→ MissionSelectorAmbiguous/AmbiguousHandleError. No if <canonical> is None: <fallback> or mission_id or slug branch. Fake fixtures are canonical-shaped.
real root was stale non-canonical fixtures. Forbidden shape is an existing binding ADR.
- Decision: cold-miss → structured not-found naming
spec-kitty migrate backfill-identity; ambiguity - Rationale: the motivating incident (PR #2277) proved the reflexive
is Nonebranch was wrong; the
D-06 — Bind by construction (from squad)
kitty-specs/ walker (token-keyed allowlist), copying test_protection_resolver_call_sites.py.
reappears. Allowlist keyed on tokens/symbols, never line numbers (they drift on merge — F5).
- Decision: ship an ADR + a new AST call-site gate naming
FsMissionResolverthe sole sanctioned - Rationale: without a structural gate the "one walk" invariant is reviewer-vigilance and walker #7
D-10 — DDD rename ExecutionContext → MissionExecutionContext (operator-directed, IC-00)
MissionExecutionContext; land as the FIRST WP so downstream resolver work uses the corrected name.
(context.py:11), a parity-test assertion (test_execution_context_parity.py:1545), and the #1619 epic title all use it; the class name is the only laggard (DDD: code follows the ubiquitous language). Decisive extra reason: it collides with core/context_validation.py:41 class ExecutionContext(StrEnum) — an unrelated type. Renaming disambiguates.
(2026-06-22-1, 2026-06-03-1). Hard exclusion: the ExecutionContext(StrEnum) — a different type, untouched (its own rename is ADJACENT, out of scope unless the operator asks).
scoped occurrence classification at /tasks and verify with the full arch suite + test_mission_runtime_surface.py / test_execution_context_parity.py after (the collision is the whack-a-symbol trap).
- Decision: rename the frozen composite
mission_runtime.context.ExecutionContext→ - Rationale (it fits):
MissionExecutionContextis already the ubiquitous term — the class docstring - Scope: class def +
ActionContextalias (:349) + ~12 import sites + usages (20 files) + ADR prose - Discipline: bulk-edit-shaped though the mission is not wholesale
change_mode: bulk_edit— apply a
Adopt-don't-duplicate (reuse, verified)
PlacementSeam(mission_runtime/resolution.py:1266) — yield dirs through its grammar, never compose paths._canonicalize_primary_read_handle(_read_path_resolver.py:1243) — handle-form canonicalization delegates here; the port does identity→dir only, and stays OUT of the blind primitiveprimary_feature_dir_for_mission(C-007).resolve_mid8/mission_dir_name(lanes/branch_naming.py) — the mid8 SSOT.- DI idiom:
RuntimeEventEmitterProtocol +NullEmitter(runtime/next/_internal_runtime/events.py:67/95); wiringemitter or NullEmitter()(engine.py:191).
Arch-gate green-list (must not regress — NFR-002)
test_no_raw_mission_spec_paths.py, test_protection_resolver_call_sites.py, test_single_mission_surface_resolver.py, test_resolution_authority_gates.py (CANONICALIZER_FLOOR/ROUTED_CANONICALIZER_FLOOR/COORD_AUTHORITY_WRITE_FLOOR), test_coord_read_residuals_closeout.py (duplicate canonicalizer pins + identity/lanes floors), test_inline_meta_read_gate.py, test_write_surface_placement_guard.py, test_mission_runtime_surface.py. Floors are shrink-only; verify from the primary checkout (markers vacuous under .worktrees/ — F1); grep floor constants repo-wide before assuming one gate file owns a pin (F2/F3).