Research — Single-Authority Resolution Gates (Phase 0)
Consolidated from the binding ADR 2026-06-26-1, the investigation note (docs/engineering_notes/2173-infra-logic-separation/00-SYNTHESIS.md), and the 3-squad pre-planning check (priti/debbie/paula). No NEEDS CLARIFICATION remain.
D-1 — Gate, not DI port, for the resolver boundary
- Decision: close the #2164 canonicalizer class with a single sanctioned seam + an AST call-site gate, not an injected
MissionResolverport. - Rationale: canonicalization is already centralized (
_canonicalize_primary_read_handle); the defect is callers bypassing it, which a gate forbids by construction at ~10% of the port's churn and with no partial-adoption tax. The codebase already ships the pattern (test_protection_resolver_call_sites.py,test_single_mission_surface_resolver.py). - Alternatives: full
MissionResolverDI port (deferred to #2173 Phase 2 — it is the enumeration-consolidation/#1619 layer, over-built for bug-closure); fold canonicalization into the primitive (rejected — FR-011 infinite recursion, live-confirmed at_read_path_resolver.py:454).
D-2 — Scan-by-name discriminator (the TBYD blind-spot)
- Decision: the canonicalizer gate scans calls by name to
primary_feature_dir_for_mission, checking the handle was canonicalized first — not rawKITTY_SPECS_DIRjoins. - Rationale: the primitive is topology-blind-by-design and auto-blessed by both existing gates; it composes the
KITTY_SPECS_DIRjoin internally, so the raw-join scanner (Idiom-B's first discriminator) is structurally blind to a bare handle reaching it. Idiom-B'sdiscover_selection_callsites()already exists for exactly this blind-spot. - Alternatives: a raw-join-only gate (misses the entire #2164 class — the 34 bare-handle sites compose no join at the call site).
D-3 — Two discriminators, one shared module
- Decision: the canonicalizer discriminator and the coord-authority discriminator share one Idiom-B machinery module (composite-key allowlist, self-test, floor, shrink-only staleness guard) but are two AST predicates.
- Rationale: they detect structurally different violations (un-canonicalized handle vs kind-blind write); one predicate cannot catch both. Sharing the machinery (C-005) avoids duplicating the governance scaffolding.
- Alternatives: two separate modules (duplicates the machinery); one predicate (cannot express both).
D-4 — #2154: route the write leg through the present authority
- Decision: route
mark_status's write (tasks.py:1807, kind-blindresolve_feature_dir_for_mission→ coord) through the same kind-aware authority its commit (:1905) andmove_taskvalidation (:660) already use → primary. Intra-function. - Rationale: the kind-aware authority exists and is correct on two of three legs; only the write leg bypasses it. No new authority needed — this is a routing fix.
- Alternatives: introduce a new authority (unnecessary duplication); change the validator instead (wrong — the validator and commit are already correct; the write is the outlier).
D-5 — #2155: route the two mixed-bundle callers, DON'T touch the guard (revised post residual-hunt)
- Decision: route the
move_task(tasks.py:1555) andimplement/claim (implement.py:1311) mixed-partition auto-commit bundles through theBookkeepingTransactionpatternworkflow.py:_commit_workflow_changealready uses (coord status → coord surface; WP file → primary), surfacing rather than swallowingSafeCommitPathPolicyError. Thesafe_commitguard (src/specify_cli/git/commit_helpers.py:983-991) is NOT modified. - Rationale: the residual hunt (debbie exhaustive + python-pedro cross-check, two independent traces) proved the guard is already surface-aware (keys on
worktree_root-foreignness) and the genuine residual is two callers committing coord status paths through a primary worktree — a mixed-partition bundle #2154's routing does not dissolve. The swallow ("Auto-commit skipped" warning) is why it's been low-visibility. Mutating the guard to be "kind-aware" cannot distinguish a leak from a legit coord write (onlyworktree_root-relativity can) → re-opens #1887 for zero gain. - Alternatives: mutate the guard to defer to the kind partition (REJECTED — re-opens #1887, the original spec framing); "regression test only" (REJECTED — there IS a real residual at the two callers; a test without the routing fix would just pin the bug).
D-8 — Discriminator is provenance/def-use, not name-matching (post-plan squad)
- Decision: the canonicalizer discriminator judges "canonical" by intra-function def-use (the arg is assigned from
_canonicalize_primary_read_handle, or is a known-canonicalfeature_dir.name, in the same function), not by name-substring; routing is the default over allowlisting, with a routed-count floor and a pre-sweep baseline. - Rationale: a scan-by-name gate can only force the ~27 bare-handle consumer sites into the allowlist; nothing forbids mass-allowlisting all 38 and freezing that as the shrink-only baseline → SC-004 green with zero routing (alphonso + renata converged). A name-substring "canonical" check auto-passes the ~5 sites that already contain
canonicalin the arg name. Def-use + routed-count-floor + pre-sweep-baseline close the vacuity. - Alternatives: pure name-matching (fakeable); full data-flow analysis (over-built — intra-function def-use suffices for the realistic site shapes).
D-6 — Convergence test is stub-driven
- Decision: assert read-seam ≡ write/placement-seam for every handle form via an injectable/stub resolver, no live
kitty-specs/fixtures. - Rationale: deterministic, fast (fast tier), exercises every handle form including ambiguity-raises and cold-miss without filesystem setup. This is the testability win the ADR's Phase-2 port would also deliver — available here via stubbing.
- Alternatives: live
kitty-specs/fixtures (slow, flaky, needs real ULID/mid8 scaffolding).
D-7 — Folds are domain-matched only
- Decision: fold #1842's
/tmp-literal-in-tests ratchet (via IC-01's gate pattern) and #2034's marker co-tag (on mission-ownedcontractfiles only). - Rationale: both touch surfaces this mission already opens; cheap incremental hygiene. The domain-match guard excludes the #1842 litter sweep and the #2034
ci-quality.ymlmatrix change (paula). - Alternatives: full #1842/#2034 (scope inflation, out-of-domain); skip the folds (leaves cheap wins on the table while the surfaces are open).
Brownfield checks (post-planning, to run in the squad pass)
Per the standing post-planning cadence — to be executed by the post-plan squads, recorded back here/plan.md:
- Foldable-issue search: DONE in pre-planning (the issue-matrix; #1842/#2034 folded, everything else excluded with rationale).
- Split-brain / dual-authority scan: the mission is the split-brain fix; verify no NEW dual-authority is introduced (the gates enforce single authority).
- LOC / sizing scan: ~34 canonicalizer sites is the dominant cost; confirm the allowlist-vs-route split is calibrated (not 34 routes).
- Deprecation check: confirm
resolve_feature_dir_for_mission(kind-blind) is being narrowed (writes routed away), not freshly adopted elsewhere.