Consequence gate (Gate IV): co-signature-gated stop_run#552
Merged
Conversation
Introduce the Ratification aggregate in the Trust BC: the "must a second principal co-sign?" member of the governance-gate family, the deontic co-action counterpart to the permission gate. It records a second-principal co-signature (four-eyes) for a consequential action, and is the second of two new gates for the "Four Gates, One Log" line. 3-state FSM Requested -> Granted | Denied (Requested genesis, both terminal, no re-open). Three slices: request_ratification (genesis), grant_ratification and deny_ratification (transitions). The load-bearing invariant is independence: the grantor/denier must differ from the requester (RatificationRequesterCannotSelf- RatifyError), enforced in both deciders. Kind-blind by construction: principals are bare UUIDs and no decider reads actor kind, so a human is held for co-sign identically to an agent. The co-signing principal (and the requester on genesis) is the envelope principal_id threaded into the decider by the handler, never a spoofable command field; grant/deny use longhand handlers (not the update-handler factory, which does not pass principal_id) with optimistic concurrency at the loaded version, mirroring supersede_caution. Why dormant: nothing gates on Ratification yet. The consequence-class declaration on Capability, the ConsequenceLookup port, and the decider gate that triggers a request plus the shared-hold discharge are the wiring increment; this lands only the aggregate and its lifecycle, mirroring CORA's mechanism-first pattern, so no existing behavior changes. Full slice-coverage compliance (no deferral allowlist entries): example + property-based decider tests, handler unit tests, REST endpoint contract test, and MCP tool contract tests for all three slices. Genesis event RatificationRequested is an allowlisted Defined/Registered deviation (it names its own Requested lifecycle state, the RunStarted shape); R3 gate-review confirmed. Aggregate count 40 -> 41. Gate review: R3 naming (genesis deviation confirmed; renamed SelfSign -> SelfRatify since the check fires on deny too; target_ref -> target_action_id) + correctness (independence, version handling, serialization all verified clean). Added the to_payload assert_never exhaustiveness guard for house consistency. Co-Authored-By: Claude <noreply@anthropic.com>
Make the dormant Ratification aggregate a live gate. A consequence-classed command (StopRun in v1) is admitted only when a GRANTED Ratification covers (run_id, command_name); absent that, the gate refuses and the run is parked in the shared reversible hold pending a second, independent principal's co-sign. This is the paper's cleanest-survivor gate (Four Gates, One Log): a new gate that costs a fold + a decider arm + reuse of the existing shared hold, not a subsystem. Flow (Option B, subscriber-driven discharge, locked at build): stop_run's decider raises RunRequiresRatificationError (HTTP 409, the sibling Run pre-flight family) when the command is in the COMMANDS_REQUIRING_RATIFICATION allowlist and the ConsequenceLookup reports no Granted coverage; the handler pre-loads coverage (I/O in the handler, pure decider), mirroring start_run's clearance pre-load. Operator then request_ratification; the RatificationHoldSubscriber parks the run (RunHeld); a second principal grant_ratification (independence enforced: granter != requester); the RatificationReleaseSubscriber resumes it; the re-issued stop_run is now covered and admitted. Why subscribers not a handler co-write: cora.agent may depend on cora.run.aggregates + cora.trust.aggregates but NOT cora.run.features (tach boundary). The enforcer subscribers do the hold/resume via Pattern C (load, guard, authorize, append), reusing the shape the kill-switch's authority_revocation_holder shipped, keeping marginal cost constant. The hold discharges into the SAME RunHeld the kill-switch uses (the "one shared hold" thesis, instanced directly). One pinned RatificationEnforcer agent (fac0 block) owns both subscribers, split into ratification_hold.py + ratification_release.py (one factory per module, per the subscriber-completeness convention) over a shared _ratification_shared.py helper. No Decision records (the Ratification events are the provenance). New tach edge cora.agent -> cora.trust.aggregates (release-only load_ratification to resolve ratification_id -> target run). Read side: proj_trust_ratification_coverage projection (folds Requested/Granted/ Denied by ratification_id) + ConsequenceLookup port (Trust ships PostgresConsequenceLookup; test default AlwaysRatifiedConsequenceLookup keeps existing stop_run tests green, NeverRatified exercises the refuse path). Gate review (4 agents: architecture / test-coverage / cross-BC / saga-correctness specialist): 0 P0, 2 correctness P1 + several test P1s, all addressed. - P1 (R4+R1, flagged twice): the release resumed ANY Held run, so a stray Granted/Denied could defeat the kill-switch or an operator hold. Fixed with a correlation guard (last_hold_placed_by_enforcer): the release folds the run's most-recent RunHeld and resumes ONLY when that hold's envelope principal_id is this enforcer; otherwise it stands down. No RunHeld schema change (envelope principal already records who held); a park-marker field is the rule-of-three refactor. - P1 (R4+R2): a Denied ratification left the run Held forever. Fixed: the release subscribes to RatificationDenied too and auto-resumes (same correlation guard), so the reversible hold stays reversible; coverage is never Granted so a re-issued stop still refuses. - Test P1s: foreign-hold-survives, denied-resumes, coverage-projection replay-idempotency, adapter Granted-only negatives (Requested/Denied do not cover), a total-gate property (uncovered stop always refuses for any state/reason), enforcer registration pinned on-by-default. - P2s: explicit bool() on the adapter fetchval; 422->409 doc correction; permanent-coverage noted. Watch (documented, deferred): coverage never expires; gate precedes the None-state check (409 not 404) per the admission-outer lock. Verified: pyright + ruff + tach clean, 43 consequence-gate tests, 30160 unit + architecture tests green. Co-Authored-By: Claude <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
The report is truncated to 25 files out of 44. To see the full report, please visit the workflow summary page. This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the dormant
Ratificationaggregate a live gate: a consequence-classed command (StopRunin v1) is admitted only when a Granted Ratification covers(run_id, command_name). Absent that, the gate refuses and the run is parked in the shared reversible hold (the sameRunHeldthe kill-switch uses) pending a second, independent principal's co-signature.This is the "Four Gates, One Log" paper's cleanest-survivor gate: a new gate that costs a fold + a decider arm + reuse of the existing shared hold, not a subsystem.
Two commits: (1) the dormant Ratification aggregate + slices (already built), (2) the wiring that makes it live.
Flow (Option B — subscriber-driven discharge)
stop_rundecider raisesRunRequiresRatificationError(HTTP 409) when the command is in theCOMMANDS_REQUIRING_RATIFICATIONallowlist andConsequenceLookupreports no Granted coverage. The handler pre-loads coverage (I/O in handler, pure decider), mirroringstart_run's clearance pre-load.request_ratification→RatificationHoldSubscriberparks the run (RunHeld).grant_ratification(independence enforced: granter ≠ requester) →RatificationReleaseSubscriberresumes it.stop_run→ now covered → admitted.Subscribers (not a handler co-write) because
cora.agentmay depend oncora.run.aggregates+cora.trust.aggregatesbut notcora.run.features. They reuse the kill-switch's Pattern-C shape; the hold discharges into the sameRunHeld(the "one shared hold" thesis, instanced directly).Gate review (4 agents)
0 P0, 2 correctness P1 + test P1s, all addressed:
RatificationDeniedtoo and auto-resumes (same guard) — the reversible hold stays reversible; coverage never Granted so a re-issued stop still refuses.bool(); 422→409 doc fix.Depends on
#551 (subscriber-bookmark fix, already merged) — without it these subscribers never fire.
Test plan
🤖 Generated with Claude Code