Contracts
egress-consent-contract.md
Contract: Egress-Consent Decision Surfaces
No HTTP/REST surface — these are the internal Python contracts the mission changes. Each row is a behavioural contract a test pins.
1. Registered resolver (sync/__init__.py)
- Before:
Callable[[Path], bool]— returns grant/deny only. - After: returns a decision-carrying value the port maps to an
EgressConsentmember, computed from oneresolve_project_consent+ one routing resolution. - Contract: given a project state, returns the member per the data-model mapping; never raises; a missing resolver ⇒
NO_RESOLVER.
2. resolve_egress_consent (invocation/adapters.py)
- Contract: maps the resolver's return to
EgressConsent. Only a recognized grant permits; a barebool,None, or any unrecognized value ⇒ a refusing member (UNANSWERABLE), never a permit and never a raise. A raw non-EgressConsentvalue must never reach apermits_egresscall. - Pinned by: re-pointed
test_resolve_egress_consent_*(incl. theNone/stale-answer refusal) + the iterate-all-memberspermits_egressguard.
3. _egress_decision + project_egress_refusal (egress.py)
_egress_decision(root, identifiers) -> EgressDecision(permits, refusal_message, channel1_state, generic)— obtains theEgressConsentmember viaresolve_egress_consent(§2) and derives all four fields from it. It performs no local consent/routing resolution and adds nosync.consent/sync.routingimport toegress.py(C-003/C-005): the single resolution + split-mapping live in the registered resolver (§1). Degraded members setgeneric = True; import-failure preserves_IMPORT_FAILURE_TEMPLATE's{exc}text asrefusal_message.project_egress_refusal(root, identifiers) -> str | None— thin wrapper =_egress_decision(...).refusal_message. Unchanged public contract; its consumers (saas_client/client.py, others) see no difference._refusal_for_verdict— theDENIEDbranch re-pointed soNO_RECORD/RECORDED_REFUSAL/NOT_CONSENTABLEall render_DENIED_TEMPLATE(no fall-through to_UNRECOGNISED_VERDICT_TEMPLATE).- Contract: the HOSTED_SERVICE refusal string is byte-identical across all three refusal members (NFR-002).
4. egress_verdict._resolve_channel1 (tracker/egress_verdict.py)
- After: consumes
_egress_decision's(permits, refusal_message, channel1_state, generic)._classify_channel1and its independent routing/consent resolution are deleted;_channel1_report's(state, generic)production is absorbed here and its generic-rendering path re-sourced (not deleted). - Contract:
refused/refusing_channelsare computed exactly as before frompermits;channel1_statecomes from the same decision; a degraded state carriesgeneric = Trueso_channel1_decided_messagerenders generic wording and never indexes the state-keyed dicts (noKeyError— NFR-003); the verdict resolves consent/routing exactly once (NFR-004).
Consumers verified unaffected (FR-004)
propagator.py— readspermits_egressonly; a refusing member behaves asDENIEDdid. Must remain raise-free (NFR-003).saas_client/client.py— consumesproject_egress_refusal'sstr | None; unchanged.