Skip to content

Consequence gate (Gate IV): co-signature-gated stop_run#552

Merged
xmap merged 2 commits into
mainfrom
worktree-consequence-gate
Jul 7, 2026
Merged

Consequence gate (Gate IV): co-signature-gated stop_run#552
xmap merged 2 commits into
mainfrom
worktree-consequence-gate

Conversation

@xmap

@xmap xmap commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Makes 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 (the same RunHeld the 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)

  1. stop_run decider raises RunRequiresRatificationError (HTTP 409) when the command is in the COMMANDS_REQUIRING_RATIFICATION allowlist and ConsequenceLookup reports no Granted coverage. The handler pre-loads coverage (I/O in handler, pure decider), mirroring start_run's clearance pre-load.
  2. Operator request_ratificationRatificationHoldSubscriber parks the run (RunHeld).
  3. A second principal grant_ratification (independence enforced: granter ≠ requester) → RatificationReleaseSubscriber resumes it.
  4. Operator re-issues stop_run → now covered → admitted.

Subscribers (not a handler co-write) because cora.agent may depend on cora.run.aggregates + cora.trust.aggregates but not cora.run.features. They reuse the kill-switch's Pattern-C shape; the hold discharges into the same RunHeld (the "one shared hold" thesis, instanced directly).

Gate review (4 agents)

0 P0, 2 correctness P1 + test P1s, all addressed:

  • P1 (flagged twice): release resumed any Held run → a stray Granted/Denied could defeat the kill-switch/operator hold. Fixed with a correlation guard (resume only a hold this enforcer placed, via the RunHeld envelope principal).
  • P1: a Denied ratification left the run Held forever. Fixed: release subscribes to RatificationDenied too and auto-resumes (same guard) — the reversible hold stays reversible; coverage never Granted so a re-issued stop still refuses.
  • Test P1s + P2s: foreign-hold-survives, denied-resumes, coverage replay-idempotency, adapter Granted-only negatives, total-gate property, enforcer registration; explicit bool(); 422→409 doc fix.

Depends on

#551 (subscriber-bookmark fix, already merged) — without it these subscribers never fire.

Test plan

  • 43 consequence-gate tests: decider gate arm, coverage projection, both enforcer subscribers (incl. correlation + denial), full Postgres end-to-end (co-sign + denial + requested-still-refuses + replay)
  • pyright + ruff + tach clean; 30,160 unit + architecture tests green
  • CI green

🤖 Generated with Claude Code

xmap and others added 2 commits July 7, 2026 09:03
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>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/agent
  _subscribers.py
  seed_ratification_enforcer.py
  apps/api/src/cora/agent/subscribers
  _ratification_shared.py 58
  ratification_hold.py 71, 77-78, 86-87, 99-100, 120-122
  ratification_release.py 82, 85, 94-95, 103-104, 122-123, 143-145
  apps/api/src/cora/infrastructure/ports
  consequence_lookup.py 72-73
  apps/api/src/cora/run/features/stop_run
  handler.py
  apps/api/src/cora/trust
  routes.py
  tools.py
  apps/api/src/cora/trust/aggregates/ratification
  events.py 133-135
  evolver.py
  read.py
  state.py
  apps/api/src/cora/trust/features/deny_ratification
  decider.py
  handler.py
  route.py
  tool.py
  apps/api/src/cora/trust/features/grant_ratification
  decider.py
  handler.py
  route.py
  tool.py
  apps/api/src/cora/trust/features/request_ratification
  handler.py
  route.py
  tool.py
  apps/api/src/cora/trust/projections
  ratification_coverage.py
Project Total  

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

@xmap xmap merged commit 4cc5949 into main Jul 7, 2026
16 checks passed
@xmap xmap deleted the worktree-consequence-gate branch July 7, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant