Implementation Plan: Single-Authority Tracker-Egress Verdict
Branch: feat/egress-single-authority | Date: 2026-08-10 | Spec: spec.md Input: Feature specification from kitty-specs/egress-single-authority-01KZN7CB/spec.md
Summary
The tracker egress verdict enforces Channel-1 consent from one authority (project_egress_refusal) but re-derives the refusal reason and remedy from a second, independent lookup (egress_verdict._classify_channel1), which can drift and repeats a full checkout-routing / consent resolution per verdict. This mission gives the egress-consent port a decision-carrying return (splitting EgressConsent.DENIED into no_record / recorded_refusal / not_consentable, keeping no_resolver / unanswerable), sources the diagnostic from the same ConsentDecision (resolve_project_consent) that enforces the outcome, and deletes _classify_channel1 and its second resolution — without changing what the gate allows or denies (NFR-001), the hosted refusal string (NFR-002), or the never-raise contract (NFR-003).
Technical approach (see research.md for the threading decision): a single internal decider in egress.py computes the EgressConsent member once from one resolve_project_consent + routing resolution, and exposes both the byte-identical refusal string (for project_egress_refusal's existing str | None consumers, incl. the widen SaaS transport) and the diagnostic state (for egress_verdict). _classify_channel1's independent resolution is deleted, closing #3287 and removing #3291-item2's redundant work as a consequence.
Technical Context
Language/Version: Python 3.11+ Primary Dependencies: internal only — specify_cli.invocation.adapters (EgressConsent, resolve_egress_consent, permits_egress), specify_cli.egress (project_egress_refusal, _refusal_for_verdict, _render_denied_refusal), specify_cli.tracker.egress_verdict, specify_cli.sync.consent (resolve_project_consent, consented_project_uuids), specify_cli.sync.routing, specify_cli.sync.__init__ (registered resolver). Tooling: pytest, ruff, mypy --strict. Storage: N/A — reads the existing .kittify/config.yaml + machine consent index; no new persistence. Testing: pytest (unit + the tests/architectural/test_egress_consent_boundary.py and test_adapters.py guards); red-first for any behavior change; the enforcement-equivalence matrix and one-resolution count are the load-bearing tests. Target Platform: Linux / macOS CLI. Project Type: single. Performance Goals: exactly one resolve_checkout_sync_routing_readonly and one resolve_project_consent per gated verdict (down from two each). Constraints: fail-closed / never-raise (NFR-003); hosted refusal byte-identity (NFR-002); the confidentiality seam must not widen (C-001 — permits_egress stays the single grant gate, True only for GRANTED). Scale/Scope: ~4 source modules + sync/__init__.py resolver wiring + tests. No public CLI surface change.
Charter Check
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
Charter mode: compact, software-dev-default (git, mypy, pytest, ruff). No paradigm packs active. Relevant standing orders: ATDD-first (enforcement-equivalence + byte-identity tests land before the refactor), canonical-sources (change the port/authority, not a mirror), red-first for behavior. No charter violations — the mission tightens an existing seam without adding surface. PASS.
Project Structure
Documentation (this mission)
kitty-specs/egress-single-authority-01KZN7CB/
├── plan.md # This file
├── research.md # Phase 0 — the threading + degraded-state design decisions
├── data-model.md # Phase 1 — EgressConsent members, ConsentDecision, state mapping
├── contracts/ # Phase 1 — the internal function contracts (resolver, decider, verdict)
├── quickstart.md # Phase 1 — how to verify SC-001..SC-005
└── tasks.md # Phase 2 (/spec-kitty.tasks — NOT created here)
Source Code (repository root)
src/specify_cli/
├── invocation/adapters.py # EgressConsent (split DENIED), resolve_egress_consent mapping, permits_egress (unchanged gate)
├── egress.py # single decider: one ConsentDecision -> (byte-identical string, channel1_state); project_egress_refusal thin wrapper; _refusal_for_verdict re-pointed
├── tracker/egress_verdict.py # _resolve_channel1 sources state from the decider; _classify_channel1 DELETED
├── sync/__init__.py # registered resolver returns the decision-carrying member (one resolve_project_consent)
└── sync/consent.py # resolve_project_consent (the single consent authority — unchanged)
tests/
├── invocation/test_adapters.py # resolve_egress_consent mapping + iterate-all-members permits_egress guard (re-pointed)
├── sync/tracker/test_tracker_egress_verdict_3108.py # enforcement-equivalence matrix, byte-identity, one-resolution count, _classify_channel1 absence (rebuilt from TestReportingSplitNeverFlipsEnforcement)
└── architectural/test_egress_consent_boundary.py # sink-keyed boundary gate (must stay green)
Structure Decision: Single project. All changes live in src/specify_cli/ across the four modules above plus the sync/__init__.py resolver wiring; no new packages, no new storage, no CLI surface change.
Complexity Tracking
No charter violations — section intentionally empty.
Implementation Concern Map
> Concerns are architectural areas, not work packages. /spec-kitty.tasks translates them into WPs.
IC-01 — Decision-carrying consent contract
- Purpose: Widen the port so consent state travels as data, not a bare bool, without opening a grant path.
- Relevant requirements: FR-001, C-001, NFR-003.
- Affected surfaces:
invocation/adapters.py(EgressConsentsplit intoNO_RECORD/RECORDED_REFUSAL/NOT_CONSENTABLEbesideGRANTED/NO_RESOLVER/UNANSWERABLE;resolve_egress_consentmapping keeps "only a recognized grant permits, malformed/bool/None refuses, never raises";permits_egressunchanged),sync/__init__.py(registered resolver returns the member from oneresolve_project_consent+ routing). - Sequencing/depends-on: none (foundation).
- Risks: a mid-migration resolver returning bool/None must refuse and never reach
permits_egress(AttributeError at thepropagatorsink). Every new member must answerpermits_egress is False— pin with the iterate-all-members guard. - Slicing constraint (post-plan m3): removing/splitting
DENIEDinstantly breaks its three consumers — theresolve_egress_consentmapping (adapters.py:208),egress._refusal_for_verdict(egress.py:273), and their tests./spec-kitty.tasksmust keep those atomic in one WP; no intermediate WP may land a non-importable tree.
IC-02 — Single-authority threading into the verdict
- Purpose: Source
channel1_statefrom the sameConsentDecisionthat enforces, resolving consent/routing once. - Relevant requirements: FR-002, FR-003, NFR-004.
- Affected surfaces:
egress.py— a single internal decider_egress_decisionobtains theEgressConsentmember via the existingresolve_egress_consentseam (it performs no local consent/routing resolution and adds nosync.consent/sync.routingimport — the C-004 invariant; the split-mapping lives once, in the resolver, per research Decision 1) and derives(permits, refusal_message, channel1_state, generic)from it;project_egress_refusalbecomes a thinstr | Nonewrapper over it.tracker/egress_verdict.py—_resolve_channel1consumes the decider's tuple;_classify_channel1and its independent routing/consent resolution are deleted;_channel1_report's(state, generic)production is absorbed into the decider and its generic-rendering path is re-sourced (not deleted). - Sequencing/depends-on: IC-01.
- Risks: the post-spec M2 crux (no second resolution, no byte-string perturbation) and the post-plan M2 symmetric never-raise gap — a degraded
channel1_statemust carrygeneric = Trueso the composer's generic branch renders it and the state-keyed_CHANNEL1_DESCRIPTIONS/_REMEDIESdicts are never indexed (elseKeyError, an NFR-003 violation).consented_project_uuidsis retained (≈9 sibling drain/emit/commit callers) and removed only from the egress resolver.
IC-03 — Consumer audit & re-point
- Purpose: Ensure every consumer of the changed contract is correct after the
DENIEDsplit. - Relevant requirements: FR-004, FR-005, NFR-002.
- Affected surfaces:
egress.py::_refusal_for_verdict(theDENIEDbranch re-pointed so all three refusal members render_DENIED_TEMPLATE— preserving hosted byte-identity and thenot_consentablecarried-but-not-rendered carve-out),saas_client/client.py(widen transport refusal string verified unchanged),src/specify_cli/invocation/propagator.py(usespermits_egressonly — verified unaffected), trackersync doctorrenderer (per-destination state/remedy parity, incl. degraded states). - Sequencing/depends-on: IC-01, IC-02.
- Risks: an unmapped new member falling through to
_UNRECOGNISED_VERDICT_TEMPLATEwould change the hosted string (NFR-002 break) though not widen the seam.saas_client's refusal string is not covered by NFR-002's pin — verify it explicitly.propagator.py:130logs the member value ("…egress consent is %s"), so the logged token changesdenied → no_record/recorded_refusal/not_consentable— harmless (enforcement is viapermits_egressat:128), noted so it is not mistaken for a regression (post-plan n1).
IC-04 — Delete the second evaluation & rebuild the guarantee
- Purpose: Remove
_classify_channel1and replace the now-unbuildable disagreement pin with the single-resolution invariant. - Relevant requirements: C-002, C-003, FR-003, SC-004.
- Affected surfaces:
tracker/egress_verdict.py(delete_classify_channel1+ its two non-authoritativeness pins),tests/sync/tracker/test_tracker_egress_verdict_3108.py(TestReportingSplitNeverFlipsEnforcementrebuilt as "exactly one routing/consent resolution on the path" (NFR-004) +_classify_channel1symbol-absence). - Sequencing/depends-on: IC-02.
- Risks: the old pin injected a disagreeing classifier; that premise is gone. The rebuilt pin must assert the property structurally (one resolution) rather than by injection.
IC-05 — Verification matrix
- Purpose: Prove enforcement, byte-identity, and the one-resolution efficiency all hold across the full state space.
- Relevant requirements: NFR-001, NFR-002, NFR-004, SC-001, SC-002, SC-003, SC-005.
- Affected surfaces:
tests/sync/tracker/test_tracker_egress_verdict_3108.py,tests/invocation/test_adapters.py,tests/cli/.../sync doctortests. - Sequencing/depends-on: IC-01..IC-04.
- Risks: SC-001 must include the permit row and consent precedence levels (project-local / machine-index / env), not only the three refusal sub-states, or C-001 goes unmeasured (post-spec M5).
- Checks the tasks phase must land as concrete tests (post-plan): (a) an NFR-003 fail-closed enumeration — each degraded input (bare
bool,None, unrecognized, resolver-import-failure) at theOUTCOME_DEFERbranch → refuses, renders generic wording, and never raises at anypermits_egresssink (closes M2, renata MINOR-1); (b) thesaas_client/client.pywiden-transport refusal string verified unchanged (renata MINOR-2); (c) a C-004 pin thategress.pyholds nosync.consent/sync.routingimport (post-plan M1 — the invariant that has no test today); (d)undeterminedis still produced forroot is Noneafter_classify_channel1deletion (renata NOTE-2).