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
114 changes: 114 additions & 0 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -13421,6 +13421,30 @@
"title": "RevokeCredentialBody",
"type": "object"
},
"RevokeGrantRequest": {
"description": "Body for `POST /policies/{policy_id}/revoke-grant`.\n\n`permitted_principal_id` is the grant to remove from the Policy's allow-list.\n`reason` is operator-supplied free text (audit-log breadcrumb; also\nfeeds the downstream mid-run compensation Decision). MUST NOT contain PII.",
"properties": {
"permitted_principal_id": {
"description": "Principal (UUID) whose grant is revoked from the policy.",
"format": "uuid",
"title": "Permitted Principal Id",
"type": "string"
},
"reason": {
"description": "Operator-supplied reason for the revocation (audit-log breadcrumb; no PII).",
"maxLength": 500,
"minLength": 1,
"title": "Reason",
"type": "string"
}
},
"required": [
"permitted_principal_id",
"reason"
],
"title": "RevokeGrantRequest",
"type": "object"
},
"RevokePermitRequest": {
"description": "Body for `POST /federation/permits/{permit_id}/revoke`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining why the Permit is being revoked. Examples: \"peer\nfacility decommissioned\", \"credential compromise\", \"policy\nchange ended sharing agreement\".",
"properties": {
Expand Down Expand Up @@ -36668,6 +36692,96 @@
]
}
},
"/policies/{policy_id}/revoke-grant": {
"post": {
"operationId": "post_policies_revoke_grant_policies__policy_id__revoke_grant_post",
"parameters": [
{
"description": "Target Policy's id.",
"in": "path",
"name": "policy_id",
"required": true,
"schema": {
"description": "Target Policy's id.",
"format": "uuid",
"title": "Policy Id",
"type": "string"
}
},
{
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"in": "header",
"name": "X-Principal-Id",
"required": false,
"schema": {
"anyOf": [
{
"format": "uuid",
"type": "string"
},
{
"type": "null"
}
],
"description": "Legacy principal-id header (trust-the-proxy shape). When IDENTITY_PROVIDERS is configured (bearer-auth mode), this header is IGNORED and the verified bearer token from `BearerAuthMiddleware` (Authorization: Bearer) sets the principal. When no IdPs are configured (legacy mode), the application TRUSTS this header (no cryptographic verification) -- production deployments in legacy mode MUST front the API with an auth proxy that strips any client-supplied X-Principal-Id and sets it to the verified principal UUID. Behavior when absent: see Settings.require_authenticated_principal.",
"title": "X-Principal-Id"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RevokeGrantRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "Successful Response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Domain invariant violated (whitespace-only reason)."
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "Authorize port denied the command."
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "No Policy exists with the given id."
},
"422": {
"description": "Request body failed schema validation."
}
},
"summary": "Revoke one principal's grant from a Policy",
"tags": [
"trust"
]
}
},
"/practices": {
"get": {
"operationId": "list_practices_practices_get",
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/cora/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
)
from cora.agent.routes import register_agent_routes
from cora.agent.seed import seed_run_debriefer_agent
from cora.agent.seed_authority_revocation_holder import (
AUTHORITY_REVOCATION_HOLDER_AGENT_ID,
seed_authority_revocation_holder_agent,
)
from cora.agent.seed_calibration_watcher import (
CALIBRATION_WATCHER_AGENT_ID,
seed_calibration_watcher_agent,
Expand Down Expand Up @@ -79,6 +83,7 @@
from cora.agent.wire import AgentHandlers, wire_agent

__all__ = [
"AUTHORITY_REVOCATION_HOLDER_AGENT_ID",
"CALIBRATION_WATCHER_AGENT_ID",
"CAMPAIGN_WATCHER_AGENT_ID",
"CAUTION_PROMOTER_AGENT_ID",
Expand All @@ -100,6 +105,7 @@
"register_agent_routes",
"register_agent_subscribers",
"register_agent_tools",
"seed_authority_revocation_holder_agent",
"seed_calibration_watcher_agent",
"seed_campaign_watcher_agent",
"seed_caution_drafter_agent",
Expand Down
43 changes: 34 additions & 9 deletions apps/api/src/cora/agent/_subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@

## Conditional registration

If `kernel.llm is None` (`ANTHROPIC_API_KEY` unset), no subscribers
are registered and a warning is logged. The alternative (raising at
app startup) would refuse to boot a deployment that wants to defer
Agent rollout.
The deterministic subscribers (AuthorityRevocationHolder,
CautionPromoter) register independently of `kernel.llm`. The
LLM-backed subscribers (RunDebriefer, CautionDrafter) register only
when `kernel.llm` is wired (`ANTHROPIC_API_KEY` set); if it is None
they are skipped with a warning rather than refusing to boot a
deployment that wants to defer Agent rollout.

## Registered subscribers

- `AuthorityRevocationHolderSubscriber` — DETERMINISTIC (no LLM),
the kill-switch (K3): on a `PolicyGrantRevoked`, holds each
in-flight Run the revoked principal drives (Running only) and
records one `Decision(context="AuthorityRevocationHold")` per run.
Registered UNCONDITIONALLY, on by default (a kill-switch that must
be turned on is not a kill-switch; the hold is reversible).
- `RunDebrieferSubscriber` — terminal Run -> one
advisory Decision with AAR narrative + 6-value choice.
- `CautionDrafterSubscriber` — terminal Run -> one
Expand All @@ -32,10 +40,10 @@
Caution + one `DecisionRegistered(context="CautionPromotion")`.
Gated by `settings.caution_promoter_enabled` (default off).

Both subscribers run concurrently and INDEPENDENTLY in the
projection worker. Both classify as `Reaction` (the public sibling
to `Projection`) and pin `batch_size = 1` on the class so the
worker bounds the bookmark transaction to a single LLM round-trip.
The subscribers run concurrently and INDEPENDENTLY in the projection
worker. Each classifies as `Reaction` (the public sibling to
`Projection`) and pins `batch_size = 1` on the class so the worker
bounds the bookmark transaction to a single unit of work.

## Subscriber framework widening status

Expand All @@ -57,6 +65,9 @@

from typing import TYPE_CHECKING

from cora.agent.subscribers.authority_revocation_holder import (
make_authority_revocation_holder_subscriber,
)
from cora.agent.subscribers.caution_drafter import make_caution_drafter_subscriber
from cora.agent.subscribers.caution_promoter import make_caution_promoter_subscriber
from cora.agent.subscribers.run_debriefer import make_run_debriefer_subscriber
Expand All @@ -71,8 +82,22 @@

def register_agent_subscribers(registry: ProjectionRegistry, deps: Kernel) -> None:
"""Register Agent BC's subscribers into the projection-worker registry."""
# AuthorityRevocationHolder is the kill-switch (K3): DETERMINISTIC (no LLM)
# and registered UNCONDITIONALLY, on by default. A kill-switch that must be
# turned on is not a kill-switch; the hold it issues is reversible (resume_run
# exists), so default-on matches the safety intent. Its gate is a fast
# deterministic lookup (no LLM round-trip), so like CautionPromoter it does
# not warrant the deferred ReactionWorker pool split.
holder = make_authority_revocation_holder_subscriber(deps)
registry.register(holder)
_log.info(
"agent_subscriber.registered",
subscriber=holder.name,
subscribed_event_types=sorted(holder.subscribed_event_types),
)

# CautionPromoter is DETERMINISTIC (no LLM), so it registers independently of
# ANTHROPIC_API_KEY, gated by its own off-by-default setting. It is the 3rd
# ANTHROPIC_API_KEY, gated by its own off-by-default setting. It is a
# projection-worker Reaction; its gate is a fast deterministic check (no
# 5-15s LLM round-trip), so it does not warrant the deferred ReactionWorker
# pool split that the LLM Reactions' latency motivated.
Expand Down
119 changes: 119 additions & 0 deletions apps/api/src/cora/agent/seed_authority_revocation_holder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Bootstrap-time seed for the AuthorityRevocationHolder Agent (kill-switch K3).

The AuthorityRevocationHolder is a DETERMINISTIC (non-LLM) subscriber: it reacts
to `PolicyGrantRevoked` and holds each in-flight Run the revoked principal drives
(appending RunHeld directly via Pattern C, guarding Running in-process, NOT
calling the hold_run slice, which is off-limits across the tach BC boundary),
recording one `Decision(context=AuthorityRevocationHold)` per run. It needs an
Agent record (and its co-registered Actor) at the pinned
`AUTHORITY_REVOCATION_HOLDER_AGENT_ID` so it can author Decisions
(`decided_by = ActorId(...)`) and hold Runs as an agent-kind principal.

Mirrors `cora.agent.seed_run_supervisor.seed_run_supervisor_agent` verbatim
except for the per-agent constants below; the shared scaffolding lives in
`cora.agent._agent_seed`.

Per the kill-switch design (K3, [[project-budget-bc-research]] sibling work in
the T-ASE resource-accountability paper's four-gates plan):
- Pinned UUID in the deployment-controlled `b111` range (a hex-valid nod to
the kill-switch "block" mnemonic), distinct from every other agent block;
deployment-stable forever. Changing it orphans every prior holder-authored
Decision.
- DETERMINISTIC agent (rule-based, NOT LLM): no prompt template
(`prompt_template_id=None`) and a sentinel `ModelRef`
(`provider="deterministic"`). The model_ref is never used to build an LLM
(the runtime is an event-triggered subscriber, not an LLM call); it only
satisfies the Agent aggregate's required field. Watch: the Agent aggregate is
LLM-shaped; revisit a first-class deterministic-agent shape if more rule-
agents land (the same watch RunSupervisor carries).
- Authorization: the subscriber authorizes HoldRun through the Authorize port
as its own principal before writing RunHeld. Under the default
AllowAllAuthorize it is permitted (the bootstrap window: holds are ungated
until TrustAuthorize is wired); under TrustAuthorize the operator's single
configured Policy must include this principal + {HoldRun}. Without the grant
an auto-hold is a logged no-op (Authorize Deny -> HoldDeferred), so the
kill-switch degrades safe rather than crashing.
- Not a safety interlock: an auto-hold is a REVERSIBLE wind-down of a software
principal (resume_run exists), edge-triggered and fail-safe, NOT the floor
PSS. It sits between WARN and Clearance on the governance rung.
"""

from __future__ import annotations

from typing import TYPE_CHECKING
from uuid import UUID

from cora.agent._agent_seed import AgentSeedIdentity, seed_agent
from cora.agent.aggregates.agent import ModelRef

if TYPE_CHECKING:
from cora.infrastructure.kernel import Kernel


# ---------------------------------------------------------------------------
# AuthorityRevocationHolder agent identity (deployment-stable constants)
# ---------------------------------------------------------------------------

# Treat as FOREVER-STABLE. Same change-cost rationale as the other pinned agent
# ids: changing this orphans every prior holder-authored Decision (their
# actor_id pointers go stale). UUID is in the deployment-controlled `b111` range
# (a hex-valid nod to the kill-switch "block" mnemonic), distinct from every
# other agent block, keeping the bootstrap constants visually grouped per agent.
AUTHORITY_REVOCATION_HOLDER_AGENT_ID = UUID("01900000-0000-7000-8000-0000b1110010")
AUTHORITY_REVOCATION_HOLDER_AGENT_NAME = "AuthorityRevocationHolder"
AUTHORITY_REVOCATION_HOLDER_AGENT_KIND = "AuthorityRevocationHolder"
AUTHORITY_REVOCATION_HOLDER_AGENT_VERSION = "1.0.0"
AUTHORITY_REVOCATION_HOLDER_AGENT_DESCRIPTION = (
"Deterministic kill-switch subscriber: on a PolicyGrantRevoked, holds each "
"in-flight Run the revoked principal drives (Running only) and records one "
"Decision(context=AuthorityRevocationHold) per run. A "
"reversible, fail-safe wind-down of a software principal, not a safety "
"interlock (the floor PSS owns hard safety)."
)


# Sentinel model ref: the holder is rule-based, not an LLM agent. The Agent
# aggregate requires a ModelRef; this value is never used to build an LLM (no
# build_llm call for this agent).
_DETERMINISTIC_MODEL_REF = ModelRef(
provider="deterministic",
model="agent:AuthorityRevocationHolder:v1",
snapshot_pin=None,
)


# ---------------------------------------------------------------------------
# Deterministic IDs for the bootstrap write envelope
# ---------------------------------------------------------------------------

_AGENT_EVENT_ID = UUID("01900000-0000-7000-8000-0000b1110012")
_ACTOR_EVENT_ID = UUID("01900000-0000-7000-8000-0000b1110013")
_BOOTSTRAP_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000b1110014")


async def seed_authority_revocation_holder_agent(kernel: Kernel) -> None:
"""Seed the AuthorityRevocationHolder Agent + co-registered Actor (idempotent)."""
identity = AgentSeedIdentity(
agent_id=AUTHORITY_REVOCATION_HOLDER_AGENT_ID,
name=AUTHORITY_REVOCATION_HOLDER_AGENT_NAME,
kind=AUTHORITY_REVOCATION_HOLDER_AGENT_KIND,
version=AUTHORITY_REVOCATION_HOLDER_AGENT_VERSION,
description=AUTHORITY_REVOCATION_HOLDER_AGENT_DESCRIPTION,
model_ref=_DETERMINISTIC_MODEL_REF,
prompt_template_id=None,
agent_event_id=_AGENT_EVENT_ID,
actor_event_id=_ACTOR_EVENT_ID,
correlation_id=_BOOTSTRAP_CORRELATION_ID,
command_name="SeedAuthorityRevocationHolderAgent",
)
await seed_agent(kernel, identity)


__all__ = [
"AUTHORITY_REVOCATION_HOLDER_AGENT_DESCRIPTION",
"AUTHORITY_REVOCATION_HOLDER_AGENT_ID",
"AUTHORITY_REVOCATION_HOLDER_AGENT_KIND",
"AUTHORITY_REVOCATION_HOLDER_AGENT_NAME",
"AUTHORITY_REVOCATION_HOLDER_AGENT_VERSION",
"seed_authority_revocation_holder_agent",
]
Loading
Loading