diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 756a7072428..82c75ea67f9 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": { @@ -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", diff --git a/apps/api/src/cora/agent/__init__.py b/apps/api/src/cora/agent/__init__.py index 5408b938498..cad11820c5e 100644 --- a/apps/api/src/cora/agent/__init__.py +++ b/apps/api/src/cora/agent/__init__.py @@ -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, @@ -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", @@ -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", diff --git a/apps/api/src/cora/agent/_subscribers.py b/apps/api/src/cora/agent/_subscribers.py index c0b9b2cb7a6..16235f2d1d8 100644 --- a/apps/api/src/cora/agent/_subscribers.py +++ b/apps/api/src/cora/agent/_subscribers.py @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/apps/api/src/cora/agent/seed_authority_revocation_holder.py b/apps/api/src/cora/agent/seed_authority_revocation_holder.py new file mode 100644 index 00000000000..7cf8436e1e4 --- /dev/null +++ b/apps/api/src/cora/agent/seed_authority_revocation_holder.py @@ -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", +] diff --git a/apps/api/src/cora/agent/subscribers/authority_revocation_holder.py b/apps/api/src/cora/agent/subscribers/authority_revocation_holder.py new file mode 100644 index 00000000000..c57c473d47b --- /dev/null +++ b/apps/api/src/cora/agent/subscribers/authority_revocation_holder.py @@ -0,0 +1,346 @@ +"""Reaction (kill-switch K3): a PolicyGrantRevoked -> hold the revoked +principal's in-flight Runs. + +AuthorityRevocationHolder is CORA's deterministic kill-switch subscriber. It +reacts to `PolicyGrantRevoked` (the K1 trigger event), asks the +`RunActorInvolvementLookup` (K2) for the in-flight Runs the revoked principal +drives, and holds each -- pausing the runs a principal can no longer be trusted +to drive. It records one +`Decision(context=AuthorityRevocationHold, parent_id=)` per +run for provenance. + +## Cross-BC write via Pattern C (not the hold_run handler) + +The `cora.agent` package may depend on `cora.run.aggregates` but NOT on +`cora.run.features` (the tach BC boundary; the RunSupervisor loop is exempt only +because it lives in `cora.api`). So, like CautionPromoter writing a Caution, this +subscriber does the hold itself: load the Run, guard `Running` in-process, +authorize as its own principal, construct `RunHeld` from `cora.run.aggregates`, +and append with optimistic concurrency. This duplicates the small hold_run +decider guard rather than crossing the boundary. + +## Order of operations: hold FIRST, record SECOND + +The hold is the safety-critical, durable act; the Decision is best-effort +provenance. So each run is held before its Decision is written. If the Decision +append fails, the run is still safely held (the RunHeld event + its envelope +principal are themselves an audit trail); a re-delivery re-derives the same +Decision id and no-ops on ConcurrencyError. + +## Running-only guard + +Per the design the holder acts on Running runs only. It loads the Run and holds +only when `status == Running`; an already-Held / terminal / missing run records +`HoldDeferred` (no event), never raised, so a stale K2 projection row cannot +wedge the shared bookmark. The load-then-append is guarded by optimistic +concurrency: if the Run advanced between the read and the write, the append's +ConcurrencyError is caught and folded to HoldDeferred (the next delivery, if any, +re-evaluates fresh). + +## Kind-blind + +Attribution and the hold never read actor kind: a revoked human's runs and a +revoked agent's runs are held by the same code. The revoked principal is just a +UUID from the event payload. + +## Authorization + +The holder authorizes HoldRun through the Authorize port as its own pinned agent +principal. Under TrustAuthorize the operator's Policy must grant it {HoldRun}; +without the grant the Deny is recorded as HoldDeferred, so the kill-switch +degrades safe rather than crashing the worker. + +## On by default + +Unlike the LLM subscribers (gated on ANTHROPIC_API_KEY) and CautionPromoter +(gated on an off-by-default setting), the holder registers unconditionally: a +kill-switch that must be turned on is not a kill-switch. Holding is reversible +(resume_run exists), so the default-on posture matches the safety intent. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID, uuid5 + +from cora.access.aggregates.actor import load_actor +from cora.agent.seed_authority_revocation_holder import ( + AUTHORITY_REVOCATION_HOLDER_AGENT_ID, +) +from cora.decision.aggregates.decision import ( + DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD, + DecisionConfidenceSource, + DecisionRegistered, + event_type_name, + to_payload, + validate_confidence, + validate_inputs, + validate_reasoning, +) +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import ConcurrencyError, Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.run.aggregates.run import ( + RunHeld, + RunStatus, +) +from cora.run.aggregates.run import ( + event_type_name as run_event_type_name, +) +from cora.run.aggregates.run import ( + fold as fold_run, +) +from cora.run.aggregates.run import ( + from_stored as run_from_stored, +) +from cora.run.aggregates.run import ( + to_payload as run_to_payload, +) +from cora.shared.identity import ActorId + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + from cora.infrastructure.ports import Authorize, Clock, IdGenerator, RunActorInvolvementLookup + from cora.infrastructure.ports.event_store import EventStore, StoredEvent + from cora.infrastructure.projection.handler import ConnectionLike + +_DECISION_STREAM_TYPE = "Decision" +_RUN_STREAM_TYPE = "Run" +_HOLD_COMMAND_NAME = "HoldRun" +_COMMAND_NAME = "AuthorityRevocationHolderSubscriber" +_DECISION_RULE = "agent:AuthorityRevocationHolder:v1" +_TRIGGER_EVENT_TYPE = "PolicyGrantRevoked" + +# Stable namespace for deriving deterministic Decision ids from the (revocation +# event, run) pair. Follows the sibling suffix convention (...00000002, +# cf. aaaa0002 / bbbb0002 / dddd0002) in the holder's own b111 block; distinct +# from every other agent's namespace. +_HOLDER_NAMESPACE = UUID("01900000-0000-7000-8000-0000b1110002") + +_log = get_logger(__name__) + + +def _derive_decision_id(revocation_event_id: UUID, run_id: UUID) -> UUID: + """Deterministic AuthorityRevocationHold Decision id, one per (revocation, run). + + Keyed on the triggering event id (not the policy or principal) so a + re-delivery of the same PolicyGrantRevoked derives the same ids and no-ops, + while a later, distinct revocation of the same principal gets fresh ids. + """ + return uuid5(_HOLDER_NAMESPACE, f"decision:{revocation_event_id}:{run_id}") + + +class AuthorityRevocationHolderSubscriber: + """Reaction: PolicyGrantRevoked -> hold each in-flight Run of the revoked principal. + + Constructed by `make_authority_revocation_holder_subscriber` from the Kernel; + satisfies the `Reaction` Protocol structurally. Deterministic (no LLM), so + `batch_size = 1` keeps the per-run hold + Decision append sequence simple. + """ + + name = "authority_revocation_holder" + subscribed_event_types = frozenset({"PolicyGrantRevoked"}) + batch_size = 1 + + def __init__( + self, + *, + event_store: EventStore, + run_actor_involvement_lookup: RunActorInvolvementLookup, + authz: Authorize, + clock: Clock, + id_generator: IdGenerator, + ) -> None: + self.event_store = event_store + self.run_actor_involvement_lookup = run_actor_involvement_lookup + self.authz = authz + self.clock = clock + self.id_generator = id_generator + + async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: + """Process one PolicyGrantRevoked; hold the revoked principal's in-flight runs.""" + # conn unused: cross-BC writes go through the event store, like the other subscribers. + _ = conn + if event.event_type != _TRIGGER_EVENT_TYPE: + return + try: + await self._handle_revocation(event) + except Exception: + # A malformed event or transient fault must never wedge the shared + # subscriber bookmark (no operator escape-hatch yet). Log and advance; + # a re-delivery replays idempotently (deterministic Decision ids + + # the Running-only guard + ConcurrencyError swallow). + _log.exception( + "authority_revocation_holder.apply_failed", + revocation_event_id=str(event.event_id), + ) + + async def _handle_revocation(self, event: StoredEvent) -> None: + actor = await load_actor(self.event_store, AUTHORITY_REVOCATION_HOLDER_AGENT_ID) + if actor is None or not actor.active: + # Not seeded yet (bootstrap runs before the worker), or the operator + # deactivated the holder Actor. Stand down. Same operator-deactivation + # revocation surface as every sibling agent (load_actor().active), + # deliberately kept uniform: an operator disables the kill-switch the + # same way they disable any agent, not through a bespoke gate. + return + + revoked_principal_id = UUID(event.payload["principal_id"]) + run_ids = await self.run_actor_involvement_lookup.runs_driven_by(revoked_principal_id) + if not run_ids: + _log.info( + "authority_revocation_holder.no_in_flight_runs", + revoked_principal_id=str(revoked_principal_id), + ) + return + + for run_id in run_ids: + # Isolate each run: a fault holding (or recording) one run must NOT + # abandon the siblings. For a kill-switch, silent partial enforcement + # (runs #2..N left Running because run #1 raised) is the worst mode, so + # the blast radius is bounded to the single run. The bookmark still + # advances after this event, so a run dropped here is NOT redelivered: + # the error log is the operator's signal to intervene (resume/re-hold + # by hand). This is the wedged-bookmark escape-hatch trigger from the + # subscriber-framework widening plan; the holder is the 4th Reaction. + try: + await self._hold_one(event=event, run_id=run_id) + except Exception: + _log.exception( + "authority_revocation_holder.hold_one_failed", + revocation_event_id=str(event.event_id), + run_id=str(run_id), + ) + + async def _hold_one(self, *, event: StoredEvent, run_id: UUID) -> None: + """Hold one run (safety act), then record its Decision (provenance).""" + decision_id = _derive_decision_id(event.event_id, run_id) + choice, reason = await self._issue_hold(run_id=run_id, decision_id=decision_id) + await self._record_hold_decision( + decision_id=decision_id, + revocation_event_id=event.event_id, + run_id=run_id, + choice=choice, + reason=reason, + ) + + async def _issue_hold(self, *, run_id: UUID, decision_id: UUID) -> tuple[str, str]: + """Pattern C hold: load + guard Running + authorize + append RunHeld. + + Returns (choice, reason). Every non-hold path folds to HoldDeferred and is + never raised, so a stale row / lost race / denied authz cannot wedge the + bookmark. + """ + stored, version = await self.event_store.load(_RUN_STREAM_TYPE, run_id) + if not stored: + return "HoldDeferred", "run not found (stale involvement projection row)" + state = fold_run([run_from_stored(s) for s in stored]) + if state is None or state.status is not RunStatus.RUNNING: + return "HoldDeferred", "run had already left Running (already held or terminal)" + + authz = await self.authz.authorize( + principal_id=AUTHORITY_REVOCATION_HOLDER_AGENT_ID, + command_name=_HOLD_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=NIL_SENTINEL_ID, + ) + if isinstance(authz, Deny): + return "HoldDeferred", "not authorized to hold (Authorize denied)" + + now = self.clock.now() + held = RunHeld(run_id=run_id, decided_by_decision_id=decision_id, occurred_at=now) + envelope = to_new_event( + event_type=run_event_type_name(held), + payload=run_to_payload(held), + occurred_at=now, + event_id=self.id_generator.new_id(), + command_name=_HOLD_COMMAND_NAME, + correlation_id=self.id_generator.new_id(), + causation_id=None, + principal_id=AUTHORITY_REVOCATION_HOLDER_AGENT_ID, + ) + try: + await self.event_store.append( + stream_type=_RUN_STREAM_TYPE, + stream_id=run_id, + expected_version=version, + events=[envelope], + ) + except ConcurrencyError: + return "HoldDeferred", "run advanced concurrently (lost the hold race)" + _log.info("authority_revocation_holder.held", run_id=str(run_id)) + return "Held", "held the revoked principal's in-flight run" + + async def _record_hold_decision( + self, + *, + decision_id: UUID, + revocation_event_id: UUID, + run_id: UUID, + choice: str, + reason: str, + ) -> None: + """Append the AuthorityRevocationHold Decision. No-op on ConcurrencyError (idempotent).""" + now = self.clock.now() + domain_event = DecisionRegistered( + decision_id=decision_id, + decided_by=ActorId(AUTHORITY_REVOCATION_HOLDER_AGENT_ID), + context=DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD, + choice=choice, + parent_id=revocation_event_id, + override_kind=None, + rule=_DECISION_RULE, + reasoning=validate_reasoning(reason), + confidence=validate_confidence(None), + confidence_source=DecisionConfidenceSource.SELF_REPORTED, + alternatives=(), + inputs=validate_inputs( + { + "revocation_event_id": str(revocation_event_id), + "run_id": str(run_id), + } + ), + reasoning_signature=None, + occurred_at=now, + ) + new_event = to_new_event( + event_type=event_type_name(domain_event), + payload=to_payload(domain_event), + occurred_at=now, + event_id=uuid5(decision_id, "event:0"), + command_name=_COMMAND_NAME, + correlation_id=self.id_generator.new_id(), + causation_id=None, + principal_id=AUTHORITY_REVOCATION_HOLDER_AGENT_ID, + ) + try: + await self.event_store.append( + stream_type=_DECISION_STREAM_TYPE, + stream_id=decision_id, + expected_version=0, + events=[new_event], + ) + except ConcurrencyError: + _log.info("authority_revocation_holder.decision_already_recorded", choice=choice) + return + _log.info("authority_revocation_holder.decision", choice=choice, run_id=str(run_id)) + + +def make_authority_revocation_holder_subscriber( + deps: Kernel, +) -> AuthorityRevocationHolderSubscriber: + """Build the AuthorityRevocationHolder subscriber closed over the shared deps.""" + return AuthorityRevocationHolderSubscriber( + event_store=deps.event_store, + run_actor_involvement_lookup=deps.run_actor_involvement_lookup, + authz=deps.authz, + clock=deps.clock, + id_generator=deps.id_generator, + ) + + +__all__ = [ + "AuthorityRevocationHolderSubscriber", + "make_authority_revocation_holder_subscriber", +] diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 0772a190d20..43420d5fcfe 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -54,6 +54,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, @@ -199,6 +200,7 @@ register_run_tools, wire_run, ) +from cora.run.adapters import PostgresRunActorInvolvementLookup from cora.safety import ( SafetyHandlers, register_safety_projections, @@ -616,6 +618,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: caution_lookup_factory=PostgresCautionLookup, capability_lookup_factory=PostgresCapabilityLookup, supply_lookup_factory=PostgresSupplyLookup, + run_actor_involvement_lookup_factory=PostgresRunActorInvolvementLookup, dataset_distribution_lookup_factory=PostgresDatasetDistributionLookup, credential_lookup_factory=PostgresCredentialLookup, facility_lookup_factory=PostgresFacilityLookup, @@ -811,6 +814,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: register_agent_subscribers(registry, deps) app.state.projections = registry + # seed the AuthorityRevocationHolder Agent record FIRST: the + # kill-switch subscriber (K3) registers unconditionally and must + # resolve its `actor_id` at apply()-time. Idempotent across restarts. + await seed_authority_revocation_holder_agent(deps) # seed the RunDebriefer Agent record so # the subscriber can resolve `actor_id` at apply()-time. # Idempotent across restarts; safe to re-run forever. diff --git a/apps/api/src/cora/decision/aggregates/decision/__init__.py b/apps/api/src/cora/decision/aggregates/decision/__init__.py index 3343ad77360..3c2735ec2b6 100644 --- a/apps/api/src/cora/decision/aggregates/decision/__init__.py +++ b/apps/api/src/cora/decision/aggregates/decision/__init__.py @@ -27,6 +27,7 @@ from cora.decision.aggregates.decision.evolver import evolve, fold from cora.decision.aggregates.decision.read import load_decision from cora.decision.aggregates.decision.state import ( + AUTHORITY_REVOCATION_HOLD_CHOICES, CALIBRATION_VERIFICATION_CHOICES, CAMPAIGN_PROGRESS_CHOICES, CAUTION_PROMOTION_CHOICES, @@ -39,6 +40,7 @@ DECISION_ALTERNATIVE_ENTRY_MAX_LENGTH, DECISION_ALTERNATIVES_MAX_ENTRIES, DECISION_CHOICE_MAX_LENGTH, + DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD, DECISION_CONTEXT_CALIBRATION_VERIFICATION, DECISION_CONTEXT_CAMPAIGN_PROGRESS, DECISION_CONTEXT_CAUTION_PROMOTION, @@ -71,6 +73,7 @@ RUN_DEBRIEF_CHOICES, RUN_INITIATION_CHOICES, RUN_SUPERVISION_CHOICES, + AuthorityRevocationHoldChoice, CalibrationVerificationChoice, CampaignProgressChoice, CautionPromotionChoice, @@ -119,6 +122,7 @@ ) __all__ = [ + "AUTHORITY_REVOCATION_HOLD_CHOICES", "CALIBRATION_VERIFICATION_CHOICES", "CAMPAIGN_PROGRESS_CHOICES", "CAUTION_PROMOTION_CHOICES", @@ -131,6 +135,7 @@ "DECISION_ALTERNATIVES_MAX_ENTRIES", "DECISION_ALTERNATIVE_ENTRY_MAX_LENGTH", "DECISION_CHOICE_MAX_LENGTH", + "DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD", "DECISION_CONTEXT_CALIBRATION_VERIFICATION", "DECISION_CONTEXT_CAMPAIGN_PROGRESS", "DECISION_CONTEXT_CAUTION_PROMOTION", @@ -170,6 +175,7 @@ "RUN_DEBRIEF_CHOICES", "RUN_INITIATION_CHOICES", "RUN_SUPERVISION_CHOICES", + "AuthorityRevocationHoldChoice", "CalibrationVerificationChoice", "CampaignProgressChoice", "CautionPromotionChoice", diff --git a/apps/api/src/cora/decision/aggregates/decision/state.py b/apps/api/src/cora/decision/aggregates/decision/state.py index ba0fc14d864..075a7c17333 100644 --- a/apps/api/src/cora/decision/aggregates/decision/state.py +++ b/apps/api/src/cora/decision/aggregates/decision/state.py @@ -590,6 +590,41 @@ CAMPAIGN_PROGRESS_CHOICES: Final = frozenset({"Stuck"}) +# AuthorityRevocationHolder agent (kill-switch K3) writes one Decision per +# in-flight Run it evaluates after a PolicyGrantRevoked, linking the hold back to +# the revocation via `parent_id`. Open-ended convention identical to +# RunSupervision / CautionPromotion; the closed choice vocabulary lives in the +# `AuthorityRevocationHoldChoice` Literal below. The context noun is +# `AuthorityRevocationHold` (abstract action-noun, family-clean with the other +# agent contexts); the agent kind is `AuthorityRevocationHolder` (the doer), the +# same action-vs-doer asymmetry as ClearanceExpiry vs ClearanceExpirer. +DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD = "AuthorityRevocationHold" + + +# Closed `choice` value set for `context = "AuthorityRevocationHold"` Decisions. +# Projection-validated, not domain-enforced. Two values: +# +# - `Held` -- the run was Running and the hold succeeded: the +# revoked principal's in-flight run is now contained. +# - `HoldDeferred` -- the hold was a benign no-op: the run had already +# left Running (already Held, terminal, missing, or a +# lost concurrency race), or the holder was not +# authorized, by the time it acted. Carries the Hold +# work-noun (parallel to SupervisionDeferred / +# PromotionDeferred) so it does not collide in the +# shared, globally-filtered DecisionChoice projection. +AuthorityRevocationHoldChoice = Literal[ + "Held", + "HoldDeferred", +] +AUTHORITY_REVOCATION_HOLD_CHOICES: Final = frozenset( + { + "Held", + "HoldDeferred", + } +) + + # acceptance-signal capture: closed 3-value rating set on # the new `DecisionRated` event. `useful` and `misleading` are # operator-affirmative; `ignored` is a positive marker ("operator saw diff --git a/apps/api/src/cora/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index 23085b4c138..b5641c71f56 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -106,8 +106,10 @@ LogbookMirror, NoComputeReachabilityLookup, NoDatasetDistributionsLookup, + NoInvolvementLookup, ProfileStore, RoleLookup, + RunActorInvolvementLookup, SupplyLookup, SystemClock, TokenVerifier, @@ -164,6 +166,7 @@ def make_postgres_kernel( caution_lookup: CautionLookup | None = None, capability_lookup: CapabilityLookup | None = None, supply_lookup: SupplyLookup | None = None, + run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, dataset_distribution_lookup: DatasetDistributionLookup | None = None, compute_reachability_lookup: ComputeReachabilityLookup | None = None, credential_lookup: CredentialLookup | None = None, @@ -226,6 +229,13 @@ def make_postgres_kernel( `supply_lookup_factory` argument; gate-specific tests override here explicitly. See [[project_supply_preflight_gate_design]]. + `run_actor_involvement_lookup` defaults to `NoInvolvementLookup` + (returns `[]`) so existing tests don't have to seed runs. + Production's `build_kernel` injects the real + `PostgresRunActorInvolvementLookup` via the + `run_actor_involvement_lookup_factory` argument; kill-switch tests + override here explicitly. + `dataset_distribution_lookup` defaults to `NoDatasetDistributionsLookup` (every Dataset has no Distribution): the conservative default for the genesis-only input gate, so a Run declaring an input but seeding @@ -322,6 +332,11 @@ def make_postgres_kernel( capability_lookup if capability_lookup is not None else AlwaysEmptyCapabilityLookup() ), supply_lookup=(supply_lookup if supply_lookup is not None else AllSatisfiedSupplyLookup()), + run_actor_involvement_lookup=( + run_actor_involvement_lookup + if run_actor_involvement_lookup is not None + else NoInvolvementLookup() + ), dataset_distribution_lookup=( dataset_distribution_lookup if dataset_distribution_lookup is not None @@ -374,6 +389,7 @@ def make_inmemory_kernel( caution_lookup: CautionLookup | None = None, capability_lookup: CapabilityLookup | None = None, supply_lookup: SupplyLookup | None = None, + run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, dataset_distribution_lookup: DatasetDistributionLookup | None = None, compute_reachability_lookup: ComputeReachabilityLookup | None = None, credential_lookup: CredentialLookup | None = None, @@ -428,6 +444,11 @@ def make_inmemory_kernel( read from. Gate-specific tests can override with `NoSuppliesRegisteredLookup` (missing-kind path) or a custom fake. + `run_actor_involvement_lookup` defaults to `NoInvolvementLookup` + (returns `[]`) for the same reason: no projection worker, no + `proj_run_actor_involvement` table to read from. Kill-switch tests + can override with a custom fake or the real adapter. + `dataset_distribution_lookup` defaults to `NoDatasetDistributionsLookup` for the same reason: no projection worker, no `proj_data_distribution_summary` table to read from. The default is @@ -517,6 +538,11 @@ def make_inmemory_kernel( capability_lookup if capability_lookup is not None else AlwaysEmptyCapabilityLookup() ), supply_lookup=(supply_lookup if supply_lookup is not None else AllSatisfiedSupplyLookup()), + run_actor_involvement_lookup=( + run_actor_involvement_lookup + if run_actor_involvement_lookup is not None + else NoInvolvementLookup() + ), dataset_distribution_lookup=( dataset_distribution_lookup if dataset_distribution_lookup is not None @@ -641,6 +667,25 @@ def __call__( ) -> SupplyLookup: ... +class RunActorInvolvementLookupFactory(Protocol): + """Builds the production RunActorInvolvementLookup port for the Kernel. + + Run BC's `cora.run.adapters.PostgresRunActorInvolvementLookup` is + the production factory; `cora.api.main` binds it. Same factory- + injection shape as the other lookup factories so + `cora.infrastructure.deps` doesn't import from any BC. + + `pool` is `None` only when `app_env=test`; the production factory + requires a real pool. Test mode falls back to `NoInvolvementLookup` + automatically. + """ + + def __call__( + self, + pool: asyncpg.Pool, + ) -> RunActorInvolvementLookup: ... + + class DatasetDistributionLookupFactory(Protocol): """Builds the production DatasetDistributionLookup port for the Kernel. @@ -848,6 +893,7 @@ async def build_kernel( caution_lookup_factory: CautionLookupFactory | None = None, capability_lookup_factory: CapabilityLookupFactory | None = None, supply_lookup_factory: SupplyLookupFactory | None = None, + run_actor_involvement_lookup_factory: RunActorInvolvementLookupFactory | None = None, dataset_distribution_lookup_factory: DatasetDistributionLookupFactory | None = None, credential_lookup_factory: CredentialLookupFactory | None = None, facility_lookup_factory: FacilityLookupFactory | None = None, @@ -956,6 +1002,11 @@ async def build_kernel( if supply_lookup_factory is not None else AllSatisfiedSupplyLookup() ) + run_actor_involvement_lookup: RunActorInvolvementLookup = ( + run_actor_involvement_lookup_factory(pool) + if run_actor_involvement_lookup_factory is not None + else NoInvolvementLookup() + ) dataset_distribution_lookup: DatasetDistributionLookup = ( dataset_distribution_lookup_factory(pool) if dataset_distribution_lookup_factory is not None @@ -1009,6 +1060,7 @@ async def build_kernel( caution_lookup=caution_lookup, capability_lookup=capability_lookup, supply_lookup=supply_lookup, + run_actor_involvement_lookup=run_actor_involvement_lookup, dataset_distribution_lookup=dataset_distribution_lookup, credential_lookup=credential_lookup, facility_lookup=facility_lookup, @@ -1103,6 +1155,7 @@ async def composed() -> None: "FamilyLookupFactory", "LLMFactory", "RoleLookupFactory", + "RunActorInvolvementLookupFactory", "SupplyLookupFactory", "build_kernel", "make_inmemory_kernel", diff --git a/apps/api/src/cora/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index 7f0e4e618c6..9037cfc9e29 100644 --- a/apps/api/src/cora/infrastructure/kernel.py +++ b/apps/api/src/cora/infrastructure/kernel.py @@ -68,6 +68,7 @@ NullInferenceRecorder, ProfileStore, RoleLookup, + RunActorInvolvementLookup, Signer, SupplyLookup, TokenVerifier, @@ -153,6 +154,15 @@ class Kernel: the `ClearanceLookup` / `CautionLookup` test-default pattern. See [[project_supply_preflight_gate_design]]. + `run_actor_involvement_lookup`: cross-BC port consumed by the + authority-revocation holder subscriber (K3) to resolve the + in-flight Runs a revoked principal drives and hold each. Run BC + ships `PostgresRunActorInvolvementLookup` as the production adapter + (reads `proj_run_actor_involvement`). Test environments default to + `NoInvolvementLookup` (returns `[]`) so existing tests don't have + to seed runs; kill-switch tests override with the real adapter. + Mirrors the `ClearanceLookup` / `CautionLookup` test-default pattern. + `dataset_distribution_lookup`: cross-BC port consumed by Run BC's `start_run` handler to gate a reconstruction Run on each declared input Dataset (`StartRun.input_dataset_ids`) having a Verified @@ -316,6 +326,7 @@ class Kernel: caution_lookup: CautionLookup capability_lookup: CapabilityLookup supply_lookup: SupplyLookup + run_actor_involvement_lookup: RunActorInvolvementLookup dataset_distribution_lookup: DatasetDistributionLookup compute_reachability_lookup: ComputeReachabilityLookup credential_lookup: CredentialLookup diff --git a/apps/api/src/cora/infrastructure/ports/__init__.py b/apps/api/src/cora/infrastructure/ports/__init__.py index 61973799ae0..e1acab7773b 100644 --- a/apps/api/src/cora/infrastructure/ports/__init__.py +++ b/apps/api/src/cora/infrastructure/ports/__init__.py @@ -131,6 +131,10 @@ RoleLookup, RoleLookupResult, ) +from cora.infrastructure.ports.run_actor_involvement_lookup import ( + NoInvolvementLookup, + RunActorInvolvementLookup, +) from cora.infrastructure.ports.signer import ( Signer, SignerKeyInactiveError, @@ -233,6 +237,7 @@ "NewEvent", "NoComputeReachabilityLookup", "NoDatasetDistributionsLookup", + "NoInvolvementLookup", "NoSuppliesRegisteredLookup", "NullInferenceRecorder", "PrincipalKind", @@ -240,6 +245,7 @@ "ProfileStore", "RoleLookup", "RoleLookupResult", + "RunActorInvolvementLookup", "SeededComputeReachabilityLookup", "SeededDatasetDistributionLookup", "Signer", diff --git a/apps/api/src/cora/infrastructure/ports/run_actor_involvement_lookup.py b/apps/api/src/cora/infrastructure/ports/run_actor_involvement_lookup.py new file mode 100644 index 00000000000..494709f1632 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/run_actor_involvement_lookup.py @@ -0,0 +1,60 @@ +"""RunActorInvolvementLookup port: which in-flight Runs does a principal drive? + +The kill-switch's resolver. The authority-revocation holder subscriber (K3), on a +committed PolicyGrantRevoked, asks this port for the revoked principal's in-flight +runs and holds each. Shaped around that consumer need: "give me the run ids this +principal drives that are still holdable (Running or Held)". + +## Convention + +Same shape as ClearanceLookup / Authorize: consumer-shaped contract, one +implementor (Run BC ships PostgresRunActorInvolvementLookup reading +proj_run_actor_involvement), placed in cora.infrastructure.ports for neutral +access. Adapters translate the projection's columns to this shape. + +## Involvement scope (v1) + +"Drives" is STARTER-ONLY for v1: the runs a principal STARTED (the RunStarted +envelope principal) and that are still in-flight. The projection reserves a +'supervisor' role for a later additive widening to "started or supervises"; when +that lands, this port's contract widens with it and callers need no change (the +method already means "drives", whatever the fold defines that as). +""" + +from typing import Protocol +from uuid import UUID + + +class RunActorInvolvementLookup(Protocol): + """Cross-BC port: query Run's actor-involvement projection.""" + + async def runs_driven_by(self, principal_id: UUID) -> list[UUID]: + """Return the ids of in-flight Runs the principal drives. + + In-flight means status in (Running, Held): a run that can still be held. + Terminal runs (Completed / Aborted / Stopped / Truncated) are excluded + even though the projection retains their rows for audit. Empty list means + the principal drives no holdable run right now, which is the normal case + for a post-hoc agent that starts no runs. + """ + ... + + +class NoInvolvementLookup: + """Test-default stub: the principal drives no runs. + + Mirrors AllowAllAuthorize / AlwaysCoveredClearanceLookup as the test-default + from the deps builder: tests that do not exercise the kill-switch hold get + this stub, so revoking a grant holds nothing. Tests that exercise the auto-hold + override with the real PostgresRunActorInvolvementLookup and seed runs via + start_run. It is also the honest production behavior for today's post-hoc + agents (they start no runs), so wiring it as the default is not merely a test + convenience. + """ + + async def runs_driven_by(self, principal_id: UUID) -> list[UUID]: + _ = principal_id # no involvement recorded + return [] + + +__all__ = ["NoInvolvementLookup", "RunActorInvolvementLookup"] diff --git a/apps/api/src/cora/run/_projections.py b/apps/api/src/cora/run/_projections.py index 968ceda0385..f39f184907d 100644 --- a/apps/api/src/cora/run/_projections.py +++ b/apps/api/src/cora/run/_projections.py @@ -8,7 +8,7 @@ from cora.infrastructure.kernel import Kernel from cora.infrastructure.projection import ProjectionRegistry -from cora.run.projections import RunSummaryProjection +from cora.run.projections import RunActorInvolvementProjection, RunSummaryProjection def register_run_projections( @@ -18,6 +18,7 @@ def register_run_projections( """Register every Run-owned projection on the worker registry.""" _ = deps registry.register(RunSummaryProjection()) + registry.register(RunActorInvolvementProjection()) __all__ = ["register_run_projections"] diff --git a/apps/api/src/cora/run/adapters/__init__.py b/apps/api/src/cora/run/adapters/__init__.py index ac62376db2c..6592130c288 100644 --- a/apps/api/src/cora/run/adapters/__init__.py +++ b/apps/api/src/cora/run/adapters/__init__.py @@ -1,5 +1,8 @@ """Run-BC production + simulation adapters for its BC-local ports.""" +from cora.run.adapters.postgres_run_actor_involvement_lookup import ( + PostgresRunActorInvolvementLookup, +) from cora.run.adapters.postgres_run_channel_lookup import PostgresRunChannelLookup from cora.run.adapters.sim_observation_feeder import ( SIM_OBSERVATION_FEEDER_AGENT_ID, @@ -9,6 +12,7 @@ __all__ = [ "SIM_OBSERVATION_FEEDER_AGENT_ID", + "PostgresRunActorInvolvementLookup", "PostgresRunChannelLookup", "SimObservationFeeder", "TracePoint", diff --git a/apps/api/src/cora/run/adapters/postgres_run_actor_involvement_lookup.py b/apps/api/src/cora/run/adapters/postgres_run_actor_involvement_lookup.py new file mode 100644 index 00000000000..04659f44fdc --- /dev/null +++ b/apps/api/src/cora/run/adapters/postgres_run_actor_involvement_lookup.py @@ -0,0 +1,37 @@ +"""Postgres adapter implementing `RunActorInvolvementLookup`. + +Reads `proj_run_actor_involvement` for the in-flight runs a principal drives. +Consumed by the authority-revocation holder subscriber (K3) via the +`Kernel.run_actor_involvement_lookup` port. Reads the replicated projection +directly through the shared asyncpg pool, per the same cross-BC-read guidance +the ClearanceLookup adapter follows. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from uuid import UUID + +import asyncpg + +_RUNS_DRIVEN_BY_SQL = """ +SELECT run_id +FROM proj_run_actor_involvement +WHERE principal_id = $1 + AND status IN ('Running', 'Held') +ORDER BY created_at, run_id +""" + + +class PostgresRunActorInvolvementLookup: + """asyncpg-backed `RunActorInvolvementLookup` implementation.""" + + def __init__(self, pool: asyncpg.Pool) -> None: + self._pool = pool + + async def runs_driven_by(self, principal_id: UUID) -> list[UUID]: + async with self._pool.acquire() as conn: + rows = await conn.fetch(_RUNS_DRIVEN_BY_SQL, principal_id) + return [row["run_id"] for row in rows] + + +__all__ = ["PostgresRunActorInvolvementLookup"] diff --git a/apps/api/src/cora/run/projections/__init__.py b/apps/api/src/cora/run/projections/__init__.py index f10ae27ac2b..eba791db956 100644 --- a/apps/api/src/cora/run/projections/__init__.py +++ b/apps/api/src/cora/run/projections/__init__.py @@ -1,10 +1,11 @@ """Run BC projections. -Single-aggregate BC; one projection today (RunSummaryProjection). -Add a new projection by creating a new module here + re-exporting -its class + adding it to `register_run_projections`. +Add a new projection by creating a new module here + re-exporting its class + +adding it to `register_run_projections`. RunActorInvolvementProjection backs the +kill-switch's actor-involvement resolver (which in-flight runs a principal drives). """ +from cora.run.projections.actor_involvement import RunActorInvolvementProjection from cora.run.projections.summary import RunSummaryProjection -__all__ = ["RunSummaryProjection"] +__all__ = ["RunActorInvolvementProjection", "RunSummaryProjection"] diff --git a/apps/api/src/cora/run/projections/actor_involvement.py b/apps/api/src/cora/run/projections/actor_involvement.py new file mode 100644 index 00000000000..22b1b38f6ee --- /dev/null +++ b/apps/api/src/cora/run/projections/actor_involvement.py @@ -0,0 +1,93 @@ +"""Kill-switch K2: the `proj_run_actor_involvement` read model. + +Answers "which in-flight Runs does principal P drive?" for the authority- +revocation holder subscriber (K3). The starter of a Run is the RunStarted +event's ENVELOPE principal_id (the Authorize-gated issuer), not a payload field, +so this projection reads `event.principal_id`. + +Involvement scope is STARTER-ONLY for v1, structured extensible: rows carry +`involvement_role='starter'` and the table reserves 'supervisor' for a later +additive fold (the HoldRun / ResumeRun envelope principals) that widens "drives" +to "started or supervises" without a schema change. + +Lifecycle events (RunHeld ... RunTruncated) update `status` keyed by run_id, so +they touch every involvement row for the Run (one starter row today; more when +the supervisor arm lands). Terminal rows are retained, not deleted: the resolver +stays a pure fold and past involvement remains auditable. The lookup filters to +in-flight (status IN Running, Held). +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from uuid import UUID + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.projection.handler import ConnectionLike + +_INSERT_SQL = """ +INSERT INTO proj_run_actor_involvement + (principal_id, run_id, involvement_role, status, created_at) +VALUES ($1, $2, 'starter', 'Running', $3) +ON CONFLICT (principal_id, run_id) DO NOTHING +""" + +_UPDATE_STATUS_SQL = """ +UPDATE proj_run_actor_involvement +SET status = $2, updated_at = now() +WHERE run_id = $1 +""" + +_EVENT_TO_STATUS = { + "RunHeld": "Held", + "RunResumed": "Running", + "RunCompleted": "Completed", + "RunAborted": "Aborted", + "RunStopped": "Stopped", + "RunTruncated": "Truncated", +} + + +class RunActorInvolvementProjection: + """Maintains the `proj_run_actor_involvement` read model.""" + + name = "proj_run_actor_involvement" + subscribed_event_types = frozenset( + { + "RunStarted", + "RunHeld", + "RunResumed", + "RunCompleted", + "RunAborted", + "RunStopped", + "RunTruncated", + } + ) + + async def apply( + self, + event: StoredEvent, + conn: ConnectionLike, + ) -> None: + if event.event_type == "RunStarted": + if event.principal_id is None: + # No starter to attribute (legacy/backfilled event with no + # envelope principal); nothing to resolve against, so skip. + return + await conn.execute( + _INSERT_SQL, + event.principal_id, + UUID(event.payload["run_id"]), + event.occurred_at, + ) + return + new_status = _EVENT_TO_STATUS.get(event.event_type) + if new_status is None: + return + await conn.execute( + _UPDATE_STATUS_SQL, + UUID(event.payload["run_id"]), + new_status, + ) + + +__all__ = ["RunActorInvolvementProjection"] diff --git a/apps/api/src/cora/trust/aggregates/policy/__init__.py b/apps/api/src/cora/trust/aggregates/policy/__init__.py index abef98c34ae..cf34de2ad22 100644 --- a/apps/api/src/cora/trust/aggregates/policy/__init__.py +++ b/apps/api/src/cora/trust/aggregates/policy/__init__.py @@ -10,6 +10,7 @@ from cora.trust.aggregates.policy.events import ( PolicyDefined, PolicyEvent, + PolicyGrantRevoked, event_type_name, from_stored, to_payload, @@ -18,23 +19,28 @@ from cora.trust.aggregates.policy.read import load_policy from cora.trust.aggregates.policy.state import ( POLICY_NAME_MAX_LENGTH, + InvalidPolicyGrantRevokeReasonError, InvalidPolicyNameError, InvalidPolicySurfaceError, Policy, PolicyAlreadyExistsError, PolicyName, + PolicyNotFoundError, evaluate, ) __all__ = [ "POLICY_NAME_MAX_LENGTH", + "InvalidPolicyGrantRevokeReasonError", "InvalidPolicyNameError", "InvalidPolicySurfaceError", "Policy", "PolicyAlreadyExistsError", "PolicyDefined", "PolicyEvent", + "PolicyGrantRevoked", "PolicyName", + "PolicyNotFoundError", "evaluate", "event_type_name", "evolve", diff --git a/apps/api/src/cora/trust/aggregates/policy/events.py b/apps/api/src/cora/trust/aggregates/policy/events.py index 02ad51e460a..88a31307327 100644 --- a/apps/api/src/cora/trust/aggregates/policy/events.py +++ b/apps/api/src/cora/trust/aggregates/policy/events.py @@ -45,8 +45,30 @@ class PolicyDefined: surface_id: UUID = NIL_SENTINEL_ID +@dataclass(frozen=True) +class PolicyGrantRevoked: + """One principal's grant was revoked from a Policy's allow-list. + + Drops `principal_id` from `Policy.permitted_principal_ids`. `revoked_by` + is the invoking principal (the operator or observer that issued the + revocation), handler-injected from the request envelope, distinct from + `principal_id` (the grant being removed). `reason` is operator free text + (audit denorm on the immutable log; also feeds the downstream mid-run + compensation Decision). This is the kill-switch trigger event: a separate, + eventually-consistent subscriber reacts to it to hold the revoked + principal's in-flight runs (kept out of the emitting handler per the + compensation-slice no-cascade lock). + """ + + policy_id: UUID + principal_id: UUID + revoked_by: UUID + reason: str + occurred_at: datetime + + # Discriminated union of every event the Policy aggregate emits. -PolicyEvent = PolicyDefined +PolicyEvent = PolicyDefined | PolicyGrantRevoked def event_type_name(event: PolicyEvent) -> str: @@ -81,6 +103,20 @@ def to_payload(event: PolicyEvent) -> dict[str, Any]: "permitted_commands": sorted(permitted_commands), "occurred_at": occurred_at.isoformat(), } + case PolicyGrantRevoked( + policy_id=policy_id, + principal_id=principal_id, + revoked_by=revoked_by, + reason=reason, + occurred_at=occurred_at, + ): + return { + "policy_id": str(policy_id), + "principal_id": str(principal_id), + "revoked_by": str(revoked_by), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover # exhaustiveness guard assert_never(event) @@ -104,6 +140,17 @@ def from_stored(stored: StoredEvent) -> PolicyEvent: surface_id=UUID(payload.get("surface_id", str(NIL_SENTINEL_ID))), ), ) + case "PolicyGrantRevoked": + return deserialize_or_raise( + "PolicyGrantRevoked", + lambda: PolicyGrantRevoked( + policy_id=UUID(payload["policy_id"]), + principal_id=UUID(payload["principal_id"]), + revoked_by=UUID(payload["revoked_by"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) case _: msg = f"Unknown PolicyEvent event_type: {stored.event_type!r}" raise ValueError(msg) @@ -112,6 +159,7 @@ def from_stored(stored: StoredEvent) -> PolicyEvent: __all__ = [ "PolicyDefined", "PolicyEvent", + "PolicyGrantRevoked", "event_type_name", "from_stored", "to_payload", diff --git a/apps/api/src/cora/trust/aggregates/policy/evolver.py b/apps/api/src/cora/trust/aggregates/policy/evolver.py index 6771d71e1b4..c6de7bcf21a 100644 --- a/apps/api/src/cora/trust/aggregates/policy/evolver.py +++ b/apps/api/src/cora/trust/aggregates/policy/evolver.py @@ -10,9 +10,14 @@ """ from collections.abc import Sequence +from dataclasses import replace from typing import assert_never -from cora.trust.aggregates.policy.events import PolicyDefined, PolicyEvent +from cora.trust.aggregates.policy.events import ( + PolicyDefined, + PolicyEvent, + PolicyGrantRevoked, +) from cora.trust.aggregates.policy.state import Policy, PolicyName @@ -36,6 +41,12 @@ def evolve(state: Policy | None, event: PolicyEvent) -> Policy: permitted_commands=frozenset(permitted_commands), surface_id=surface_id, ) + case PolicyGrantRevoked(principal_id=principal_id): + assert state is not None, "PolicyGrantRevoked requires prior state" + return replace( + state, + permitted_principal_ids=state.permitted_principal_ids - {principal_id}, + ) case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/trust/aggregates/policy/state.py b/apps/api/src/cora/trust/aggregates/policy/state.py index ef1c37044ee..5b62f979aa9 100644 --- a/apps/api/src/cora/trust/aggregates/policy/state.py +++ b/apps/api/src/cora/trust/aggregates/policy/state.py @@ -43,6 +43,7 @@ from cora.infrastructure.ports import Allow, AuthzResult, Deny from cora.infrastructure.routing import NIL_SENTINEL_ID from cora.shared.bounded_text import bounded_name +from cora.shared.text_bounds import REASON_MAX_LENGTH POLICY_NAME_MAX_LENGTH = 200 @@ -81,6 +82,25 @@ def __init__(self) -> None: ) +class PolicyNotFoundError(Exception): + """A transition (e.g. revoke_grant) targeted a Policy stream with no events.""" + + def __init__(self, policy_id: UUID) -> None: + super().__init__(f"Policy {policy_id} not found") + self.policy_id = policy_id + + +class InvalidPolicyGrantRevokeReasonError(ValueError): + """Reason text on revoke_grant is empty or too long after trim.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Policy grant-revoke reason must be 1-{REASON_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + @bounded_name(max_length=POLICY_NAME_MAX_LENGTH, error_class=InvalidPolicyNameError) @dataclass(frozen=True) class PolicyName: diff --git a/apps/api/src/cora/trust/features/revoke_grant/__init__.py b/apps/api/src/cora/trust/features/revoke_grant/__init__.py new file mode 100644 index 00000000000..42b16569982 --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/__init__.py @@ -0,0 +1,9 @@ +"""Vertical slice for the `RevokePolicyGrant` command.""" + +from cora.trust.features.revoke_grant import tool +from cora.trust.features.revoke_grant.command import RevokePolicyGrant +from cora.trust.features.revoke_grant.decider import decide +from cora.trust.features.revoke_grant.handler import Handler, bind +from cora.trust.features.revoke_grant.route import router + +__all__ = ["Handler", "RevokePolicyGrant", "bind", "decide", "router", "tool"] diff --git a/apps/api/src/cora/trust/features/revoke_grant/command.py b/apps/api/src/cora/trust/features/revoke_grant/command.py new file mode 100644 index 00000000000..7397d7559e6 --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/command.py @@ -0,0 +1,35 @@ +"""The `RevokePolicyGrant` command: intent dataclass for this slice. + +`policy_id` is the target Policy aggregate. `permitted_principal_id` is the grant +being removed: the principal whose entry is dropped from the Policy's +`permitted_principal_ids` allow-list (the field name ties directly to that state +field, so it is not confused with the invoker). `reason` is REQUIRED operator free +text; it rides the `PolicyGrantRevoked` event so operator context survives on the +immutable event log, and it feeds the downstream mid-run compensation Decision. + +The invoker's principal id is supplied separately by the application handler at +call time (its `principal_id` kwarg) and stamped onto the event as `revoked_by`. +The command carries only the grant being removed, never the invoker. + +Command class carries the aggregate qualifier (`RevokePolicyGrant`) because it +acts on a per-aggregate sub-concept (a grant = one allow-list entry); the slice +directory and MCP tool drop it (`revoke_grant`), per the sub-concept naming rule. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class RevokePolicyGrant: + """Revoke one principal's grant from a Policy. + + Removes `permitted_principal_id` from `Policy.permitted_principal_ids`. + Silently idempotent: a no-op when the principal is already absent. `reason` + is required (1-REASON_MAX_LENGTH chars, validated at the API boundary and + defensively at the decider). + """ + + policy_id: UUID + permitted_principal_id: UUID + reason: str diff --git a/apps/api/src/cora/trust/features/revoke_grant/decider.py b/apps/api/src/cora/trust/features/revoke_grant/decider.py new file mode 100644 index 00000000000..88ffbd10ecb --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/decider.py @@ -0,0 +1,68 @@ +"""Pure decider for the `RevokePolicyGrant` command. + +Set-membership removal: drops one principal from the Policy's +`permitted_principal_ids` allow-list. This is a set-membership variant (like +`revoke_tool_from_agent`), so it is SILENTLY IDEMPOTENT: revoking a principal +that is not in the permitted set returns `[]` (no event), not an error. There is +NO `PolicyCannotRevokeGrantError` / 409, because Policy has no status FSM to guard. + +`revoked_by` is threaded in by the handler from the request envelope's +principal_id, and stamped onto the `PolicyGrantRevoked` event for the audit +denorm. Kind-blind: revoking a human's grant and an autonomous agent's grant run +the same code; the decider compares bare principal identifiers and never reads +actor kind. +""" + +from datetime import datetime +from uuid import UUID + +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.aggregates.policy import ( + InvalidPolicyGrantRevokeReasonError, + Policy, + PolicyGrantRevoked, + PolicyNotFoundError, +) +from cora.trust.features.revoke_grant.command import RevokePolicyGrant + + +def decide( + state: Policy | None, + command: RevokePolicyGrant, + *, + revoked_by: UUID, + now: datetime, +) -> list[PolicyGrantRevoked]: + """Decide the events produced by revoking a grant. + + Invariants: + - State must not be None (Policy must exist) -> PolicyNotFoundError + - Reason 1-REASON_MAX_LENGTH chars after trim + -> InvalidPolicyGrantRevokeReasonError + - Principal not in the permitted set -> no event ([]), silently idempotent + + No self-lockout or last-grant guard: a principal MAY revoke its own grant, + and the final grant MAY be revoked (leaving a deny-all Policy). This is + intentional for the kill-switch, whose whole point is to remove a principal's + authority; recovery is a fresh grant via define_policy, not a guard here. + + Ordering (reason-validation BEFORE the membership no-op) is deliberate: reason + is a required bounded command field, so a malformed command is rejected even + when the target happens to be absent, closing a validation-bypass path. + """ + if state is None: + raise PolicyNotFoundError(command.policy_id) + trimmed = command.reason.strip() + if not trimmed or len(trimmed) > REASON_MAX_LENGTH: + raise InvalidPolicyGrantRevokeReasonError(command.reason) + if command.permitted_principal_id not in state.permitted_principal_ids: + return [] + return [ + PolicyGrantRevoked( + policy_id=state.id, + principal_id=command.permitted_principal_id, + revoked_by=revoked_by, + reason=trimmed, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/trust/features/revoke_grant/handler.py b/apps/api/src/cora/trust/features/revoke_grant/handler.py new file mode 100644 index 00000000000..d96aed6053b --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/handler.py @@ -0,0 +1,155 @@ +"""Application handler for the `revoke_grant` slice. + +Single-stream set-removal: loads the Policy, threads the envelope `principal_id` +into the decider as `revoked_by` (the invoker; distinct from the command's +`principal_id`, which is the grant being removed), and appends the resulting +`PolicyGrantRevoked` at the loaded version. Same load-authorize-fold-decide-append +sequence as `define_policy`, but a transition (append at the loaded version, not +genesis 0). + +NOT idempotency-wrapped at wire.py: revoke is set-membership silently idempotent +(a re-issued revoke of an already-absent principal produces no event), so +HTTP-layer caching adds no value. + +The mid-run compensation that HOLDS the revoked principal's in-flight runs is a +SEPARATE eventually-consistent subscriber reacting to the committed +`PolicyGrantRevoked` event; it is deliberately kept OUT of this handler to obey +the compensation-slice no-cascade lock and keep `revoke_grant` a routine +authorization edit. +""" + +from typing import Protocol +from uuid import UUID + +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.logging import get_logger +from cora.infrastructure.ports import Deny +from cora.infrastructure.routing import NIL_SENTINEL_ID +from cora.trust.aggregates.policy import ( + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.errors import UnauthorizedError +from cora.trust.features.revoke_grant.command import RevokePolicyGrant +from cora.trust.features.revoke_grant.decider import decide + +_STREAM_TYPE = "Policy" +_COMMAND_NAME = "RevokePolicyGrant" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare revoke_grant handler -- what `bind()` returns.""" + + async def __call__( + self, + command: RevokePolicyGrant, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: ... + + +def bind(deps: Kernel) -> Handler: + """Build a revoke_grant handler closed over the shared deps.""" + + async def handler( + command: RevokePolicyGrant, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "revoke_grant.start", + command_name=_COMMAND_NAME, + policy_id=str(command.policy_id), + revoked_principal_id=str(command.permitted_principal_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + # Authorization is command-level (may this principal issue RevokePolicyGrant + # at all), not per-Policy: there is no per-instance grant check. This is the + # deliberate posture for the kill-switch, matching define_policy; a + # per-resource authz check would land on the Authorize port if ever needed. + decision = await deps.authz.authorize( + principal_id=principal_id, + command_name=_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=surface_id, + ) + if isinstance(decision, Deny): + _log.info( + "revoke_grant.denied", + command_name=_COMMAND_NAME, + policy_id=str(command.policy_id), + revoked_principal_id=str(command.permitted_principal_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + now = deps.clock.now() + stored, version = await deps.event_store.load(_STREAM_TYPE, command.policy_id) + state = fold([from_stored(s) for s in stored]) + + domain_events = decide( + state=state, + command=command, + revoked_by=principal_id, + now=now, + ) + + if not domain_events: + _log.info( + "revoke_grant.noop", + command_name=_COMMAND_NAME, + policy_id=str(command.policy_id), + revoked_principal_id=str(command.permitted_principal_id), + correlation_id=str(correlation_id), + ) + return + + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=deps.id_generator.new_id(), + command_name=_COMMAND_NAME, + correlation_id=correlation_id, + causation_id=causation_id, + principal_id=principal_id, + ) + for event in domain_events + ] + await deps.event_store.append( + stream_type=_STREAM_TYPE, + stream_id=command.policy_id, + expected_version=version, + events=new_events, + ) + + _log.info( + "revoke_grant.success", + command_name=_COMMAND_NAME, + policy_id=str(command.policy_id), + revoked_principal_id=str(command.permitted_principal_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + event_count=len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/trust/features/revoke_grant/route.py b/apps/api/src/cora/trust/features/revoke_grant/route.py new file mode 100644 index 00000000000..675ea3a2010 --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/route.py @@ -0,0 +1,98 @@ +"""HTTP route for the `revoke_grant` slice. + +Action endpoint at `POST /policies/{policy_id}/revoke-grant`. Body carries the +`permitted_principal_id` grant to remove plus a required `reason`. 204 on success +(a transition, no content). Silently idempotent: revoking an already-absent +principal still returns 204. + +The body's `permitted_principal_id` is the grant being revoked (it names the +Policy's `permitted_principal_ids` entry), distinct from the invoking principal +(supplied by `get_principal_id` and threaded as the handler's `principal_id` +kwarg, stamped on the event as `revoked_by`). +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.features.revoke_grant.command import RevokePolicyGrant +from cora.trust.features.revoke_grant.handler import Handler + + +class RevokeGrantRequest(BaseModel): + """Body for `POST /policies/{policy_id}/revoke-grant`. + + `permitted_principal_id` is the grant to remove from the Policy's allow-list. + `reason` is operator-supplied free text (audit-log breadcrumb; also + feeds the downstream mid-run compensation Decision). MUST NOT contain PII. + """ + + permitted_principal_id: UUID = Field( + ..., + description="Principal (UUID) whose grant is revoked from the policy.", + ) + reason: str = Field( + ..., + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Operator-supplied reason for the revocation (audit-log breadcrumb; no PII).", + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.trust.revoke_grant + return handler + + +router = APIRouter(tags=["trust"]) + + +@router.post( + "/policies/{policy_id}/revoke-grant", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (whitespace-only reason).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Policy exists with the given id.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Request body failed schema validation.", + }, + }, + summary="Revoke one principal's grant from a Policy", +) +async def post_policies_revoke_grant( + policy_id: Annotated[UUID, Path(description="Target Policy's id.")], + body: RevokeGrantRequest, + handler: Annotated[Handler, Depends(_get_handler)], + cid: Annotated[UUID, Depends(get_correlation_id)], + principal_id: Annotated[UUID, Depends(get_principal_id)], + surface_id: Annotated[UUID, Depends(get_surface_id)], +) -> None: + await handler( + RevokePolicyGrant( + policy_id=policy_id, + permitted_principal_id=body.permitted_principal_id, + reason=body.reason, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/trust/features/revoke_grant/tool.py b/apps/api/src/cora/trust/features/revoke_grant/tool.py new file mode 100644 index 00000000000..246b0b0cb74 --- /dev/null +++ b/apps/api/src/cora/trust/features/revoke_grant/tool.py @@ -0,0 +1,63 @@ +"""MCP tool for the `revoke_grant` slice.""" + +from collections.abc import Callable +from typing import Annotated, Any +from uuid import UUID + +from mcp.server.fastmcp import Context, FastMCP +from pydantic import BaseModel, Field + +from cora.infrastructure.mcp_principal import get_mcp_principal_id +from cora.infrastructure.observability import current_correlation_id +from cora.infrastructure.routing import get_mcp_surface_id +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.features.revoke_grant.command import RevokePolicyGrant +from cora.trust.features.revoke_grant.handler import Handler + + +class RevokeGrantOutput(BaseModel): + """Structured output of the `revoke_grant` MCP tool.""" + + policy_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `revoke_grant` tool on the given MCP server.""" + + @mcp.tool( + name="revoke_grant", + description=( + "Revoke one principal's grant from a Policy's allow-list. Silently " + "idempotent: revoking an already-absent principal is a no-op. This " + "is the kill-switch trigger: a separate subscriber reacts to hold " + "the revoked principal's in-flight runs. Reason REQUIRED. Reason " + "MUST NOT contain PII." + ), + ) + async def revoke_grant_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + policy_id: Annotated[UUID, Field(description="Target Policy's id.")], + permitted_principal_id: Annotated[ + UUID, Field(description="Principal whose grant is revoked from the policy.") + ], + reason: Annotated[ + str, + Field( + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Operator-supplied reason for the revocation (no PII).", + ), + ], + ) -> RevokeGrantOutput: + handler = get_handler() + await handler( + RevokePolicyGrant( + policy_id=policy_id, + permitted_principal_id=permitted_principal_id, + reason=reason, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return RevokeGrantOutput(policy_id=policy_id) diff --git a/apps/api/src/cora/trust/routes.py b/apps/api/src/cora/trust/routes.py index c696363db53..4a0ad425f1f 100644 --- a/apps/api/src/cora/trust/routes.py +++ b/apps/api/src/cora/trust/routes.py @@ -45,9 +45,11 @@ InvalidConduitNameError, ) from cora.trust.aggregates.policy import ( + InvalidPolicyGrantRevokeReasonError, InvalidPolicyNameError, InvalidPolicySurfaceError, PolicyAlreadyExistsError, + PolicyNotFoundError, ) from cora.trust.aggregates.surface import InvalidSurfaceNameError, SurfaceAlreadyExistsError from cora.trust.aggregates.visit import ( @@ -94,6 +96,7 @@ register_visit, release_control_of_surface, resume_visit, + revoke_grant, start_visit, take_control_of_surface, void_visit, @@ -194,6 +197,7 @@ def register_trust_routes(app: FastAPI) -> None: app.include_router(list_conduits.router) app.include_router(list_policies.router) app.include_router(list_permissions.router) + app.include_router(revoke_grant.router) # Visit lifecycle slices. app.include_router(register_visit.router) app.include_router(record_visit_arrival.router) @@ -239,9 +243,11 @@ def register_trust_routes(app: FastAPI) -> None: VisitNotFoundError, VisitActorNotCheckedInError, VisitParentNotFoundError, + PolicyNotFoundError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for invalid_400_cls in ( + InvalidPolicyGrantRevokeReasonError, InvalidPolicySurfaceError, InvalidVisitPlannedPeriodError, InvalidVisitReasonError, diff --git a/apps/api/src/cora/trust/tools.py b/apps/api/src/cora/trust/tools.py index 756dcd7f11f..2a5e5f771fd 100644 --- a/apps/api/src/cora/trust/tools.py +++ b/apps/api/src/cora/trust/tools.py @@ -30,6 +30,7 @@ from cora.trust.features.register_visit import tool as register_visit_tool from cora.trust.features.release_control_of_surface import tool as release_control_of_surface_tool from cora.trust.features.resume_visit import tool as resume_visit_tool +from cora.trust.features.revoke_grant import tool as revoke_grant_tool from cora.trust.features.start_visit import tool as start_visit_tool from cora.trust.features.take_control_of_surface import tool as take_control_of_surface_tool from cora.trust.features.void_visit import tool as void_visit_tool @@ -52,6 +53,7 @@ def register_trust_tools( list_conduits_tool.register(mcp, get_handler=lambda: get_handlers().list_conduits) list_policies_tool.register(mcp, get_handler=lambda: get_handlers().list_policies) list_permissions_tool.register(mcp, get_handler=lambda: get_handlers().list_permissions) + revoke_grant_tool.register(mcp, get_handler=lambda: get_handlers().revoke_grant) # Visit lifecycle tools. register_visit_tool.register(mcp, get_handler=lambda: get_handlers().register_visit) record_visit_arrival_tool.register(mcp, get_handler=lambda: get_handlers().record_visit_arrival) diff --git a/apps/api/src/cora/trust/wire.py b/apps/api/src/cora/trust/wire.py index 65cc92dac87..284a75f2f39 100644 --- a/apps/api/src/cora/trust/wire.py +++ b/apps/api/src/cora/trust/wire.py @@ -44,6 +44,7 @@ register_visit, release_control_of_surface, resume_visit, + revoke_grant, start_visit, take_control_of_surface, void_visit, @@ -83,6 +84,7 @@ class TrustHandlers: check_out_visit: check_out_visit.Handler take_control_of_surface: take_control_of_surface.Handler release_control_of_surface: release_control_of_surface.Handler + revoke_grant: revoke_grant.Handler evaluate_policy: evaluate_policy.Handler get_surface: get_surface.Handler list_zones: list_zones.Handler @@ -214,6 +216,11 @@ def wire_trust(deps: Kernel) -> TrustHandlers: command_name="ReleaseControlOfSurface", bc=_BC, ), + revoke_grant=with_tracing( + revoke_grant.bind(deps), + command_name="RevokePolicyGrant", + bc=_BC, + ), evaluate_policy=with_tracing( evaluate_policy.bind(deps), command_name="EvaluatePolicy", diff --git a/apps/api/tests/architecture/test_slice_verb_names_subject.py b/apps/api/tests/architecture/test_slice_verb_names_subject.py index 5cbf8540f86..647ec96b43d 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -93,6 +93,7 @@ "outcome", # Operation: append_outcomes (steered-pass measured-values logbook entry) "iteration", # Operation: start_iteration / end_iteration (convergence-loop boundary noun) "entry", # generic logbook-entry vocabulary + "grant", # Trust: revoke_grant (a grant = one entry in Policy.permitted_principal_ids) "permission", # Trust: list_permissions "event", # Agent: dismiss_event_in_reaction "reaction", # Agent: dismiss_event_in_reaction (Reaction = subscriber class) diff --git a/apps/api/tests/contract/test_revoke_grant_endpoint.py b/apps/api/tests/contract/test_revoke_grant_endpoint.py new file mode 100644 index 00000000000..83d03d7c0bf --- /dev/null +++ b/apps/api/tests/contract/test_revoke_grant_endpoint.py @@ -0,0 +1,77 @@ +"""HTTP contract test for the `revoke_grant` endpoint. + +Pins the REST surface at `POST /policies/{policy_id}/revoke-grant`: 204 happy +path, 404 unknown policy, 400 blank reason, and the silently-idempotent noop +(revoking an already-absent principal still returns 204). Policies are seeded +via `define_policy`'s `POST /policies`. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.infrastructure.routing import SYSTEM_HTTP_SURFACE_ID + +_CONDUIT = "01900000-0000-7000-8000-00000000aaaa" +_PRINCIPAL = "01900000-0000-7000-8000-000000000a01" + + +def _define_policy(client: TestClient) -> str: + """Define a policy granting `_PRINCIPAL`; return its id.""" + response = client.post( + "/policies", + json={ + "name": "Beam-team", + "conduit_id": _CONDUIT, + "permitted_principal_ids": [_PRINCIPAL], + "permitted_commands": ["RegisterActor"], + "surface_id": str(SYSTEM_HTTP_SURFACE_ID), + }, + ) + assert response.status_code == 201, response.text + return response.json()["policy_id"] + + +@pytest.mark.contract +def test_revoke_grant_returns_204_for_present_principal() -> None: + with TestClient(create_app()) as client: + policy_id = _define_policy(client) + response = client.post( + f"/policies/{policy_id}/revoke-grant", + json={"permitted_principal_id": _PRINCIPAL, "reason": "access review"}, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_revoke_grant_returns_204_for_absent_principal_silent_idempotent() -> None: + with TestClient(create_app()) as client: + policy_id = _define_policy(client) + response = client.post( + f"/policies/{policy_id}/revoke-grant", + json={"permitted_principal_id": str(uuid4()), "reason": "access review"}, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_revoke_grant_returns_404_when_policy_absent() -> None: + with TestClient(create_app()) as client: + response = client.post( + f"/policies/{uuid4()}/revoke-grant", + json={"permitted_principal_id": _PRINCIPAL, "reason": "access review"}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_revoke_grant_returns_400_on_whitespace_only_reason() -> None: + with TestClient(create_app()) as client: + policy_id = _define_policy(client) + response = client.post( + f"/policies/{policy_id}/revoke-grant", + json={"permitted_principal_id": _PRINCIPAL, "reason": " "}, + ) + assert response.status_code == 400 diff --git a/apps/api/tests/contract/test_revoke_grant_mcp_tool.py b/apps/api/tests/contract/test_revoke_grant_mcp_tool.py new file mode 100644 index 00000000000..12686beaa3c --- /dev/null +++ b/apps/api/tests/contract/test_revoke_grant_mcp_tool.py @@ -0,0 +1,105 @@ +"""Contract tests for the `revoke_grant` MCP tool. + +Shared MCP helpers live in `tests/contract/_mcp_helpers.py`. Policies are seeded +via `define_policy`'s `POST /policies` (same app state as the MCP surface). +""" + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.infrastructure.routing import SYSTEM_HTTP_SURFACE_ID +from tests.contract._mcp_helpers import open_session, parse_sse_data + +_CONDUIT = "01900000-0000-7000-8000-00000000aaaa" +_PRINCIPAL = "01900000-0000-7000-8000-000000000a01" + + +def _define_policy(client: TestClient) -> str: + response = client.post( + "/policies", + json={ + "name": "Beam-team", + "conduit_id": _CONDUIT, + "permitted_principal_ids": [_PRINCIPAL], + "permitted_commands": ["RegisterActor"], + "surface_id": str(SYSTEM_HTTP_SURFACE_ID), + }, + ) + assert response.status_code == 201, response.text + return response.json()["policy_id"] + + +@pytest.mark.contract +def test_mcp_lists_revoke_grant_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + tool_names = [t["name"] for t in body["result"]["tools"]] + assert "revoke_grant" in tool_names + + +@pytest.mark.contract +def test_mcp_revoke_grant_tool_returns_structured_policy_id() -> None: + with TestClient(create_app()) as client: + policy_id = _define_policy(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "revoke_grant", + "arguments": { + "policy_id": policy_id, + "permitted_principal_id": _PRINCIPAL, + "reason": "access review", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + assert result["structuredContent"]["policy_id"] == policy_id + + +@pytest.mark.contract +def test_mcp_revoke_grant_tool_returns_iserror_on_invalid_reason() -> None: + """Whitespace-only reason passes Pydantic min_length=1 but trips the + domain VO; FastMCP wraps the raised InvalidPolicyGrantRevokeReasonError.""" + with TestClient(create_app()) as client: + policy_id = _define_policy(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "revoke_grant", + "arguments": { + "policy_id": policy_id, + "permitted_principal_id": _PRINCIPAL, + "reason": " ", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is True + assert "Policy grant-revoke reason" in result["content"][0]["text"] diff --git a/apps/api/tests/integration/_helpers.py b/apps/api/tests/integration/_helpers.py index 2f17391e9d7..4a3e0c1766b 100644 --- a/apps/api/tests/integration/_helpers.py +++ b/apps/api/tests/integration/_helpers.py @@ -53,8 +53,10 @@ FakeClock, FixedIdGenerator, IdempotencyStore, + IdGenerator, ProfileStore, RoleLookup, + RunActorInvolvementLookup, ) from cora.shared.facility_code import FacilityCode @@ -73,6 +75,7 @@ def build_postgres_deps( *, now: datetime, ids: list[UUID] | None = None, + id_generator: IdGenerator | None = None, authz: Authorize | None = None, event_store: EventStore | None = None, idempotency_store: IdempotencyStore | None = None, @@ -82,6 +85,7 @@ def build_postgres_deps( facility_lookup: FacilityLookup | None = None, asset_lookup: AssetLookup | None = None, role_lookup: RoleLookup | None = None, + run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, profile_store: ProfileStore | None = None, llm: LLM | None = None, ) -> Kernel: @@ -113,7 +117,7 @@ def build_postgres_deps( pool, settings=Settings(app_env="test"), # type: ignore[call-arg] clock=FakeClock(now), - id_generator=FixedIdGenerator(list(ids or [])), + id_generator=id_generator or FixedIdGenerator(list(ids or [])), authz=authz or AllowAllAuthorize(), event_store=event_store, idempotency_store=idempotency_store, @@ -123,6 +127,7 @@ def build_postgres_deps( facility_lookup=facility_lookup, asset_lookup=asset_lookup, role_lookup=role_lookup, + run_actor_involvement_lookup=run_actor_involvement_lookup, profile_store=profile_store, llm=llm, ) diff --git a/apps/api/tests/integration/test_authority_revocation_holder_postgres.py b/apps/api/tests/integration/test_authority_revocation_holder_postgres.py new file mode 100644 index 00000000000..3f192ab62f4 --- /dev/null +++ b/apps/api/tests/integration/test_authority_revocation_holder_postgres.py @@ -0,0 +1,250 @@ +"""End-to-end integration: the kill-switch (K1 + K2 + K3) against real Postgres. + +Exercises the whole chain: a principal starts a real Run (RunStarted envelope +principal == that principal), K2's involvement projection folds it, a +PolicyGrantRevoked (K1's trigger) is issued, and draining K2's projection + the +K3 holder subscriber together holds the run and records a Decision. + +Pins: + - a Running run STARTED by the revoked principal is held (RunHeld persisted, + aggregate folds to Held) and a Decision(context=AuthorityRevocationHold, + choice=Held) is written, parented to the revocation event. + - a run started by a DIFFERENT principal is left Running (kind-blind attribution + is by starter, not a blanket hold). + - the hold uses the aggregate's Running-only guard (an already-held run -> the + holder records HoldDeferred, no crash, status unchanged). +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +# pyright: reportPrivateUsage=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.agent.seed_authority_revocation_holder import seed_authority_revocation_holder_agent +from cora.agent.subscribers.authority_revocation_holder import ( + _derive_decision_id, + make_authority_revocation_holder_subscriber, +) +from cora.decision.aggregates.decision import load_decision +from cora.equipment.aggregates.asset import AssetTier +from cora.equipment.features import add_asset_family, define_family, register_asset +from cora.equipment.features.add_asset_family import AddAssetFamily +from cora.equipment.features.define_family import DefineFamily +from cora.equipment.features.register_asset import RegisterAsset +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports import UUIDv7Generator +from cora.infrastructure.ports.event_store import StoredEvent +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.recipe.aggregates.method import ExecutionPattern +from cora.recipe.features import define_method, define_plan, define_practice +from cora.recipe.features.define_method import DefineMethod +from cora.recipe.features.define_plan import DefinePlan +from cora.recipe.features.define_practice import DefinePractice +from cora.run.adapters import PostgresRunActorInvolvementLookup +from cora.run.aggregates.run import RunStatus, load_run +from cora.run.features import start_run +from cora.run.features.hold_run import HoldRun +from cora.run.features.hold_run import bind as bind_hold_run +from cora.run.features.start_run import StartRun +from cora.run.projections import RunActorInvolvementProjection +from cora.subject.features import mount_subject, register_subject +from cora.subject.features.mount_subject import MountSubject +from cora.subject.features.register_subject import RegisterSubject +from tests.integration._helpers import build_postgres_deps, seed_capability_postgres +from tests.unit.subject._helpers import seed_active_asset + +_NOW = datetime(2026, 7, 6, 12, 0, 0, tzinfo=UTC) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +def _deps(db_pool: asyncpg.Pool) -> Kernel: + """Kernel with a UUIDv7 id generator (the genesis chain mints many ids) and the + real Postgres involvement lookup wired, so the K3 holder reads K2's projection.""" + return build_postgres_deps( + db_pool, + now=_NOW, + id_generator=UUIDv7Generator(), + run_actor_involvement_lookup=PostgresRunActorInvolvementLookup(db_pool), + ) + + +async def _start_run_as(deps: Kernel, *, starter: UUID, tag: str) -> UUID: + """Seed the upstream chain and start one Run as `starter`; return its run_id. + + Each call uses fresh streams (distinct Family name per tag) so runs are + independent. The RunStarted envelope principal is `starter`, which is what K2 + attributes on. + """ + family_id = await define_family.bind(deps)( + DefineFamily(name=f"FlyMotion-{tag}", affordances=frozenset()), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset( + name=f"EigerDetector-{tag}", tier=AssetTier.UNIT, parent_id=None, facility_code="cora" + ), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + await add_asset_family.bind(deps)( + AddAssetFamily(asset_id=asset_id, family_id=family_id), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + cap_event_id = uuid4() + await seed_capability_postgres(deps.event_store, cap_event_id) + method_id = await define_method.bind(deps)( + DefineMethod( + execution_pattern=ExecutionPattern.BATCH, + capability_id=cap_event_id, + name=f"XRF Fly Scan {tag}", + needed_family_ids=frozenset({family_id}), + ), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + practice_id = await define_practice.bind(deps)( + DefinePractice(name=f"APS XRF {tag}", method_id=method_id, site_id=uuid4()), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + plan_id = await define_plan.bind(deps)( + DefinePlan( + name=f"32-ID FlyScan {tag}", + practice_id=practice_id, + asset_ids=frozenset({asset_id}), + ), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + subject_id = await register_subject.bind(deps)( + RegisterSubject(name=f"PorousCeramicSample-{tag}"), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + mount_asset_id = await seed_active_asset( + deps.event_store, asset_id=uuid4(), now=_NOW, correlation_id=_CORRELATION_ID + ) + await mount_subject.bind(deps)( + MountSubject(subject_id=subject_id, asset_id=mount_asset_id, reason=""), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + return await start_run.bind(deps)( + StartRun(name=f"session {tag}", plan_id=plan_id, subject_id=subject_id), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + + +def _revocation_event(*, revoked_principal_id: UUID) -> StoredEvent: + """Build a PolicyGrantRevoked (K1 trigger) as a StoredEvent. + + Delivered to the subscriber via a direct `apply()` call, matching the + established subscriber-integration-test convention (RunDebriefer / + CautionDrafter tests apply() directly rather than going through the + projection-worker drain, which requires a seeded bookmark row).""" + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Policy", + stream_id=uuid4(), + version=1, + event_type="PolicyGrantRevoked", + schema_version=1, + payload={ + "policy_id": str(uuid4()), + "principal_id": str(revoked_principal_id), + "revoked_by": str(uuid4()), + "reason": "trust withdrawn", + "occurred_at": _NOW.isoformat(), + }, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +async def _drain_involvement(db_pool: asyncpg.Pool) -> None: + registry = ProjectionRegistry() + registry.register(RunActorInvolvementProjection()) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +async def _run_holder(deps: Kernel, event: StoredEvent) -> None: + """Deliver one revocation event to the holder subscriber via direct apply().""" + holder = make_authority_revocation_holder_subscriber(deps) + await holder.apply(event, conn=None) # type: ignore[arg-type] + + +@pytest.mark.integration +async def test_revoke_holds_the_revoked_principals_running_run(db_pool: asyncpg.Pool) -> None: + deps = _deps(db_pool) + await seed_authority_revocation_holder_agent(deps) + + revoked = uuid4() + run_id = await _start_run_as(deps, starter=revoked, tag="revoked") + await _drain_involvement(db_pool) + + event = _revocation_event(revoked_principal_id=revoked) + await _run_holder(deps, event) + + state = await load_run(deps.event_store, run_id) + assert state is not None + assert state.status is RunStatus.HELD + + decision = await load_decision(deps.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "Held" + assert decision.parent_id == event.event_id + + +@pytest.mark.integration +async def test_revoke_leaves_other_principals_run_running(db_pool: asyncpg.Pool) -> None: + deps = _deps(db_pool) + await seed_authority_revocation_holder_agent(deps) + + revoked = uuid4() + other = uuid4() + revoked_run = await _start_run_as(deps, starter=revoked, tag="rev") + other_run = await _start_run_as(deps, starter=other, tag="other") + await _drain_involvement(db_pool) + + await _run_holder(deps, _revocation_event(revoked_principal_id=revoked)) + + revoked_state = await load_run(deps.event_store, revoked_run) + other_state = await load_run(deps.event_store, other_run) + assert revoked_state is not None and revoked_state.status is RunStatus.HELD + assert other_state is not None and other_state.status is RunStatus.RUNNING + + +@pytest.mark.integration +async def test_already_held_run_folds_to_hold_deferred(db_pool: asyncpg.Pool) -> None: + deps = _deps(db_pool) + await seed_authority_revocation_holder_agent(deps) + + revoked = uuid4() + run_id = await _start_run_as(deps, starter=revoked, tag="held") + # Operator-hold it first, so the holder's own hold hits the Running-only guard. + await bind_hold_run(deps)( + HoldRun(run_id=run_id), + principal_id=revoked, + correlation_id=_CORRELATION_ID, + ) + await _drain_involvement(db_pool) + + event = _revocation_event(revoked_principal_id=revoked) + await _run_holder(deps, event) + + state = await load_run(deps.event_store, run_id) + assert state is not None and state.status is RunStatus.HELD + decision = await load_decision(deps.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "HoldDeferred" diff --git a/apps/api/tests/integration/test_run_actor_involvement_lookup_postgres.py b/apps/api/tests/integration/test_run_actor_involvement_lookup_postgres.py new file mode 100644 index 00000000000..a58d43a33f4 --- /dev/null +++ b/apps/api/tests/integration/test_run_actor_involvement_lookup_postgres.py @@ -0,0 +1,339 @@ +"""Integration: RunActorInvolvementProjection + PostgresRunActorInvolvementLookup +against real Postgres (kill-switch K2, the actor-involvement resolver). + +The projection unit tests (test_run_actor_involvement_projection.py) mock the +connection, so the actual table, the `status IN ('Running','Held')` in-flight +filter, and the envelope-principal attribution are only exercised here. + +Seeds Run lifecycle events directly through PostgresEventStore (the STARTER is +the RunStarted event's ENVELOPE principal_id, not a payload field, so a direct +seed is the faithful way to vary the starter per run without the full +start_run upstream chain), drains the projection worker, then queries through +the adapter. + +Pins: + - runs_driven_by returns the in-flight runs a principal STARTED. + - A held run is still in-flight (returned); a resumed run returns to + in-flight; each terminal transition (Completed/Aborted/Stopped/Truncated) + drops the run from the result while retaining its audit row. + - Attribution keys on the RunStarted ENVELOPE principal_id: a different + starter's runs never surface under this principal. + - A RunStarted with no envelope principal (pre-hook / backfilled) is skipped, + not attributed. + - An unknown principal returns []. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.infrastructure.adapters.postgres_event_store import PostgresEventStore +from cora.infrastructure.ports.event_store import NewEvent +from cora.infrastructure.projection import ProjectionRegistry, drain_projections +from cora.run.adapters import PostgresRunActorInvolvementLookup +from cora.run.projections import RunActorInvolvementProjection + +_NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC) +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") + + +async def _drain(db_pool: asyncpg.Pool) -> None: + registry = ProjectionRegistry() + registry.register(RunActorInvolvementProjection()) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +async def _append( + store: PostgresEventStore, + *, + run_id: UUID, + event_type: str, + expected_version: int, + principal_id: UUID | None, + reason: str | None = None, + occurred_at: datetime = _NOW, +) -> None: + payload: dict[str, object] = {"run_id": str(run_id), "occurred_at": occurred_at.isoformat()} + if reason is not None: + payload["reason"] = reason + await store.append( + "Run", + run_id, + expected_version, + [ + NewEvent( + event_id=uuid4(), + event_type=event_type, + schema_version=1, + payload=payload, + occurred_at=occurred_at, + correlation_id=_CORRELATION_ID, + causation_id=None, + metadata={}, + principal_id=principal_id, + ) + ], + ) + + +async def _start_run( + store: PostgresEventStore, + *, + run_id: UUID, + starter: UUID | None, + occurred_at: datetime = _NOW, +) -> None: + await _append( + store, + run_id=run_id, + event_type="RunStarted", + expected_version=0, + principal_id=starter, + occurred_at=occurred_at, + ) + + +@pytest.mark.integration +async def test_unknown_principal_returns_empty(db_pool: asyncpg.Pool) -> None: + """A principal that started no run drives nothing (the normal post-hoc-agent + case).""" + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(uuid4()) == [] + + +@pytest.mark.integration +async def test_started_run_is_driven_by_its_envelope_principal( + db_pool: asyncpg.Pool, +) -> None: + """RunStarted attributes the run to its ENVELOPE principal (the starter), + and a freshly started run is in-flight (Running).""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(starter) == [run_id] + + +@pytest.mark.integration +async def test_held_run_stays_in_flight(db_pool: asyncpg.Pool) -> None: + """RunHeld keeps the run in-flight: a revoked principal's HELD runs must + still be resolvable (they are exactly what the kill-switch holds/keeps held).""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _append( + store, + run_id=run_id, + event_type="RunHeld", + expected_version=1, + principal_id=starter, + reason="beam dropped", + ) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(starter) == [run_id] + + +@pytest.mark.integration +async def test_resumed_run_returns_to_in_flight(db_pool: asyncpg.Pool) -> None: + """Held -> Resumed folds status back to Running; the run stays resolvable.""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _append( + store, run_id=run_id, event_type="RunHeld", expected_version=1, principal_id=starter + ) + await _append( + store, run_id=run_id, event_type="RunResumed", expected_version=2, principal_id=starter + ) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(starter) == [run_id] + + +@pytest.mark.integration +@pytest.mark.parametrize( + ("terminal_type", "reason"), + [ + ("RunCompleted", None), + ("RunAborted", "operator abort"), + ("RunStopped", "controlled early stop"), + ("RunTruncated", "downstream truncation"), + ], +) +async def test_terminal_run_is_excluded_from_in_flight( + db_pool: asyncpg.Pool, terminal_type: str, reason: str | None +) -> None: + """Each terminal transition drops the run from runs_driven_by: a terminal run + can no longer be held, so the resolver must not return it.""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _append( + store, + run_id=run_id, + event_type=terminal_type, + expected_version=1, + principal_id=starter, + reason=reason, + ) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(starter) == [] + + +@pytest.mark.integration +async def test_terminal_row_retained_for_audit(db_pool: asyncpg.Pool) -> None: + """A terminal run leaves its involvement row in place (status='Completed'), + it is only filtered from the in-flight lookup, not deleted.""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _append( + store, run_id=run_id, event_type="RunCompleted", expected_version=1, principal_id=starter + ) + await _drain(db_pool) + + async with db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT status, involvement_role FROM proj_run_actor_involvement " + "WHERE principal_id = $1 AND run_id = $2", + starter, + run_id, + ) + assert row is not None + assert row["status"] == "Completed" + assert row["involvement_role"] == "starter" + + +@pytest.mark.integration +async def test_attribution_is_per_starter(db_pool: asyncpg.Pool) -> None: + """Two principals each start their own run; runs_driven_by returns only the + caller's own run. Attribution keys on the RunStarted envelope principal.""" + store = PostgresEventStore(db_pool) + alice = uuid4() + bob = uuid4() + alice_run = uuid4() + bob_run = uuid4() + await _start_run(store, run_id=alice_run, starter=alice) + await _start_run(store, run_id=bob_run, starter=bob) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + assert await lookup.runs_driven_by(alice) == [alice_run] + assert await lookup.runs_driven_by(bob) == [bob_run] + + +@pytest.mark.integration +async def test_multiple_in_flight_runs_ordered_by_created_at( + db_pool: asyncpg.Pool, +) -> None: + """One principal starting several in-flight runs gets them all back, ordered + by (created_at, run_id). created_at is the RunStarted envelope occurred_at, + so a run started LATER sorts LAST regardless of its run_id: seed the earliest + occurred_at on the largest run_id to prove created_at is the primary key, not + the run_id tiebreak.""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_ids = sorted((uuid4() for _ in range(3)), reverse=True) # descending run_id + times = [ + datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC), + datetime(2026, 7, 5, 12, 5, 0, tzinfo=UTC), + datetime(2026, 7, 5, 12, 10, 0, tzinfo=UTC), + ] + for run_id, when in zip(run_ids, times, strict=True): + await _start_run(store, run_id=run_id, starter=starter, occurred_at=when) + await _drain(db_pool) + + lookup = PostgresRunActorInvolvementLookup(db_pool) + got = await lookup.runs_driven_by(starter) + # Chronological by created_at (earliest first) == the seed order, which is + # DESCENDING run_id: proves created_at sorts ahead of the run_id tiebreak. + assert got == run_ids + + +@pytest.mark.integration +async def test_runstarted_without_envelope_principal_is_skipped( + db_pool: asyncpg.Pool, +) -> None: + """A pre-hook / backfilled RunStarted with no envelope principal_id has no + starter to attribute, so no involvement row is written (no crash, no ghost + attribution).""" + store = PostgresEventStore(db_pool) + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=None) + await _drain(db_pool) + + async with db_pool.acquire() as conn: + count = await conn.fetchval( + "SELECT count(*) FROM proj_run_actor_involvement WHERE run_id = $1", run_id + ) + assert count == 0 + + +@pytest.mark.integration +async def test_replay_from_zero_is_idempotent(db_pool: asyncpg.Pool) -> None: + """Draining the same stream twice (at-least-once redelivery / rebuild) yields + exactly one row in its final state: the INSERT's ON CONFLICT DO NOTHING and + the set-to-constant UPDATEs make apply() idempotent.""" + store = PostgresEventStore(db_pool) + starter = uuid4() + run_id = uuid4() + await _start_run(store, run_id=run_id, starter=starter) + await _append( + store, run_id=run_id, event_type="RunHeld", expected_version=1, principal_id=starter + ) + await _drain(db_pool) + # Second drain replays the bookmark-tracked stream; without idempotency this + # would double-insert or clobber. Reset the bookmark to force a full replay. + async with db_pool.acquire() as conn: + await conn.execute( + "UPDATE projection_bookmarks SET last_transaction_id = '0'::xid8, last_position = 0 " + "WHERE name = 'proj_run_actor_involvement'" + ) + await _drain(db_pool) + + async with db_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT status FROM proj_run_actor_involvement WHERE run_id = $1", run_id + ) + assert len(rows) == 1 + assert rows[0]["status"] == "Held" + + +@pytest.mark.integration +async def test_terminal_event_without_run_started_is_a_harmless_noop( + db_pool: asyncpg.Pool, +) -> None: + """A lifecycle event for a run that has no involvement row (its RunStarted was + skipped for a None principal, or has not yet been projected) updates zero rows: + no crash, no ghost row.""" + store = PostgresEventStore(db_pool) + run_id = uuid4() + # RunStarted with no envelope principal -> no row; a following terminal event + # must not resurrect one. + await _start_run(store, run_id=run_id, starter=None) + await _append( + store, run_id=run_id, event_type="RunCompleted", expected_version=1, principal_id=None + ) + await _drain(db_pool) + + async with db_pool.acquire() as conn: + count = await conn.fetchval( + "SELECT count(*) FROM proj_run_actor_involvement WHERE run_id = $1", run_id + ) + assert count == 0 diff --git a/apps/api/tests/unit/agent/test_agent_subscribers_registration.py b/apps/api/tests/unit/agent/test_agent_subscribers_registration.py index e64ce0db4ec..ea5b46f068d 100644 --- a/apps/api/tests/unit/agent/test_agent_subscribers_registration.py +++ b/apps/api/tests/unit/agent/test_agent_subscribers_registration.py @@ -52,6 +52,20 @@ def test_skips_run_debrief_when_llm_is_none() -> None: assert "run_debriefer" not in registry.names() +@pytest.mark.unit +def test_registers_authority_revocation_holder_unconditionally() -> None: + """The kill-switch (K3) registers ON BY DEFAULT: even with no LLM and default + settings (promoter off), the holder must be present. A kill-switch that must + be turned on is not a kill-switch; this pins the on-by-default contract so a + regression that gates or drops it fails CI.""" + registry = ProjectionRegistry() + kernel = _kernel(llm=None) + + register_agent_subscribers(registry, kernel) # type: ignore[arg-type] + + assert "authority_revocation_holder" in registry.names() + + @pytest.mark.unit def test_registers_caution_promoter_when_enabled() -> None: """The deterministic promoter registers independently of the LLM, gated by diff --git a/apps/api/tests/unit/agent/test_authority_revocation_holder_subscriber.py b/apps/api/tests/unit/agent/test_authority_revocation_holder_subscriber.py new file mode 100644 index 00000000000..34e79f6dd8e --- /dev/null +++ b/apps/api/tests/unit/agent/test_authority_revocation_holder_subscriber.py @@ -0,0 +1,479 @@ +"""Tests for the AuthorityRevocationHolder subscriber (kill-switch K3). + +Covers the apply path against an in-memory event store: a PolicyGrantRevoked +holds each Running Run the revoked principal drives (Pattern C: load + guard + +authorize + append RunHeld), records one Decision(context=AuthorityRevocationHold) +per run, folds a non-Running / missing / Deny run to HoldDeferred, is kind-blind, +skips when unseeded, and is idempotent on re-delivery (deterministic Decision +ids). +""" + +# white-box test of the subscriber internals (private helpers / dispositions); +# the fault-injection fakes proxy the real store via __getattr__, so their +# append() args are dynamically typed. +# pyright: reportPrivateUsage=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportUnknownMemberType=false, reportUnknownVariableType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.agent.seed_authority_revocation_holder import ( + AUTHORITY_REVOCATION_HOLDER_AGENT_ID, + seed_authority_revocation_holder_agent, +) +from cora.agent.subscribers.authority_revocation_holder import ( + AuthorityRevocationHolderSubscriber, + _derive_decision_id, + make_authority_revocation_holder_subscriber, +) +from cora.decision.aggregates.decision import ( + DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD, + load_decision, +) +from cora.infrastructure.config import Settings +from cora.infrastructure.deps import make_inmemory_kernel +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports import ( + AllowAllAuthorize, + Authorize, + ConcurrencyError, + Deny, + FakeClock, + UUIDv7Generator, +) +from cora.infrastructure.ports.event_store import EventStore, StoredEvent +from cora.run.aggregates.run import ( + RunHeld, + RunStarted, + RunStatus, + load_run, +) +from cora.run.aggregates.run import ( + event_type_name as run_event_type_name, +) +from cora.run.aggregates.run import ( + to_payload as run_to_payload, +) + +_NOW = datetime(2026, 7, 6, 12, 0, 0, tzinfo=UTC) + + +def _kernel(authz: Authorize | None = None) -> Kernel: + settings = Settings() # type: ignore[call-arg] + return make_inmemory_kernel( + settings=settings, + clock=FakeClock(_NOW), + id_generator=UUIDv7Generator(), + authz=authz or AllowAllAuthorize(), + ) + + +class _FakeInvolvementLookup: + def __init__(self, run_ids: list[UUID]) -> None: + self._run_ids = run_ids + self.asked_for: list[UUID] = [] + + async def runs_driven_by(self, principal_id: UUID) -> list[UUID]: + self.asked_for.append(principal_id) + return self._run_ids + + +async def _seed_running_run(store: EventStore, *, starter: UUID) -> UUID: + """Append a RunStarted so the run folds to Running; return its run_id.""" + run_id = uuid4() + started = RunStarted( + run_id=run_id, + name="test run", + plan_id=uuid4(), + subject_id=uuid4(), + occurred_at=_NOW, + ) + envelope = to_new_event( + event_type=run_event_type_name(started), + payload=run_to_payload(started), + occurred_at=_NOW, + event_id=uuid4(), + command_name="StartRun", + correlation_id=uuid4(), + causation_id=None, + principal_id=starter, + ) + await store.append("Run", run_id, 0, [envelope]) + return run_id + + +async def _seed_held_run(store: EventStore, *, starter: UUID) -> UUID: + """Append RunStarted + RunHeld so the run folds to Held (already contained).""" + run_id = await _seed_running_run(store, starter=starter) + held = RunHeld(run_id=run_id, decided_by_decision_id=None, occurred_at=_NOW) + envelope = to_new_event( + event_type=run_event_type_name(held), + payload=run_to_payload(held), + occurred_at=_NOW, + event_id=uuid4(), + command_name="HoldRun", + correlation_id=uuid4(), + causation_id=None, + principal_id=starter, + ) + await store.append("Run", run_id, 1, [envelope]) + return run_id + + +def _revocation_event(*, revoked_principal_id: UUID) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Policy", + stream_id=uuid4(), + version=1, + event_type="PolicyGrantRevoked", + schema_version=1, + payload={ + "policy_id": str(uuid4()), + "principal_id": str(revoked_principal_id), + "revoked_by": str(uuid4()), + "reason": "trust withdrawn", + "occurred_at": _NOW.isoformat(), + }, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +def _build( + kernel: Kernel, + *, + run_ids: list[UUID], + lookup: _FakeInvolvementLookup | None = None, +) -> AuthorityRevocationHolderSubscriber: + return AuthorityRevocationHolderSubscriber( + event_store=kernel.event_store, + run_actor_involvement_lookup=lookup or _FakeInvolvementLookup(run_ids), + authz=kernel.authz, + clock=kernel.clock, + id_generator=kernel.id_generator, + ) + + +@pytest.mark.unit +async def test_holds_each_running_run_and_records_held_decision() -> None: + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_a = await _seed_running_run(kernel.event_store, starter=revoked) + run_b = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_a, run_b]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + for run_id in (run_a, run_b): + state = await load_run(kernel.event_store, run_id) + assert state is not None + assert state.status is RunStatus.HELD + + for run_id in (run_a, run_b): + decision = await load_decision( + kernel.event_store, _derive_decision_id(event.event_id, run_id) + ) + assert decision is not None + assert decision.context.value == DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD + assert decision.choice.value == "Held" + assert decision.parent_id == event.event_id + assert decision.decided_by == AUTHORITY_REVOCATION_HOLDER_AGENT_ID + + +@pytest.mark.unit +async def test_asks_lookup_for_the_revoked_principal() -> None: + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + lookup = _FakeInvolvementLookup([]) + sub = _build(kernel, run_ids=[], lookup=lookup) + + await sub.apply(_revocation_event(revoked_principal_id=revoked), conn=None) # type: ignore[arg-type] + + assert lookup.asked_for == [revoked] + + +@pytest.mark.unit +async def test_already_held_run_folds_to_hold_deferred() -> None: + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_id = await _seed_held_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_id]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None + assert state.status is RunStatus.HELD + decision = await load_decision(kernel.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "HoldDeferred" + + +@pytest.mark.unit +async def test_missing_run_folds_to_hold_deferred() -> None: + """A stale involvement row (run id with no stream) records HoldDeferred, no crash.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + ghost_run = uuid4() # never seeded + sub = _build(kernel, run_ids=[ghost_run]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + decision = await load_decision( + kernel.event_store, _derive_decision_id(event.event_id, ghost_run) + ) + assert decision is not None + assert decision.choice.value == "HoldDeferred" + + +@pytest.mark.unit +async def test_authorize_deny_folds_to_hold_deferred() -> None: + """When Authorize denies HoldRun, the run is NOT held and the holder records + HoldDeferred (degrade safe, no crash).""" + + class _DenyAll: + async def authorize( + self, *, principal_id: UUID, command_name: str, conduit_id: UUID, surface_id: UUID + ) -> Deny: + _ = (principal_id, command_name, conduit_id, surface_id) + return Deny(reason="not permitted") + + kernel = _kernel(authz=_DenyAll()) # type: ignore[arg-type] + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_id = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_id]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None + assert state.status is RunStatus.RUNNING # not held: authz denied + decision = await load_decision(kernel.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "HoldDeferred" + + +@pytest.mark.unit +async def test_no_in_flight_runs_writes_no_decision() -> None: + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + sub = _build(kernel, run_ids=[]) + + event = _revocation_event(revoked_principal_id=uuid4()) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + assert ( + await load_decision(kernel.event_store, _derive_decision_id(event.event_id, uuid4())) + is None + ) + + +@pytest.mark.unit +async def test_unseeded_holder_stands_down() -> None: + """Before the bootstrap seed runs, the holder resolves no Actor and does not act.""" + kernel = _kernel() # NOT seeded + revoked = uuid4() + run_id = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_id]) + + await sub.apply(_revocation_event(revoked_principal_id=revoked), conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None + assert state.status is RunStatus.RUNNING # untouched + + +@pytest.mark.unit +async def test_redelivery_is_idempotent() -> None: + """Re-applying the same revocation holds once and records one Held Decision: the + second delivery sees status=Held -> HoldDeferred disposition, but the Decision + id is deterministic so the earlier Held Decision survives (ConcurrencyError + no-op) and only one RunHeld lands on the stream.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_id = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_id]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + await sub.apply(event, conn=None) # type: ignore[arg-type] + + stored, _version = await kernel.event_store.load("Run", run_id) + held_count = sum(1 for s in stored if s.event_type == "RunHeld") + assert held_count == 1 + decision = await load_decision(kernel.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "Held" + + +@pytest.mark.unit +async def test_ignores_non_trigger_event_types() -> None: + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_id = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[run_id]) + + other = _revocation_event(revoked_principal_id=revoked) + object.__setattr__(other, "event_type", "PolicyGrantAdded") + await sub.apply(other, conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None + assert state.status is RunStatus.RUNNING # untouched + + +@pytest.mark.unit +async def test_mixed_fan_out_continues_past_a_deferred_run() -> None: + """A deferred run (already Held) must not stop the loop: a Running sibling is + still held. Pins per-run independence in the fan-out.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + already_held = await _seed_held_run(kernel.event_store, starter=revoked) + running = await _seed_running_run(kernel.event_store, starter=revoked) + sub = _build(kernel, run_ids=[already_held, running]) + + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + held_decision = await load_decision( + kernel.event_store, _derive_decision_id(event.event_id, already_held) + ) + running_decision = await load_decision( + kernel.event_store, _derive_decision_id(event.event_id, running) + ) + assert held_decision is not None and held_decision.choice.value == "HoldDeferred" + assert running_decision is not None and running_decision.choice.value == "Held" + running_state = await load_run(kernel.event_store, running) + assert running_state is not None and running_state.status is RunStatus.HELD + + +@pytest.mark.unit +async def test_one_run_failure_does_not_abandon_siblings() -> None: + """If holding one run raises, the loop isolates it and still holds the rest + (the kill-switch must not silently drop siblings). Simulated by an event store + whose Decision append raises once for a chosen run's Decision id.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + boom_run = await _seed_running_run(kernel.event_store, starter=revoked) + ok_run = await _seed_running_run(kernel.event_store, starter=revoked) + + event = _revocation_event(revoked_principal_id=revoked) + boom_decision_id = _derive_decision_id(event.event_id, boom_run) + real_store = kernel.event_store + raised = {"done": False} + + class _FlakyStore: + def __getattr__(self, name: str) -> object: + return getattr(real_store, name) + + async def append(self, stream_type, stream_id, expected_version, events): # type: ignore[no-untyped-def] + if stream_type == "Decision" and stream_id == boom_decision_id and not raised["done"]: + raised["done"] = True + raise RuntimeError("transient decision-append fault") + return await real_store.append(stream_type, stream_id, expected_version, events) + + sub = AuthorityRevocationHolderSubscriber( + event_store=_FlakyStore(), # type: ignore[arg-type] + run_actor_involvement_lookup=_FakeInvolvementLookup([boom_run, ok_run]), + authz=kernel.authz, + clock=kernel.clock, + id_generator=kernel.id_generator, + ) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + # The sibling was still held despite the first run's fault. + ok_state = await load_run(kernel.event_store, ok_run) + assert ok_state is not None and ok_state.status is RunStatus.HELD + + +@pytest.mark.unit +async def test_lost_concurrency_race_folds_to_hold_deferred() -> None: + """If the Run advances between the load and the RunHeld append, the append's + ConcurrencyError is caught and recorded as HoldDeferred, not raised.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + revoked = uuid4() + run_id = await _seed_running_run(kernel.event_store, starter=revoked) + real_store = kernel.event_store + + class _RaceStore: + def __getattr__(self, name: str) -> object: + return getattr(real_store, name) + + async def append(self, stream_type, stream_id, expected_version, events): # type: ignore[no-untyped-def] + if stream_type == "Run": + raise ConcurrencyError( + stream_type=stream_type, + stream_id=stream_id, + expected=expected_version, + actual=expected_version + 1, + ) + return await real_store.append(stream_type, stream_id, expected_version, events) + + sub = AuthorityRevocationHolderSubscriber( + event_store=_RaceStore(), # type: ignore[arg-type] + run_actor_involvement_lookup=_FakeInvolvementLookup([run_id]), + authz=kernel.authz, + clock=kernel.clock, + id_generator=kernel.id_generator, + ) + event = _revocation_event(revoked_principal_id=revoked) + await sub.apply(event, conn=None) # type: ignore[arg-type] + + decision = await load_decision(kernel.event_store, _derive_decision_id(event.event_id, run_id)) + assert decision is not None + assert decision.choice.value == "HoldDeferred" + + +@pytest.mark.unit +async def test_malformed_payload_is_swallowed_not_wedged() -> None: + """A PolicyGrantRevoked missing principal_id raises inside _handle_revocation; + apply() swallows it (logged skip) so the shared bookmark is never wedged.""" + kernel = _kernel() + await seed_authority_revocation_holder_agent(kernel) + sub = _build(kernel, run_ids=[]) + + event = _revocation_event(revoked_principal_id=uuid4()) + object.__setattr__(event, "payload", {"policy_id": str(uuid4())}) # no principal_id + # Must not raise. + await sub.apply(event, conn=None) # type: ignore[arg-type] + + +@pytest.mark.unit +def test_decision_id_distinct_across_revocations_of_same_run() -> None: + """Two distinct revocation events targeting the same run derive distinct + Decision ids, so a later revocation is not swallowed as a re-delivery of an + earlier one (the derivation keys on the event id, not the principal).""" + run_id = uuid4() + first = _derive_decision_id(uuid4(), run_id) + second = _derive_decision_id(uuid4(), run_id) + assert first != second + + +@pytest.mark.unit +async def test_make_subscriber_from_kernel_wires_deps() -> None: + kernel = _kernel() + sub = make_authority_revocation_holder_subscriber(kernel) + assert sub.name == "authority_revocation_holder" + assert sub.subscribed_event_types == frozenset({"PolicyGrantRevoked"}) + assert sub.batch_size == 1 diff --git a/apps/api/tests/unit/decision/test_authority_revocation_hold_vocab.py b/apps/api/tests/unit/decision/test_authority_revocation_hold_vocab.py new file mode 100644 index 00000000000..11cd46b5a55 --- /dev/null +++ b/apps/api/tests/unit/decision/test_authority_revocation_hold_vocab.py @@ -0,0 +1,43 @@ +"""Tests for the AuthorityRevocationHold Decision vocabulary (kill-switch K3). + +Covers the DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD context constant, the +closed AUTHORITY_REVOCATION_HOLD_CHOICES set, its parity with the +AuthorityRevocationHoldChoice Literal, and a naming guard that the audit-fallback +value stays work-noun-qualified (no bare Deferred) so it does not collide in the +shared, globally-filtered DecisionChoice projection. +""" + +from typing import get_args + +import pytest + +from cora.decision.aggregates.decision import ( + AUTHORITY_REVOCATION_HOLD_CHOICES, + DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD, + AuthorityRevocationHoldChoice, +) + + +@pytest.mark.unit +def test_decision_context_authority_revocation_hold_constant() -> None: + assert DECISION_CONTEXT_AUTHORITY_REVOCATION_HOLD == "AuthorityRevocationHold" + + +@pytest.mark.unit +def test_authority_revocation_hold_choices_closed_set() -> None: + assert frozenset({"Held", "HoldDeferred"}) == AUTHORITY_REVOCATION_HOLD_CHOICES + + +@pytest.mark.unit +def test_authority_revocation_hold_choices_match_literal() -> None: + """The frozenset and the Literal stay in lockstep.""" + assert frozenset(get_args(AuthorityRevocationHoldChoice)) == AUTHORITY_REVOCATION_HOLD_CHOICES + + +@pytest.mark.unit +def test_audit_fallback_choice_is_work_noun_qualified() -> None: + """A bare `Deferred` would collide in the shared DecisionChoice namespace; the + audit-fallback value must carry the Hold work-noun (parallel to + PromotionDeferred / SupervisionDeferred).""" + assert "Deferred" not in AUTHORITY_REVOCATION_HOLD_CHOICES + assert "HoldDeferred" in AUTHORITY_REVOCATION_HOLD_CHOICES diff --git a/apps/api/tests/unit/run/test_run_actor_involvement_projection.py b/apps/api/tests/unit/run/test_run_actor_involvement_projection.py new file mode 100644 index 00000000000..99a1b2a49f6 --- /dev/null +++ b/apps/api/tests/unit/run/test_run_actor_involvement_projection.py @@ -0,0 +1,142 @@ +"""Unit tests for RunActorInvolvementProjection. + +Pins per-event apply() dispatch: RunStarted INSERTs a starter row keyed by the +event's ENVELOPE principal_id + ENVELOPE occurred_at (not payload fields), +lifecycle events UPDATE status by run_id (RunResumed folds back to Running via +the same status map), and a RunStarted with no envelope principal is skipped. +The exact SQL constant per branch is asserted so a swap of INSERT/UPDATE would +fail. Postgres-side behavior (the actual rows + the in-flight filter) is in the +integration/lookup tests. +""" + +from datetime import UTC, datetime +from typing import Any +from unittest.mock import AsyncMock +from uuid import uuid4 + +import pytest + +from cora.infrastructure.ports.event_store import StoredEvent +from cora.run.projections import RunActorInvolvementProjection + +_RUN_ID = uuid4() +_STARTER_ID = uuid4() +_EVENT_ID = uuid4() +_CORRELATION_ID = uuid4() +_NOW = datetime(2026, 7, 5, 14, 0, 0, tzinfo=UTC) +# Deliberately different from _NOW so a test can prove the INSERT uses the +# envelope occurred_at, never a payload["occurred_at"]. +_PAYLOAD_TIME = datetime(2000, 1, 1, 0, 0, 0, tzinfo=UTC) + + +def _stored( + event_type: str, + payload: dict[str, Any], + *, + principal_id: Any = _STARTER_ID, +) -> StoredEvent: + return StoredEvent( + position=1, + event_id=_EVENT_ID, + stream_type="Run", + stream_id=_RUN_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=_CORRELATION_ID, + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=principal_id, + ) + + +@pytest.mark.unit +def test_projection_metadata() -> None: + proj = RunActorInvolvementProjection() + assert proj.name == "proj_run_actor_involvement" + assert proj.subscribed_event_types == frozenset( + { + "RunStarted", + "RunHeld", + "RunResumed", + "RunCompleted", + "RunAborted", + "RunStopped", + "RunTruncated", + } + ) + + +@pytest.mark.unit +async def test_run_started_inserts_starter_from_envelope_principal_and_time() -> None: + conn = AsyncMock() + proj = RunActorInvolvementProjection() + await proj.apply( + _stored( + "RunStarted", + {"run_id": str(_RUN_ID), "occurred_at": _PAYLOAD_TIME.isoformat()}, + ), + conn, + ) + conn.execute.assert_awaited_once() + args = conn.execute.await_args.args + # The INSERT statement, then (principal_id, run_id, created_at). The starter + # and the created_at both come from the ENVELOPE (event.principal_id + + # event.occurred_at), never from payload: created_at is _NOW, not the + # deliberately-stale payload occurred_at. + assert "INSERT INTO proj_run_actor_involvement" in args[0] + assert "ON CONFLICT (principal_id, run_id) DO NOTHING" in args[0] + assert args[1] == _STARTER_ID + assert args[2] == _RUN_ID + assert args[3] == _NOW + + +@pytest.mark.unit +async def test_run_started_without_envelope_principal_is_skipped() -> None: + conn = AsyncMock() + proj = RunActorInvolvementProjection() + await proj.apply( + _stored( + "RunStarted", + {"run_id": str(_RUN_ID), "occurred_at": _NOW.isoformat()}, + principal_id=None, + ), + conn, + ) + conn.execute.assert_not_awaited() + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("event_type", "expected_status"), + [ + ("RunHeld", "Held"), + ("RunResumed", "Running"), + ("RunCompleted", "Completed"), + ("RunAborted", "Aborted"), + ("RunStopped", "Stopped"), + ("RunTruncated", "Truncated"), + ], +) +async def test_lifecycle_updates_status_by_run_id(event_type: str, expected_status: str) -> None: + conn = AsyncMock() + proj = RunActorInvolvementProjection() + await proj.apply(_stored(event_type, {"run_id": str(_RUN_ID)}), conn) + conn.execute.assert_awaited_once() + args = conn.execute.await_args.args + # The UPDATE statement (keyed by run_id), then (run_id, status). RunResumed + # folds back to Running via the same status map, no separate SQL. + assert "UPDATE proj_run_actor_involvement" in args[0] + assert "WHERE run_id = $1" in args[0] + assert args[1] == _RUN_ID + assert args[2] == expected_status + + +@pytest.mark.unit +async def test_unsubscribed_event_is_a_noop() -> None: + conn = AsyncMock() + proj = RunActorInvolvementProjection() + await proj.apply(_stored("RunAdjusted", {"run_id": str(_RUN_ID)}), conn) + conn.execute.assert_not_awaited() diff --git a/apps/api/tests/unit/trust/policy/__init__.py b/apps/api/tests/unit/trust/policy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/tests/unit/trust/policy/test_revoke_grant_decider.py b/apps/api/tests/unit/trust/policy/test_revoke_grant_decider.py new file mode 100644 index 00000000000..22da03a2a1f --- /dev/null +++ b/apps/api/tests/unit/trust/policy/test_revoke_grant_decider.py @@ -0,0 +1,118 @@ +"""Decider tests for `revoke_grant` (drop one principal from a Policy's allow-list).""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.trust.aggregates.policy import ( + InvalidPolicyGrantRevokeReasonError, + Policy, + PolicyGrantRevoked, + PolicyName, + PolicyNotFoundError, +) +from cora.trust.features.revoke_grant import RevokePolicyGrant +from cora.trust.features.revoke_grant.decider import decide + +_NOW = datetime(2026, 5, 27, 12, 0, 0, tzinfo=UTC) +_POLICY_ID = UUID("01900000-0000-7000-8000-000000000f01") +_PRINCIPAL_IN = UUID("01900000-0000-7000-8000-000000000a01") +_PRINCIPAL_ABSENT = UUID("01900000-0000-7000-8000-000000000a02") +_INVOKER = UUID("01900000-0000-7000-8000-000000000099") +_CONDUIT = UUID("01900000-0000-7000-8000-000000000c01") + + +def _policy(*, principals: frozenset[UUID] = frozenset({_PRINCIPAL_IN})) -> Policy: + return Policy( + id=_POLICY_ID, + name=PolicyName("Beam-team"), + conduit_id=_CONDUIT, + permitted_principal_ids=principals, + permitted_commands=frozenset({"RegisterActor"}), + ) + + +@pytest.mark.unit +def test_revoke_present_principal_emits_grant_revoked() -> None: + events = decide( + state=_policy(), + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="access review" + ), + revoked_by=_INVOKER, + now=_NOW, + ) + [e] = events + assert isinstance(e, PolicyGrantRevoked) + assert e.policy_id == _POLICY_ID + assert e.principal_id == _PRINCIPAL_IN + assert e.revoked_by == _INVOKER + assert e.reason == "access review" + assert e.occurred_at == _NOW + + +@pytest.mark.unit +def test_revoke_trims_reason() -> None: + events = decide( + state=_policy(), + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason=" trimmed " + ), + revoked_by=_INVOKER, + now=_NOW, + ) + assert events[0].reason == "trimmed" + + +@pytest.mark.unit +def test_revoke_raises_not_found_on_empty_state() -> None: + with pytest.raises(PolicyNotFoundError): + decide( + state=None, + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="r" + ), + revoked_by=_INVOKER, + now=_NOW, + ) + + +@pytest.mark.unit +def test_revoke_absent_principal_is_silently_idempotent() -> None: + events = decide( + state=_policy(), + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_ABSENT, reason="access review" + ), + revoked_by=_INVOKER, + now=_NOW, + ) + assert events == [] + + +@pytest.mark.parametrize("bad_reason", ["", " ", "\n\t"]) +@pytest.mark.unit +def test_revoke_rejects_whitespace_reason(bad_reason: str) -> None: + with pytest.raises(InvalidPolicyGrantRevokeReasonError): + decide( + state=_policy(), + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason=bad_reason + ), + revoked_by=_INVOKER, + now=_NOW, + ) + + +@pytest.mark.unit +def test_revoke_rejects_too_long_reason() -> None: + with pytest.raises(InvalidPolicyGrantRevokeReasonError): + decide( + state=_policy(), + command=RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="a" * 501 + ), + revoked_by=_INVOKER, + now=_NOW, + ) diff --git a/apps/api/tests/unit/trust/test_policy_events.py b/apps/api/tests/unit/trust/test_policy_events.py index 44f2065346f..32cd3d61e4c 100644 --- a/apps/api/tests/unit/trust/test_policy_events.py +++ b/apps/api/tests/unit/trust/test_policy_events.py @@ -8,6 +8,7 @@ from cora.infrastructure.ports.event_store import StoredEvent from cora.trust.aggregates.policy.events import ( PolicyDefined, + PolicyGrantRevoked, event_type_name, from_stored, to_payload, @@ -151,6 +152,52 @@ def test_to_payload_then_from_stored_round_trips() -> None: assert rebuilt.occurred_at == original.occurred_at +@pytest.mark.unit +def test_event_type_name_returns_grant_revoked_class_name() -> None: + event = PolicyGrantRevoked( + policy_id=uuid4(), + principal_id=uuid4(), + revoked_by=uuid4(), + reason="access review", + occurred_at=_NOW, + ) + assert event_type_name(event) == "PolicyGrantRevoked" + + +@pytest.mark.unit +def test_to_payload_serializes_grant_revoked_to_primitives() -> None: + policy_id = uuid4() + principal_id = uuid4() + revoked_by = uuid4() + event = PolicyGrantRevoked( + policy_id=policy_id, + principal_id=principal_id, + revoked_by=revoked_by, + reason="access review", + occurred_at=_NOW, + ) + assert to_payload(event) == { + "policy_id": str(policy_id), + "principal_id": str(principal_id), + "revoked_by": str(revoked_by), + "reason": "access review", + "occurred_at": _NOW.isoformat(), + } + + +@pytest.mark.unit +def test_grant_revoked_to_payload_then_from_stored_round_trips() -> None: + original = PolicyGrantRevoked( + policy_id=uuid4(), + principal_id=uuid4(), + revoked_by=uuid4(), + reason="access review", + occurred_at=_NOW, + ) + stored = _stored("PolicyGrantRevoked", to_payload(original)) + assert from_stored(stored) == original + + @pytest.mark.unit def test_from_stored_raises_on_unknown_event_type() -> None: """Foreign event_types in a stream must fail loud.""" @@ -169,6 +216,7 @@ def test_from_stored_raises_on_unknown_event_type() -> None: "event_type", [ "PolicyDefined", + "PolicyGrantRevoked", ], ) def test_from_stored_raises_on_malformed_payload(event_type: str) -> None: diff --git a/apps/api/tests/unit/trust/test_policy_evolver.py b/apps/api/tests/unit/trust/test_policy_evolver.py index 7333e2b549b..6c947ac88f5 100644 --- a/apps/api/tests/unit/trust/test_policy_evolver.py +++ b/apps/api/tests/unit/trust/test_policy_evolver.py @@ -7,7 +7,7 @@ from cora.infrastructure.routing import SYSTEM_HTTP_SURFACE_ID from cora.trust.aggregates.policy import Policy, PolicyName, evolve, fold -from cora.trust.aggregates.policy.events import PolicyDefined +from cora.trust.aggregates.policy.events import PolicyDefined, PolicyGrantRevoked from cora.trust.features import define_policy from cora.trust.features.define_policy import DefinePolicy @@ -60,6 +60,57 @@ def test_evolve_converts_lists_to_frozensets() -> None: assert state.permitted_commands == frozenset({"A", "B"}) +@pytest.mark.unit +def test_evolve_grant_revoked_drops_principal_from_permitted_set() -> None: + policy_id = uuid4() + conduit = uuid4() + p1, p2 = uuid4(), uuid4() + defined = PolicyDefined( + policy_id=policy_id, + name="Beam-team", + conduit_id=conduit, + permitted_principal_ids=(p1, p2), + permitted_commands=("RegisterActor",), + occurred_at=_NOW, + ) + state = evolve( + evolve(None, defined), + PolicyGrantRevoked( + policy_id=policy_id, + principal_id=p1, + revoked_by=uuid4(), + reason="access review", + occurred_at=_NOW, + ), + ) + assert state.permitted_principal_ids == frozenset({p2}) + + +@pytest.mark.unit +def test_evolve_grant_revoked_of_absent_principal_leaves_set_unchanged() -> None: + policy_id = uuid4() + p1 = uuid4() + defined = PolicyDefined( + policy_id=policy_id, + name="Beam-team", + conduit_id=uuid4(), + permitted_principal_ids=(p1,), + permitted_commands=("RegisterActor",), + occurred_at=_NOW, + ) + state = evolve( + evolve(None, defined), + PolicyGrantRevoked( + policy_id=policy_id, + principal_id=uuid4(), + revoked_by=uuid4(), + reason="access review", + occurred_at=_NOW, + ), + ) + assert state.permitted_principal_ids == frozenset({p1}) + + @pytest.mark.unit def test_fold_empty_event_list_returns_none() -> None: assert fold([]) is None diff --git a/apps/api/tests/unit/trust/test_revoke_grant_decider_properties.py b/apps/api/tests/unit/trust/test_revoke_grant_decider_properties.py new file mode 100644 index 00000000000..120363d8b2f --- /dev/null +++ b/apps/api/tests/unit/trust/test_revoke_grant_decider_properties.py @@ -0,0 +1,201 @@ +"""Property-based tests for `revoke_grant.decide` (Trust BC, Policy). + +Complements the example-based `policy/test_revoke_grant_decider.py` with +universal claims across generated inputs. The decider is a pure set-membership +removal + + (state, command, revoked_by, now) -> list[PolicyGrantRevoked] + +Load-bearing properties: + + - state=None always raises `PolicyNotFoundError` carrying command.policy_id. + - A principal not in the permitted set always returns [] (silently + idempotent; no event, no error). + - A principal in the permitted set + a valid reason emits exactly one + PolicyGrantRevoked (policy_id=state.id, principal_id + revoked_by threaded, + reason trimmed, occurred_at=now). + - Blank / overlong reason always raises `InvalidPolicyGrantRevokeReasonError`. + - Pure: same (state, command, revoked_by, now) returns equal events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.aggregates.policy import ( + InvalidPolicyGrantRevokeReasonError, + Policy, + PolicyGrantRevoked, + PolicyName, + PolicyNotFoundError, +) +from cora.trust.features.revoke_grant import RevokePolicyGrant +from cora.trust.features.revoke_grant.decider import decide +from tests._strategies import aware_datetimes, printable_ascii_text + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +_REASON = printable_ascii_text(min_size=1, max_size=REASON_MAX_LENGTH) + + +def _policy(*, policy_id: UUID, principals: frozenset[UUID]) -> Policy: + return Policy( + id=policy_id, + name=PolicyName("Beam-team"), + conduit_id=policy_id, + permitted_principal_ids=principals, + permitted_commands=frozenset({"RegisterActor"}), + ) + + +@pytest.mark.unit +@given( + policy_id=st.uuids(), + principal_id=st.uuids(), + revoked_by=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_revoke_with_none_state_always_raises_not_found( + policy_id: UUID, + principal_id: UUID, + revoked_by: UUID, + reason: str, + now: datetime, +) -> None: + """Empty stream always raises `PolicyNotFoundError` carrying command.policy_id.""" + with pytest.raises(PolicyNotFoundError) as exc: + decide( + state=None, + command=RevokePolicyGrant( + policy_id=policy_id, permitted_principal_id=principal_id, reason=reason + ), + revoked_by=revoked_by, + now=now, + ) + assert exc.value.policy_id == policy_id + + +@pytest.mark.unit +@given( + policy_id=st.uuids(), + principal_id=st.uuids(), + revoked_by=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_revoke_absent_principal_always_returns_empty( + policy_id: UUID, + principal_id: UUID, + revoked_by: UUID, + reason: str, + now: datetime, +) -> None: + """A principal not in the permitted set is a silent no-op ([]).""" + events = decide( + state=_policy(policy_id=policy_id, principals=frozenset()), + command=RevokePolicyGrant( + policy_id=policy_id, permitted_principal_id=principal_id, reason=reason + ), + revoked_by=revoked_by, + now=now, + ) + assert events == [] + + +@pytest.mark.unit +@given( + policy_id=st.uuids(), + principal_id=st.uuids(), + revoked_by=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_revoke_present_principal_emits_single_event( + policy_id: UUID, + principal_id: UUID, + revoked_by: UUID, + reason: str, + now: datetime, +) -> None: + """A permitted principal + valid reason emits one PolicyGrantRevoked, reason trimmed.""" + events = decide( + state=_policy(policy_id=policy_id, principals=frozenset({principal_id})), + command=RevokePolicyGrant( + policy_id=policy_id, permitted_principal_id=principal_id, reason=reason + ), + revoked_by=revoked_by, + now=now, + ) + assert events == [ + PolicyGrantRevoked( + policy_id=policy_id, + principal_id=principal_id, + revoked_by=revoked_by, + reason=reason.strip(), + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + policy_id=st.uuids(), + principal_id=st.uuids(), + revoked_by=st.uuids(), + reason=st.one_of( + st.text(alphabet=" \t\n", min_size=0, max_size=5), + printable_ascii_text(min_size=REASON_MAX_LENGTH + 1, max_size=REASON_MAX_LENGTH + 20), + ), + now=aware_datetimes(), +) +def test_revoke_blank_or_overlong_reason_always_raises( + policy_id: UUID, + principal_id: UUID, + revoked_by: UUID, + reason: str, + now: datetime, +) -> None: + """Blank or overlong reason (after trim) raises before any emit.""" + assume(not reason.strip() or len(reason.strip()) > REASON_MAX_LENGTH) + with pytest.raises(InvalidPolicyGrantRevokeReasonError): + decide( + state=_policy(policy_id=policy_id, principals=frozenset({principal_id})), + command=RevokePolicyGrant( + policy_id=policy_id, permitted_principal_id=principal_id, reason=reason + ), + revoked_by=revoked_by, + now=now, + ) + + +@pytest.mark.unit +@given( + policy_id=st.uuids(), + principal_id=st.uuids(), + revoked_by=st.uuids(), + reason=_REASON, + now=aware_datetimes(), +) +def test_revoke_is_pure_same_input_same_output( + policy_id: UUID, + principal_id: UUID, + revoked_by: UUID, + reason: str, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + state = _policy(policy_id=policy_id, principals=frozenset({principal_id})) + command = RevokePolicyGrant( + policy_id=policy_id, permitted_principal_id=principal_id, reason=reason + ) + first = decide(state=state, command=command, revoked_by=revoked_by, now=now) + second = decide(state=state, command=command, revoked_by=revoked_by, now=now) + assert first == second diff --git a/apps/api/tests/unit/trust/test_revoke_grant_handler.py b/apps/api/tests/unit/trust/test_revoke_grant_handler.py new file mode 100644 index 00000000000..36b7a4a0f4f --- /dev/null +++ b/apps/api/tests/unit/trust/test_revoke_grant_handler.py @@ -0,0 +1,146 @@ +"""Application-handler unit tests for the `revoke_grant` slice. + +Pins the handler-level concerns: authz invocation, event-store append at the +loaded version, the silently-idempotent noop path (revoking an absent principal +appends nothing), and PolicyNotFoundError on a missing Policy stream. Pure- +decider behavior is exercised in `policy/test_revoke_grant_decider.py` + +`test_revoke_grant_decider_properties.py`. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.infrastructure.event_envelope import to_new_event +from cora.trust import UnauthorizedError +from cora.trust.aggregates.policy import ( + PolicyDefined, + PolicyNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.features import revoke_grant +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 5, 27, 12, 0, 0, tzinfo=UTC) +_POLICY_ID = UUID("01900000-0000-7000-8000-00000000f001") +_CONDUIT_ID = UUID("01900000-0000-7000-8000-00000000f002") +_SURFACE_ID = UUID("01900000-0000-7000-8000-00000000f003") +_PRINCIPAL_IN = UUID("01900000-0000-7000-8000-00000000f0a1") +_PRINCIPAL_ABSENT = UUID("01900000-0000-7000-8000-00000000f0a2") +_INVOKER = UUID("01900000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000000000aa") +_EVENT_ID = UUID("01900000-0000-7000-8000-00000000f101") + + +async def _seed_policy(store: InMemoryEventStore) -> None: + """Append a PolicyDefined at version 0 so the stream folds to a real Policy.""" + event = PolicyDefined( + policy_id=_POLICY_ID, + name="Beam-team", + conduit_id=_CONDUIT_ID, + permitted_principal_ids=(_PRINCIPAL_IN,), + permitted_commands=("RegisterActor",), + occurred_at=_NOW, + surface_id=_SURFACE_ID, + ) + await store.append( + stream_type="Policy", + stream_id=_POLICY_ID, + expected_version=0, + events=[ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=UUID(int=1), + command_name="DefinePolicy", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_INVOKER, + ) + ], + ) + + +@pytest.mark.unit +async def test_revoke_grant_handler_appends_grant_revoked() -> None: + store = InMemoryEventStore() + await _seed_policy(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = revoke_grant.bind(deps) + + await handler( + revoke_grant.RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="access review" + ), + principal_id=_INVOKER, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Policy", _POLICY_ID) + assert version == 2 + assert events[-1].event_type == "PolicyGrantRevoked" + assert events[-1].payload["principal_id"] == str(_PRINCIPAL_IN) + assert events[-1].payload["revoked_by"] == str(_INVOKER) + assert events[-1].payload["reason"] == "access review" + folded = fold([from_stored(s) for s in events]) + assert folded is not None + assert _PRINCIPAL_IN not in folded.permitted_principal_ids + + +@pytest.mark.unit +async def test_revoke_grant_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + await _seed_policy(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store, deny=True) + handler = revoke_grant.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + revoke_grant.RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="access review" + ), + principal_id=_INVOKER, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_revoke_grant_handler_noop_appends_nothing_for_absent_principal() -> None: + store = InMemoryEventStore() + await _seed_policy(store) + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = revoke_grant.bind(deps) + + await handler( + revoke_grant.RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_ABSENT, reason="access review" + ), + principal_id=_INVOKER, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Policy", _POLICY_ID) + assert version == 1 + assert not any(e.event_type == "PolicyGrantRevoked" for e in events) + + +@pytest.mark.unit +async def test_revoke_grant_handler_raises_not_found_when_policy_absent() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_EVENT_ID], now=_NOW, event_store=store) + handler = revoke_grant.bind(deps) + + with pytest.raises(PolicyNotFoundError): + await handler( + revoke_grant.RevokePolicyGrant( + policy_id=_POLICY_ID, permitted_principal_id=_PRINCIPAL_IN, reason="access review" + ), + principal_id=_INVOKER, + correlation_id=_CORRELATION_ID, + ) diff --git a/docs/reference/conventions.md b/docs/reference/conventions.md index 561b93bf8ec..9925c7de294 100644 --- a/docs/reference/conventions.md +++ b/docs/reference/conventions.md @@ -174,7 +174,7 @@ An architecture fitness test in `apps/api/tests/architecture/test_rest_url_kebab Slice directory names, command class names, and MCP tool names carry the SUBJECT in the verb-phrase when the slice mutates a specific aggregate kind: `add_asset_family`, `decommission_asset`, `enter_asset_maintenance`, `update_asset_settings`. Reading aloud, "add asset family" and "enter asset maintenance" are parallel English noun-phrases. -When the slice acts on a per-aggregate SUB-CONCEPT rather than the aggregate itself, the sub-concept noun is the subject: `append_observations` (Run), `append_activities` (Operation), `start_iteration` / `end_iteration` (Operation, the convergence-loop boundary on a Procedure). The command class still carries the aggregate qualifier (`AppendProcedureActivities`, `StartProcedureIteration`) while the slice directory and MCP tool drop it (the BC namespace disambiguates). These sub-concept nouns are tracked in the `_DOMAIN_NOUN_ALLOWLIST` of `tests/architecture/test_slice_verb_names_subject.py`. +When the slice acts on a per-aggregate SUB-CONCEPT rather than the aggregate itself, the sub-concept noun is the subject: `append_observations` (Run), `append_activities` (Operation), `start_iteration` / `end_iteration` (Operation, the convergence-loop boundary on a Procedure), `revoke_grant` (Trust, a grant is one entry in `Policy.permitted_principal_ids`). The command class still carries the aggregate qualifier (`AppendProcedureActivities`, `StartProcedureIteration`, `RevokePolicyGrant`) while the slice directory and MCP tool drop it (the BC namespace disambiguates). These sub-concept nouns are tracked in the `_DOMAIN_NOUN_ALLOWLIST` of `tests/architecture/test_slice_verb_names_subject.py`. Orchestration slices that drive an aggregate's full lifecycle through a runtime (the Conductor) carry the subject when it reads cleanly (`conduct_procedure`, `conduct_or_hold_procedure`, `conduct_from_procedure` all name `procedure`). The one exception is `conduct_until_converged` (Operation): its name states the loop's TERMINAL CONDITION (conduct the Procedure repeatedly until it converges) rather than re-stating the Procedure subject, because the convergence predicate is what distinguishes it from `conduct_procedure`. The command class is `ConductUntilConverged` and the subject is still the Procedure (driven via start / start_iteration / end_iteration / complete / abort). This single slice is exempted by its exact fully-qualified name in `_SUBJECT_EXEMPT_SLICES` of `tests/architecture/test_slice_verb_names_subject.py` (not by allowlisting the `converged` token); exactly one slice is exempt, by name, so a slice like `mark_converged` in any BC would still fail the subject guard. diff --git a/infra/atlas/migrations/20260705000000_init_proj_run_actor_involvement.sql b/infra/atlas/migrations/20260705000000_init_proj_run_actor_involvement.sql new file mode 100644 index 00000000000..2b2e9e79cdb --- /dev/null +++ b/infra/atlas/migrations/20260705000000_init_proj_run_actor_involvement.sql @@ -0,0 +1,58 @@ +-- Kill-switch K2: the actor-involvement resolver read model. +-- +-- Answers "which in-flight Runs does principal P drive?" so the +-- authority-revocation holder subscriber (K3) can hold a revoked +-- principal's runs. Folded from Run lifecycle events; the STARTER is +-- the RunStarted event's ENVELOPE principal_id (the Authorize-gated +-- issuer), not a payload field. +-- +-- Involvement scope is STARTER-ONLY for v1, structured extensible: the +-- `involvement_role` column carries 'starter' today and reserves +-- 'supervisor' so a later additive fold (the HoldRun / ResumeRun +-- envelope principals) widens "drives" to "started or supervises" +-- without a schema migration. +-- +-- Subscribed events: +-- RunStarted -> INSERT (role='starter', status='Running') +-- RunHeld -> UPDATE status='Held' (still in-flight) +-- RunResumed -> UPDATE status='Running' +-- RunCompleted -> UPDATE status='Completed' (terminal, no longer driven) +-- RunAborted -> UPDATE status='Aborted' (terminal) +-- RunStopped -> UPDATE status='Stopped' (terminal) +-- RunTruncated -> UPDATE status='Truncated' (terminal) +-- +-- "In-flight" (what the lookup returns) = status IN ('Running', 'Held'). +-- Terminal rows are retained (not deleted) so the resolver stays a pure +-- fold and an audit of past involvement survives. +-- +-- Mutable read model. cora_app gets full DML. + +CREATE TABLE proj_run_actor_involvement ( + principal_id UUID NOT NULL, + run_id UUID NOT NULL, + involvement_role TEXT NOT NULL DEFAULT 'starter' CHECK ( + involvement_role IN ('starter', 'supervisor') + ), + status TEXT NOT NULL CHECK ( + status IN ('Running', 'Held', 'Completed', 'Aborted', 'Stopped', 'Truncated') + ), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (principal_id, run_id) +); + +-- The lookup's hot path: in-flight runs for one principal. +CREATE INDEX proj_run_actor_involvement_principal_idx + ON proj_run_actor_involvement (principal_id, status); + +-- Lifecycle UPDATEs arrive keyed by run_id (the event has the run, the +-- projection must find every involvement row for it). +CREATE INDEX proj_run_actor_involvement_run_idx + ON proj_run_actor_involvement (run_id); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_run_actor_involvement TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_run_actor_involvement') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index 00bd5342a81..f2de8f90647 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:gnBaTlD1tpI/XrinLo/ZR5X7vVTNwlgjH4YDLxOMe1E= +h1:nbGdEKznh7y8O9/XylcvRr0vHmIA3pEH6hRPvc5CHnk= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -154,3 +154,4 @@ h1:gnBaTlD1tpI/XrinLo/ZR5X7vVTNwlgjH4YDLxOMe1E= 20260701000000_init_entries_operation_procedure_diagnostics.sql h1:s98T1rNETGVFqHKhjL6/Y0kU2w49FtDejJKAjNL0GDY= 20260701010000_add_proj_operation_procedure_iterations_steering_trail.sql h1:EQNn4o02AmHjjAGNC0dxmOdoBBhjrIi2q2nONRZfLeI= 20260701020000_init_entries_operation_procedure_outcomes.sql h1:am+Up8MDHuO/yT/pk3rYKAObOVtkvK+Oe1giXczpSu8= +20260705000000_init_proj_run_actor_involvement.sql h1:UmVHB7VBa+uC9xPlxwTPs+BHBK03F5nZITs3SLDJAKQ=