Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions apps/api/src/cora/api/_agent_decision_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

The reactive agent subscribers (RunDebriefer, CautionDrafter) sign each
agent-authored `DecisionRegistered` via their own `_maybe_sign` before appending,
per the signing design lock: `DecisionRegistered` is in `SIGNED_EVENT_TYPES`, so
an AI-agent row is signed at write time (an unsigned agent row would trip the
strict audit sweep, `signing.verify_stream(strict=True)` raising
`SignatureMissingError`). Human-actor rows from the operator-driven
`register_decision` slice stay unsigned by design.
per the signing design lock: `DecisionRegistered` is in `SIGNED_EVENT_TYPES`, and
the paths producing agent-attributed rows are exactly the ones the composition
root hands a `Signer`. Human-attributed rows from the operator-driven
`register_decision` slice stay unsigned by design, which is why an audit sweep
passes `verify_stream(must_be_signed=...)` and not a blanket flag: membership in
`SIGNED_EVENT_TYPES` means "signed IF a Signer-wired path produced it", so only
the caller can say which unsigned rows are findings.

The proactive agent runtimes hosted at the composition root (`_run_initiator`,
`_run_supervisor`) also compose `DecisionRegistered` directly (they cannot use
`register_decision`, whose decider rejects AGENT principals), so they need the
same signing step. This module hoists it to one helper both import, rather than
each re-declaring a private `_maybe_sign`. Mirrors
`_run_supervisor`) also compose `DecisionRegistered` directly, so they need the
same signing step. They cannot route through `register_decision`: its decider
refuses rows ATTRIBUTED to an agent-kind Actor, and theirs are. Note that is a
guard on the Actor named by `decided_by`, not on the calling principal, whose
kind no decider ever sees. This module hoists the signing step to one helper both
import, rather than each re-declaring a private `_maybe_sign`. Mirrors
`RunDebrieferSubscriber._maybe_sign` exactly.
"""

Expand Down
21 changes: 16 additions & 5 deletions apps/api/src/cora/decision/features/register_decision/decider.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,22 @@
`register_decision` is the operator-driven slice. Agent-emitted
Decisions go through the subscriber path (CautionDrafter,
RunDebriefer) so the Signer port can sign each row per
[[project_signed_events_design]] (SIGNED_EVENT_TYPES =
{DecisionRegistered}, discriminated at write time by
`actor.kind == AGENT`). The decider refuses `context.actor.kind
== AGENT` with `InvalidActorKindForDecisionError` so the slice
cannot become a signing-bypass route.
[[project_signed_events_design]].

The signing obligation is discriminated by EVENT TYPE, not by actor
kind. Every signing site gates on `signer is None or
new_event.event_type not in SIGNED_EVENT_TYPES` (=
{DecisionRegistered}); none of them reads `Actor.kind`, and
`tests/architecture/test_actor_kind_blindness.py` pins that.

The decider refuses `context.actor.kind == AGENT` with
`InvalidActorKindForDecisionError` so this slice does not offer an
unsigned path for rows that the subscriber path would have signed.
The guard reads the kind of the Actor a Decision is ATTRIBUTED to
(`command.decided_by`), never the authenticated principal, which the
Authorize port structurally cannot observe. Note the guard rests on
honest attribution: `decided_by` is body-supplied and is not
reconciled against `principal_id`.

## VO trim semantics

Expand Down
25 changes: 19 additions & 6 deletions apps/api/src/cora/infrastructure/ports/federation/value_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,18 @@ class SignedOffBy:
"""Human Signed-off-by attribution; the only DCO arm that closes the chain.

Per the Linux-kernel `coding-assistants.rst` invariant transplant,
AI actors cannot Signed-off-by. The federation decider enforces
that `actor_id` resolves to `Actor.kind == human`; an Agent
actor_id raises `DcoChainMissingHumanSignoffError`.
AI actors cannot Signed-off-by.

NOT ENFORCED TODAY. Nothing checks that `actor_id` resolves to
`Actor.kind == human`: no decider reads the kind, and
`DcoChainMissingHumanSignoffError` does not exist. `check_dco_chain`
verifies only that the chain carries at least one `SignedOffBy`
entry, not that its actor is human, so an agent actor_id passes
today. The human-only rule is design intent per
[[project_federation_port_design]], not a shipped check. Building it
would add the first branch on `Actor.kind` outside the two carve-outs
enumerated in `tests/architecture/test_actor_kind_blindness.py`, which
is where the reason for it belongs.
"""

actor_id: UUID
Expand Down Expand Up @@ -125,9 +134,13 @@ class CoDevelopedBy:
"""Collaborative attribution between TWO HUMAN actors.

Mirrors the Linux kernel `CO-DEVELOPED-BY` convention. Both
`actor_id_a` and `actor_id_b` MUST resolve to `Actor.kind ==
human`; Agent actor_ids raise `CoDevelopedByForbidsAgentError`
at the decider.
`actor_id_a` and `actor_id_b` are intended to resolve to
`Actor.kind == human`.

NOT ENFORCED TODAY, same as `SignedOffBy`: no decider reads the
kind and `CoDevelopedByForbidsAgentError` does not exist. Agent
actor_ids pass. Design intent per
[[project_federation_port_design]], not a shipped check.
"""

actor_id_a: UUID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,12 @@ def check_dco_chain(artifact: PublishedArtifact) -> StageResult:

Per project_federation_port_design.md: the Linux-kernel
`coding-assistants.rst` invariant transplant requires at least
one human Signed-off-by. The decider enforces the deeper
invariant (actor_id resolves to Actor.kind == human) at
publish-time; this gate checks the chain shape at verify-time.
one human Signed-off-by. This gate checks the chain SHAPE only.

The deeper invariant (actor_id resolves to Actor.kind == human) is
NOT enforced anywhere: no publish-time decider reads the kind. A
`SignedOffBy` naming an agent actor_id passes this gate. See the
`SignedOffBy` docstring in `ports/federation/value_types.py`.
"""
if not artifact.dco_chain:
return StageResult(
Expand Down
86 changes: 55 additions & 31 deletions apps/api/src/cora/infrastructure/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,16 @@

- `DecisionRegistered`: produced by Agents AND by humans. Per the
design lock the signing requirement applies only to the Agent-
produced rows; the subscriber tier (CautionDrafter and
RunDebriefer today) checks `actor_id` against the Agent BC's
stable-id rule before invoking `Signer.sign`. Human-actor
`DecisionRegistered` rows from the operator-driven
`register_decision` slice stay unsigned. Both AI-agent
produced rows. That split is achieved by WIRING, not by a branch:
the subscriber tier (CautionDrafter and RunDebriefer today) is
handed a `Signer` and signs every row of a type in this set, while
the operator-driven `register_decision` slice is handed none, so
its human-attributed rows stay unsigned. No signing site reads
`Actor.kind`; `tests/architecture/test_actor_kind_blindness.py`
pins that. Membership here therefore means "signed IF a
Signer-wired path produced it", which is why an audit sweep needs
`verify_stream`'s `must_be_signed` predicate to say whether a given
unsigned row is a finding. Both AI-agent
subscribers route through this single entry: CautionDrafter
emits `DecisionRegistered` with `context="CautionProposal"`
(the Caution aggregate itself is created later via the
Expand Down Expand Up @@ -196,7 +201,7 @@ async def verify_stream(
events: Sequence[StoredEvent],
*,
resolve_public_key: Callable[[str], Awaitable[bytes]],
strict: bool = False,
must_be_signed: Callable[[StoredEvent], Awaitable[bool]] | None = None,
) -> None:
"""Verify every signed event in a loaded stream. Raise on first failure.

Expand All @@ -207,34 +212,53 @@ async def verify_stream(
- When `event.signature_kid is not None`, calls `verify_signature`
with the event's payload, signature, and kid. Raises
`SignatureInvalidError` on the first row that fails.
- When `event.signature_kid is None` AND `event.event_type` is in
`SIGNED_EVENT_TYPES` AND `strict=True`, raises
`SignatureMissingError` (audit-mode "no signed event went
unsigned" check).
- When `event.signature_kid is None` and either the event type
is NOT in `SIGNED_EVENT_TYPES` or `strict=False`, the event is
skipped silently. Pre-rollout events and human-actor rows are
legitimately unsigned per the design lock's "AI-agent events
signed, human-actor events not" stance.

The function is event-type-agnostic at the call signature; the
`SIGNED_EVENT_TYPES` check is the registry-lookup that determines
which rows must carry a signature.

Kept as a standalone helper rather than an `EventStore.load` flag
so the port stays signing-unaware. Callers wanting opt-in
verification compose: `events, _ = await store.load(...);
await verify_stream(events, resolve_public_key=...)`.

`strict=True` is the audit-sweep default; production read paths
that just need bytes (projection rebuilds, decider folds) leave it
`False` so they don't pay the verify cost per row.
- When `event.signature_kid is None`, the row is unsigned. Whether
that is a finding or a fact depends on who wrote it, and only the
caller knows: pass `must_be_signed` to say. It is awaited per
unsigned row and raises `SignatureMissingError` when it returns
True. Omit it (the default) and unsigned rows are skipped.

## Why the caller owns the obligation rule

This took a `strict: bool` flag 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 the rows a `Signer`-wired
path produced carry a signature: an operator recording a human decision
through `register_decision` legitimately produces an unsigned
`DecisionRegistered`, and the old flag raised on it. The docstring said
so, a few lines above the code that did the opposite.

Event type alone cannot express "should this row have been signed",
because the answer depends on the Actor the row is attributed to. Rather
than teach this module to load Actors and read their kind, which would
put an authorship branch inside signing infrastructure, the obligation
moves to the caller, who already has a store to read and a reason to
care. An audit sweep supplies something like:

async def _must_be_signed(event: StoredEvent) -> bool:
if event.event_type not in SIGNED_EVENT_TYPES:
return False
actor = await load_actor(store, UUID(event.payload["decided_by"]))
return actor is not None and actor.kind is ActorKind.AGENT

That predicate is exact in both directions: `register_decision` refuses
agent-attributed rows, so an agent-attributed `DecisionRegistered` can
only have come from a `Signer`-wired path. Reading a kind there is
evidence-checking, not authorization, and no shipped caller needs it yet.

Kept as a standalone helper rather than an `EventStore.load` flag so the
port stays signing-unaware. Callers wanting opt-in verification compose:
`events, _ = await store.load(...); await verify_stream(events,
resolve_public_key=...)`.

Production read paths that just need bytes (projection rebuilds, decider
folds) omit `must_be_signed` so they never pay the predicate per row.

Raises:
- `SignatureInvalidError`: a signed event's signature failed
verification (tampering, key rotation drift, key compromise).
- `SignatureMissingError`: only with `strict=True`, an event
whose type is in SIGNED_EVENT_TYPES has no signature.
- `SignatureMissingError`: `must_be_signed` returned True for a row
carrying no signature.
"""
for event in events:
if event.signature_kid is not None:
Expand All @@ -249,7 +273,7 @@ async def verify_stream(
kid=event.signature_kid,
resolve_public_key=resolve_public_key,
)
elif strict and event.event_type in SIGNED_EVENT_TYPES:
elif must_be_signed is not None and await must_be_signed(event):
raise SignatureMissingError(event.event_type)


Expand Down
Loading
Loading