Skip to content

Pin actor-kind blindness, and fix the audit sweep that contradicted it#566

Merged
xmap merged 5 commits into
mainfrom
worktree-actor-kind-blindness
Jul 16, 2026
Merged

Pin actor-kind blindness, and fix the audit sweep that contradicted it#566
xmap merged 5 commits into
mainfrom
worktree-actor-kind-blindness

Conversation

@xmap

@xmap xmap commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Why

CORA's governance claim is that an agent and a human operator are the same kind of actor to the system. That claim is load-bearing for two in-flight papers, and it was enforced by nothing.

Making it enforced turned up three docstrings asserting checks that do not exist, and one real defect that contradicted the claim outright.

What an AST sweep actually found

Exactly two branches on ActorKind in the whole source tree. Both read the kind of a command's subject, never the kind of the caller:

  • register_actor guards the kind of the Actor being minted, forcing agent genesis through define_agent's cross-BC atomic write so the Agent.id == Actor.id lock holds.
  • register_decision guards the kind of the Actor a Decision is attributed to, so the unsigned operator route offers no unsigned path for rows the Signer-wired path would have signed.

The load-bearing pin is the Authorize signature: principal_id, command_name, conduit_id, surface_id. Policy has no kind field. The policy decision point cannot branch on kind even in principle, so authority is kind-blind structurally rather than by discipline.

Commits

  1. Pin actor-kind blindness — the fitness test. Allowlist asserted in both directions (a deleted branch fails as a stale entry, not silently). Authorize parameter set pinned. Nothing that produces a signature may read ActorKind.
  2. Correct three docstrings that describe enforcement the code does not haveDcoChainMissingHumanSignoffError and CoDevelopedByForbidsAgentError do not exist anywhere in the tree; check_dco_chain verifies only that a SignedOffBy entry is present, never that its actor is human, so an agent actor_id passes today.
  3. Key the signing rule on producing a signature, not on importing the registry — Rule 3 was wrong in both directions: the two subscriber sites take their Signer by constructor and never import the port, while signing.py defines the registry and so escaped the rule meant to guard it. Keying on an actual .sign(...) call pins 7 sites where it pinned 4.
  4. Let the audit caller say which unsigned rows are findingsverify_stream's strict: bool raised on any unsigned row whose type is in SIGNED_EVENT_TYPES, but human-recorded DecisionRegistered rows are legitimately unsigned. Replaced with a caller-supplied must_be_signed predicate.

The framing this settles

Three axes, not one:

  • Authority is kind-blind, structurally.
  • Attribution is kind-aware, on subject kind.
  • Evidentiary obligation is kind-aware, carried by the event vocabulary and by which paths are wired a Signer, never by a branch inside signing code.

"Sign everything" was evaluated and rejected: a human at a beamline holds no key, so the server would sign, which attests only that the server wrote the row down. That is what the envelope principal_id already attests.

Verification

Every rule mutation-tested rather than trusted green. Injected ActorKind comparison, bare-string compare, and match probes into the PDP: all caught. Widened Authorize with a principal_kind parameter: caught. Regressed verify_stream to type-only logic: fails exactly the two regression tests and nothing else.

Architecture tier 29533 passed, 588 skipped. Infrastructure tier 449 passed.

Known, deliberately not addressed

  • decided_by is body-supplied and not reconciled against principal_id. This is designed (the route documents any Actor, "including Deactivated (historical fact still holds)"), and the envelope records the submitter. The residual is transparency: proj_decision_summary carries decided_by but not the recording principal.
  • No must_be_signed predicate ships. There are zero audit callers, and writing one now would invent a consumer.
  • Today's agent signature is hollow (InMemorySigner ignores actor_id, one ephemeral key), but _enforce_production_signing_posture refuses to boot production wired to it.

🤖 Generated with Claude Code

xmap and others added 4 commits July 16, 2026 11:52
CORA's governance claim is that an agent and a human operator are the same
kind of actor to the system: every gate decides over a bare principal and
none asks whether that principal is a machine. That claim was asserted in
prose and enforced by nothing, so a single careless branch could falsify it
silently.

An AST sweep over the git-tracked source finds exactly two branches on
ActorKind, and both are deliberate: register_actor guards the kind of the
Actor being minted (never the caller) to force agent genesis through
define_agent's atomic write, and register_decision guards the kind of the
Actor a Decision is attributed to (never the authenticated principal) so the
unsigned operator route cannot become a signing-bypass door. Both are
enumerated with their reason, and the allowlist is asserted in both
directions so a deleted branch fails as a stale entry rather than rotting.

The load-bearing pin is the Authorize signature. The policy decision point
takes principal_id, command_name, conduit_id, surface_id, and Policy carries
no kind field, so authorization is kind-blind structurally rather than by
discipline. Widening that signature would make the claim false, so the
parameter set is locked.

The register_decision reason records the rule-of-three trigger: a third
carve-out means the discriminator belongs in Policy data, not in machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…have

Each of these asserts a kind check that no code performs, which is worse than
silence: a reader trusts them, and a reviewer who greps finds documentation
describing an unimplemented human-only gate sitting next to CORA's
kind-blindness claim.

register_decision's decider said SIGNED_EVENT_TYPES is "discriminated at write
time by actor.kind == AGENT". It is not. Every signing site gates on
`signer is None or event_type not in SIGNED_EVENT_TYPES` and none reads
Actor.kind, so the signing layer is more kind-blind than the prose claimed.
The docstring now also records that the attribution guard rests on honest
declaration, since decided_by is body-supplied and never reconciled against
principal_id.

SignedOffBy and CoDevelopedBy said a federation decider enforces
`Actor.kind == human` and raises DcoChainMissingHumanSignoffError /
CoDevelopedByForbidsAgentError. Neither error class exists anywhere in the
tree, no decider reads the kind, and check_dco_chain verifies only that the
chain carries a SignedOffBy entry, never that its actor is human. An agent
actor_id passes today. The human-only rule stays as stated design intent from
the Linux-kernel coding-assistants transplant, now marked unenforced, with a
pointer to the fitness test where the reason for a third kind branch would
have to be argued.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…egistry

Two defects, both found by adversarially evaluating the rule against the fix
it will have to permit.

Rule 3 keyed on files importing SIGNED_EVENT_TYPES as a proxy for "signing
site". It was wrong in both directions. The two subscriber sites take their
Signer by constructor and never import the port, so they were pinned only by
accident of also importing the registry; and signing.py DEFINES the registry
rather than importing it, so the one file holding the verifier was exempt from
the rule meant to guard it. Meanwhile an innocent read-side obligation
predicate that imports the registry and signs nothing would have been caught.
Keying on an actual `<x>.sign(...)` call fixes both: it pins seven sites where
it previously pinned four, adding the calibration signing path and both
adapters, and it frees the verify side.

The allowlist's rule-of-three trigger was counting the wrong axis. Both
entries read the kind of a command's SUBJECT (the Actor being minted, the
Actor being attributed), and the trigger told a future reader to move the
discriminator into Policy data. That is a category error: Policy governs what
a CALLER may do, and the caller-kind count is zero and structurally pinned by
Rule 2. A subject-kind read has no business in Policy. The module docstring
now states the subject/caller distinction the whole file turns on, and the
allowlist says plainly that a caller-kind branch is not allowlistable at all.

Verified by mutation both ways: a kind read inside a signing site fails Rule 3,
while the same read inside signing.py's verifier does not, since verification
reading a subject's kind to decide whether an absent signature is an anomaly is
evidence-checking rather than a gate. Rule 1 still enumerates it, which is the
intended division of labour and is now spelled out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
verify_stream took a `strict: bool` meaning "raise on any unsigned row whose
type is in SIGNED_EVENT_TYPES". Event type is the wrong predicate.
DecisionRegistered is in that set, but only rows a Signer-wired path produced
carry a signature: an operator recording a human decision through
register_decision legitimately emits an unsigned DecisionRegistered, and the
flag raised on it. The function's own docstring said human rows are legitimately
unsigned about thirty-five lines above the code that raised on them.

Event type alone cannot express "should this row have been signed", because the
answer depends on the Actor the row is attributed to. Teaching this module to
load Actors and read their kind would put an authorship branch inside signing
infrastructure, which is the one place it must not go. So the obligation moves
out to the caller, who already has a store to read and a reason to care, as a
`must_be_signed` predicate: absent, unsigned rows are skipped as before;
supplied, it decides per row. One parameter instead of a boolean plus a registry
lookup, and the state that produced the bug is now unrepresentable.

No production caller exists, so this is a choice about what to build rather than
an outage, and no predicate ships with it: writing one before an audit sweep
needs it would invent a consumer. The docstring carries the exact shape a
correct one takes.

Two docstrings nearby claimed enforcement the code does not have. The
SIGNED_EVENT_TYPES registry said the subscriber tier "checks actor_id" before
signing; it does not, the split is achieved by which paths the composition root
hands a Signer. And _agent_decision_signing said register_decision's decider
"rejects AGENT principals"; it refuses rows ATTRIBUTED to an agent-kind Actor,
never the calling principal, whose kind no decider sees. That distinction is the
whole point of the sibling fitness test, so leaving it inverted here would have
undone the lesson two files away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
xmap added a commit that referenced this pull request Jul 16, 2026
`assert authz["median"] < 5.0` is red on main-merged PRs. It failed at 5.418 and
5.453 on PR #566, whose diff cannot touch authz at all, minutes after passing on
main with the same test. Same code, different runner.

The ceiling was never careless, and that is the interesting part:

  developer machine   0.253us   what it was calibrated against
  old ceiling         5.0us     ~20x the dev number; reads as generous
  shared CI runner    ~5.44us   ~21x SLOWER than dev for this in-memory call

Written on a machine where it had 20x headroom, it lands a hair under the actual
cost on a runner that is ~21x slower at a no-I/O `Policy.evaluate`. Nobody would
guess that ratio, which is why the number survived review. Main cleared it by
luck; the next PR did not.

Assert the shape of the claim instead. Policy.evaluate is an in-memory decision,
so it costs microseconds; a regression that matters means someone put a lookup on
the decision path, and that costs milliseconds. Between runner noise and a real
regression sit three orders of magnitude, so any ceiling in that gap catches
everything this test exists to catch while ignoring which CPU GitHub assigned.
50us is ~200x the dev median, ~9x the CI median, and mirrors the headroom the
replay floor already had (78k/s observed against a 10k/s floor) which is exactly
why that assertion never flaked and this one did.

The paper's numbers are unaffected: they come from a deliberate CORA_PERF_OUT run
on the developer testcontainer, not from this guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
xmap added a commit that referenced this pull request Jul 16, 2026
…567)

`assert authz["median"] < 5.0` is red on main-merged PRs. It failed at 5.418 and
5.453 on PR #566, whose diff cannot touch authz at all, minutes after passing on
main with the same test. Same code, different runner.

The ceiling was never careless, and that is the interesting part:

  developer machine   0.253us   what it was calibrated against
  old ceiling         5.0us     ~20x the dev number; reads as generous
  shared CI runner    ~5.44us   ~21x SLOWER than dev for this in-memory call

Written on a machine where it had 20x headroom, it lands a hair under the actual
cost on a runner that is ~21x slower at a no-I/O `Policy.evaluate`. Nobody would
guess that ratio, which is why the number survived review. Main cleared it by
luck; the next PR did not.

Assert the shape of the claim instead. Policy.evaluate is an in-memory decision,
so it costs microseconds; a regression that matters means someone put a lookup on
the decision path, and that costs milliseconds. Between runner noise and a real
regression sit three orders of magnitude, so any ceiling in that gap catches
everything this test exists to catch while ignoring which CPU GitHub assigned.
50us is ~200x the dev median, ~9x the CI median, and mirrors the headroom the
replay floor already had (78k/s observed against a 10k/s floor) which is exactly
why that assertion never flaked and this one did.

The paper's numbers are unaffected: they come from a deliberate CORA_PERF_OUT run
on the developer testcontainer, not from this guard.

Co-authored-by: xmap <16776958+xmap@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  _agent_decision_signing.py
  apps/api/src/cora/infrastructure
  signing.py
  apps/api/src/cora/infrastructure/published_artifact
  _stages.py
Project Total  

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

@xmap
xmap merged commit 56be8c8 into main Jul 16, 2026
16 checks passed
@xmap
xmap deleted the worktree-actor-kind-blindness branch July 16, 2026 13:58
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