Skip to content

Kill-switch: revoke a grant, hold the revoked principal's runs (K1-K3)#547

Open
xmap wants to merge 3 commits into
mainfrom
worktree-kill-switch
Open

Kill-switch: revoke a grant, hold the revoked principal's runs (K1-K3)#547
xmap wants to merge 3 commits into
mainfrom
worktree-kill-switch

Conversation

@xmap

@xmap xmap commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Builds the kill-switch end to end: revoking a principal's policy grant now automatically holds the in-flight Runs that principal can no longer be trusted to drive. Three stacked slices:

  • K1 (4b6b30da83) revoke_grant on the Policy aggregate emits PolicyGrantRevoked, the trigger event.
  • K2 (cddfb36c04) an actor-involvement resolver folds the Run event log into proj_run_actor_involvement, answering "which in-flight Runs does principal P drive?" (starter-only for v1, envelope-principal attribution, in-flight = Running/Held).
  • K3 (19183683d4) the AuthorityRevocationHolder subscriber reacts to PolicyGrantRevoked, holds each Running run, and records one Decision(context=AuthorityRevocationHold) per run.

CORA's first fully-autonomous enforcement action. Three user-locked design choices for K3: on by default (a kill-switch that must be turned on is not a kill-switch; holds are reversible), own pinned agent + a Decision per hold, Running-only.

Design notes

  • The hold is a Pattern C cross-BC write (load Run + guard Running + authorize + append RunHeld), not a call to the hold_run slice: cora.agent may depend on cora.run.aggregates but not cora.run.features (tach boundary). Same shape CautionPromoter uses for Caution.
  • Fail-safe: every non-hold path (missing / already-Held / terminal / Authorize Deny / lost race) folds to HoldDeferred, never raised; per-run failures are isolated so one faulted run cannot abandon its siblings.
  • Kind-blind, non-spoofable: human and agent runs held by the same code; hold + Decision stamped with the holder's own id; the revoked principal is only a lookup key.

Gate reviews

  • K2: 4-agent panel, 0 P0 / 3 P1 (all addressed).
  • K3: 4-agent panel incl. a security/authority specialist, 0 P0 / 4 P1 (all addressed). Security verdict: the autonomous-authority grant is safely bounded (authz-gated, non-spoofable, fail-safe against over-holding, reversible).

Deferred (noted, not blocking)

  • The broad apply() swallow advances the shared bookmark on a transient fault, so a dropped run is not redelivered. The holder is the 4th Reaction, which fires the previously-deferred "operator escape-hatch / wedged-bookmark" framework-widening trigger.
  • Under bootstrap AllowAllAuthorize, holds are ungated until TrustAuthorize is wired.

Test plan

  • pyright + ruff clean, tach validated
  • K2: 24 tests (unit + Postgres lookup/projection)
  • K3: 15 holder-subscriber unit + 6 registration + 4 vocab + 3 Postgres end-to-end
  • 28,876 unit + architecture tests green
  • CI green on the PR

🤖 Generated with Claude Code

xmap and others added 3 commits July 5, 2026 19:29
Recover the grant-revocation slice on the Policy aggregate (Trust BC): the
kill-switch's first increment. RevokePolicyGrant drops one principal from a
Policy's permitted_principal_ids allow-list and emits PolicyGrantRevoked, the
trigger event a later subscriber (K3) reacts to by holding the revoked
principal's in-flight runs.

Rebuilt from the recovered design of a prior slice whose source was lost (only
stale bytecode remained; not on main). Decider is set-membership removal,
SILENTLY IDEMPOTENT (returns [] when the principal is already absent, no error,
no 409 since Policy has no status FSM); PolicyNotFoundError when the Policy does
not exist; InvalidPolicyGrantRevokeReasonError on a bad reason (validated before
the membership no-op so a malformed command cannot bypass validation via an
absent target). Kind-blind: revoking a human's grant and an agent's grant run
the same code; no decider reads actor kind.

The invoker is the envelope principal, threaded by the handler and stamped as
PolicyGrantRevoked.revoked_by (unspoofable), distinct from the command's
permitted_principal_id (the grant removed). The mid-run hold is deliberately a
SEPARATE eventually-consistent subscriber (K3), kept out of this handler per the
compensation-slice no-cascade lock. Authorization is command-level, not
per-Policy (the intended kill-switch posture, matching define_policy).

Naming (R3 gate-review): command carries the aggregate qualifier
(RevokePolicyGrant) as a per-aggregate sub-concept, while the slice dir + MCP
tool drop it (revoke_grant); grant added to the sub-concept domain-noun allowlist
+ documented in conventions.md. Field named permitted_principal_id (ties to the
state field) to avoid overloading the ambient invoker principal_id.

Full slice-coverage compliance (no deferral allowlist entries): decider example
+ property-based tests, handler unit test, REST endpoint + MCP tool contract
tests; Policy events + evolver tests extended for PolicyGrantRevoked.

Gate review: correctness (APPROVE, no P0/P1) + R3 naming (applied). Verified:
pyright + ruff clean, 24 slice tests, 27922 architecture, 3800 trust+contract.

Co-Authored-By: Claude <noreply@anthropic.com>
…ookup (K2)

Fold the Run event log into proj_run_actor_involvement so the authority-
revocation holder subscriber (K3) can resolve a revoked principal's in-flight
runs and hold each. Answers "which in-flight Runs does principal P drive?".

The starter of a Run is the RunStarted event's ENVELOPE principal_id (the
Authorize-gated issuer), not a payload field, so the projection attributes on
event.principal_id and stamps created_at from the envelope occurred_at (both
deterministic, so ORDER BY created_at is stable across rebuilds). A RunStarted
with no envelope principal (pre-hook / backfilled) is skipped: no starter to
attribute. Lifecycle events fold status by run_id via one status map (RunHeld
-> Held, RunResumed -> Running, and the four terminals); the lookup filters to
in-flight (status IN Running, Held). Terminal rows are retained, not deleted,
so the resolver stays a pure fold and past involvement is auditable.

Involvement scope is STARTER-ONLY for v1, structured extensible: the
involvement_role column carries 'starter' and reserves 'supervisor' for a later
additive fold (the HoldRun / ResumeRun envelope principals) that widens "drives"
to "started or supervises" with no schema change.

Kind-blind: attribution and the fold never read actor kind; a human's runs and
an agent's runs resolve through the same code. The port is consumer-shaped
(runs_driven_by), lives in cora.infrastructure.ports for neutral cross-BC
access, and the Run BC ships PostgresRunActorInvolvementLookup; test envs
default to NoInvolvementLookup (returns []), mirroring the ClearanceLookup /
SupplyLookup test-default pattern. No AuthzResult widening, no cascade: this is
a pure read-model + consumer port, the hold logic lands in K3.

Gate review (4 agents: architecture / test-coverage / cross-BC / event-fold
specialist): 0 P0, 3 P1, all addressed. P1s: port re-exported from
ports/__init__.py (was imported from the submodule, breaking the neutral-access
convention); idempotent replay-from-zero test added (guards ON CONFLICT DO
NOTHING + set-to-constant UPDATEs); lifecycle-event-without-RunStarted test
added (harmless zero-row no-op). P2s folded in: read created_at from the
envelope occurred_at not payload (a missing payload key would have stalled the
fold, blinding the kill-switch); collapsed RunResumed into the single status
map (dropped the duplicate SQL); aligned the supervisor-widening note across
port / projection / migration; created_at-primary ordering test + SQL-identity
assertions. Watch: unconditional lifecycle UPDATE-by-run_id could revive a
terminal row under a malformed stream; left as-is to match RunSummaryProjection
precedent (the aggregate FSM prevents it), replay-safety verified holds since
per-stream event order is total under the worker's (transaction_id, position).

Verified: pyright + ruff clean, tach validated, 24 K2 tests (unit + integration
against real Postgres), 28619 architecture tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
…(K3)

Complete the kill-switch (K1 trigger -> K2 resolver -> K3 holder). The
AuthorityRevocationHolder is a deterministic Agent-BC subscriber: on a committed
PolicyGrantRevoked it asks the K2 involvement lookup for the revoked principal's
in-flight Runs and holds each Running one, recording one
Decision(context=AuthorityRevocationHold, choice in {Held, HoldDeferred}) per run
parented to the revocation event. This is CORA's first fully-autonomous
enforcement action: an operator revoking trust now immediately contains the runs
that principal can no longer be trusted to drive.

Three user-locked design decisions: (1) ON BY DEFAULT (registered
unconditionally, no llm/setting gate: a kill-switch that must be turned on is not
a kill-switch; the hold is reversible via resume_run, so default-on matches the
safety intent); (2) own pinned agent + a Decision record per hold (full
provenance, AuthorityRevocationHolder in the b111 id block); (3) Running-only.

The hold is a Pattern C cross-BC write, not a call to the hold_run slice:
cora.agent may depend on cora.run.aggregates but NOT cora.run.features (the tach
BC boundary; the RunSupervisor loop is exempt only because it lives in cora.api).
So the subscriber loads the Run, guards status == Running in-process (mirroring
hold_run's own decider), authorizes HoldRun as its own principal via the Authorize
port, constructs RunHeld from cora.run.aggregates, and appends with optimistic
concurrency, exactly the pattern CautionPromoter uses for CautionRegistered.

Fail-safe by construction: every non-hold path (missing run, already-Held or
terminal, Authorize Deny, lost concurrency race) folds to HoldDeferred and is
recorded, never raised. Per-run failures are isolated in the fan-out loop so one
faulted run cannot abandon its siblings (silent partial enforcement is a
kill-switch's worst mode). Kind-blind: a revoked human's runs and a revoked
agent's runs are held by the same code; the revoked principal is only a lookup
key and never touches attribution (the hold + Decision are stamped with the
holder's own id, unspoofable). Operator stand-down uses the same
load_actor().active surface as every sibling agent. Idempotent on re-delivery:
deterministic per-(revocation, run) Decision ids + the Running-only guard +
ConcurrencyError-as-noop.

Gate review (4 agents: architecture / test-coverage / cross-BC / security+
authority specialist): 0 P0, 4 P1, all addressed. Security verdict: the
autonomous-authority grant is safely bounded (authz-gated, non-spoofable,
fail-safe against over-holding, reversible; residual risk is under-enforcement,
not over-reach). P1s: (a) per-run loop isolation so a fault holding one run does
not drop siblings [R4+R1, flagged twice]; (b) stand-down via load_actor().active
to match the sibling operator-deactivation surface [R3]; (c) registration test
pinning the unconditional on-by-default contract [R2]. P2s folded in: seed +
state.py prose corrected to Pattern C (no hold_run / RunCannotHoldError), the
ki11->b111 mnemonic reconciled, the deterministic-id namespace moved to the
sibling b1110002 suffix, and tests added for mixed-disposition fan-out, the
lost-race branch, decision-id distinctness across revocations, and
malformed-payload swallow. Watch (deferred, noted for the next slice): the broad
apply() swallow advances the shared bookmark on a transient fault, so a dropped
run is not redelivered; the holder is the 4th Reaction, which fires the
"wedged-bookmark operator escape-hatch" widening trigger. Also: under the
bootstrap AllowAllAuthorize, holds are ungated until TrustAuthorize is wired.

Verified: pyright + ruff clean, tach validated, 15 holder-subscriber unit tests +
6 registration + 4 vocab + 3 Postgres end-to-end (revoke holds the run + records
the Decision; another principal's run left Running; already-held -> HoldDeferred),
28876 unit + architecture tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 6, 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
  __init__.py
  _subscribers.py
  seed_authority_revocation_holder.py
  apps/api/src/cora/agent/subscribers
  authority_revocation_holder.py
  apps/api/src/cora/api
  main.py
  apps/api/src/cora/decision/aggregates/decision
  state.py
  apps/api/src/cora/infrastructure
  deps.py
  apps/api/src/cora/infrastructure/ports
  run_actor_involvement_lookup.py 56-57
  apps/api/src/cora/infrastructure/projection
  bookmark.py
  worker.py
  apps/api/src/cora/run
  _projections.py
  apps/api/src/cora/run/adapters
  __init__.py
  postgres_run_actor_involvement_lookup.py
  apps/api/src/cora/run/projections
  actor_involvement.py
  apps/api/src/cora/trust
  routes.py
  tools.py
  apps/api/src/cora/trust/aggregates/policy
  events.py
  evolver.py
  state.py
  apps/api/src/cora/trust/features/revoke_grant
  __init__.py
  command.py
  decider.py
  handler.py
  route.py
  tool.py
Project Total  

The report is truncated to 25 files out of 29. To see the full report, please visit the workflow summary page.

This report was generated by python-coverage-comment-action

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