From 12f3cea89c8ee0b202547271a6894185506a1fab Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 5 Jul 2026 16:56:02 +0300 Subject: [PATCH 1/2] Add the Ratification aggregate: the consequence gate (dormant) Introduce the Ratification aggregate in the Trust BC: the "must a second principal co-sign?" member of the governance-gate family, the deontic co-action counterpart to the permission gate. It records a second-principal co-signature (four-eyes) for a consequential action, and is the second of two new gates for the "Four Gates, One Log" line. 3-state FSM Requested -> Granted | Denied (Requested genesis, both terminal, no re-open). Three slices: request_ratification (genesis), grant_ratification and deny_ratification (transitions). The load-bearing invariant is independence: the grantor/denier must differ from the requester (RatificationRequesterCannotSelf- RatifyError), enforced in both deciders. Kind-blind by construction: principals are bare UUIDs and no decider reads actor kind, so a human is held for co-sign identically to an agent. The co-signing principal (and the requester on genesis) is the envelope principal_id threaded into the decider by the handler, never a spoofable command field; grant/deny use longhand handlers (not the update-handler factory, which does not pass principal_id) with optimistic concurrency at the loaded version, mirroring supersede_caution. Why dormant: nothing gates on Ratification yet. The consequence-class declaration on Capability, the ConsequenceLookup port, and the decider gate that triggers a request plus the shared-hold discharge are the wiring increment; this lands only the aggregate and its lifecycle, mirroring CORA's mechanism-first pattern, so no existing behavior changes. Full slice-coverage compliance (no deferral allowlist entries): example + property-based decider tests, handler unit tests, REST endpoint contract test, and MCP tool contract tests for all three slices. Genesis event RatificationRequested is an allowlisted Defined/Registered deviation (it names its own Requested lifecycle state, the RunStarted shape); R3 gate-review confirmed. Aggregate count 40 -> 41. Gate review: R3 naming (genesis deviation confirmed; renamed SelfSign -> SelfRatify since the check fires on deny too; target_ref -> target_action_id) + correctness (independence, version handling, serialization all verified clean). Added the to_payload assert_never exhaustiveness guard for house consistency. Co-Authored-By: Claude --- apps/api/openapi.json | 341 ++++++++++++++++++ .../trust/aggregates/ratification/__init__.py | 59 +++ .../trust/aggregates/ratification/events.py | 146 ++++++++ .../trust/aggregates/ratification/evolver.py | 63 ++++ .../trust/aggregates/ratification/read.py | 24 ++ .../trust/aggregates/ratification/state.py | 200 ++++++++++ .../features/deny_ratification/__init__.py | 9 + .../features/deny_ratification/command.py | 21 ++ .../features/deny_ratification/decider.py | 61 ++++ .../features/deny_ratification/handler.py | 131 +++++++ .../trust/features/deny_ratification/route.py | 86 +++++ .../trust/features/deny_ratification/tool.py | 54 +++ .../features/grant_ratification/__init__.py | 9 + .../features/grant_ratification/command.py | 20 + .../features/grant_ratification/decider.py | 56 +++ .../features/grant_ratification/handler.py | 131 +++++++ .../features/grant_ratification/route.py | 61 ++++ .../trust/features/grant_ratification/tool.py | 45 +++ .../features/request_ratification/__init__.py | 9 + .../features/request_ratification/command.py | 39 ++ .../features/request_ratification/decider.py | 55 +++ .../features/request_ratification/handler.py | 126 +++++++ .../features/request_ratification/route.py | 107 ++++++ .../features/request_ratification/tool.py | 68 ++++ apps/api/src/cora/trust/routes.py | 23 ++ apps/api/src/cora/trust/tools.py | 7 + apps/api/src/cora/trust/wire.py | 21 ++ .../test_event_class_defined_vs_registered.py | 9 + .../test_slice_verb_names_subject.py | 1 + .../test_deny_ratification_mcp_tool.py | 129 +++++++ .../test_grant_ratification_mcp_tool.py | 123 +++++++ .../contract/test_ratification_endpoints.py | 232 ++++++++++++ .../test_request_ratification_mcp_tool.py | 110 ++++++ .../test_architecture_introspect.py | 7 +- .../tests/unit/trust/ratification/__init__.py | 0 .../unit/trust/ratification/_fixtures.py | 46 +++ .../test_deny_ratification_decider.py | 82 +++++ .../test_grant_ratification_decider.py | 68 ++++ .../ratification/test_ratification_evolver.py | 81 +++++ .../test_request_ratification_decider.py | 95 +++++ ...st_deny_ratification_decider_properties.py | 232 ++++++++++++ .../trust/test_deny_ratification_handler.py | 139 +++++++ ...t_grant_ratification_decider_properties.py | 169 +++++++++ .../trust/test_grant_ratification_handler.py | 136 +++++++ ...request_ratification_decider_properties.py | 263 ++++++++++++++ .../test_request_ratification_handler.py | 92 +++++ docs/architecture/model.md | 2 +- 47 files changed, 3984 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/cora/trust/aggregates/ratification/__init__.py create mode 100644 apps/api/src/cora/trust/aggregates/ratification/events.py create mode 100644 apps/api/src/cora/trust/aggregates/ratification/evolver.py create mode 100644 apps/api/src/cora/trust/aggregates/ratification/read.py create mode 100644 apps/api/src/cora/trust/aggregates/ratification/state.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/__init__.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/command.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/decider.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/handler.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/route.py create mode 100644 apps/api/src/cora/trust/features/deny_ratification/tool.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/__init__.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/command.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/decider.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/handler.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/route.py create mode 100644 apps/api/src/cora/trust/features/grant_ratification/tool.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/__init__.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/command.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/decider.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/handler.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/route.py create mode 100644 apps/api/src/cora/trust/features/request_ratification/tool.py create mode 100644 apps/api/tests/contract/test_deny_ratification_mcp_tool.py create mode 100644 apps/api/tests/contract/test_grant_ratification_mcp_tool.py create mode 100644 apps/api/tests/contract/test_ratification_endpoints.py create mode 100644 apps/api/tests/contract/test_request_ratification_mcp_tool.py create mode 100644 apps/api/tests/unit/trust/ratification/__init__.py create mode 100644 apps/api/tests/unit/trust/ratification/_fixtures.py create mode 100644 apps/api/tests/unit/trust/ratification/test_deny_ratification_decider.py create mode 100644 apps/api/tests/unit/trust/ratification/test_grant_ratification_decider.py create mode 100644 apps/api/tests/unit/trust/ratification/test_ratification_evolver.py create mode 100644 apps/api/tests/unit/trust/ratification/test_request_ratification_decider.py create mode 100644 apps/api/tests/unit/trust/test_deny_ratification_decider_properties.py create mode 100644 apps/api/tests/unit/trust/test_deny_ratification_handler.py create mode 100644 apps/api/tests/unit/trust/test_grant_ratification_decider_properties.py create mode 100644 apps/api/tests/unit/trust/test_grant_ratification_handler.py create mode 100644 apps/api/tests/unit/trust/test_request_ratification_decider_properties.py create mode 100644 apps/api/tests/unit/trust/test_request_ratification_handler.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 82c75ea67f9..7610e330eaa 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -6515,6 +6515,23 @@ "title": "DemoteDatasetRequest", "type": "object" }, + "DenyRatificationRequest": { + "description": "Body for `POST /ratifications/{ratification_id}/deny`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb) explaining\nthe refusal. MUST NOT contain PII.", + "properties": { + "reason": { + "description": "Operator-supplied reason for the denial (audit-log breadcrumb; no PII).", + "maxLength": 500, + "minLength": 1, + "title": "Reason", + "type": "string" + } + }, + "required": [ + "reason" + ], + "title": "DenyRatificationRequest", + "type": "object" + }, "DeprecateAgentRequest": { "description": "Body for `POST /agents/{agent_id}/deprecate`.", "properties": { @@ -13206,6 +13223,58 @@ "title": "RemoveRunFromCampaignRequest", "type": "object" }, + "RequestRatificationRequest": { + "description": "Body for `POST /ratifications`.", + "properties": { + "command_name": { + "description": "Canonical name of the gated command.", + "title": "Command Name", + "type": "string" + }, + "consequence_class": { + "description": "Declared class that triggered the requirement (bare-str label).", + "maxLength": 100, + "minLength": 1, + "title": "Consequence Class", + "type": "string" + }, + "ratification_id": { + "description": "Caller-supplied UUID. A subscriber may mint a deterministic uuid5 for replay-safe ingest; operator-direct may use uuid4.", + "format": "uuid", + "title": "Ratification Id", + "type": "string" + }, + "target_action_id": { + "description": "Opaque id of the action being gated (e.g. the run id whose consequential command is held). Not existence-checked at the decider.", + "format": "uuid", + "title": "Target Action Id", + "type": "string" + } + }, + "required": [ + "ratification_id", + "target_action_id", + "command_name", + "consequence_class" + ], + "title": "RequestRatificationRequest", + "type": "object" + }, + "RequestRatificationResponse": { + "description": "Response body for `POST /ratifications`.", + "properties": { + "ratification_id": { + "format": "uuid", + "title": "Ratification Id", + "type": "string" + } + }, + "required": [ + "ratification_id" + ], + "title": "RequestRatificationResponse", + "type": "object" + }, "RestoreAssetRequest": { "description": "Body for `POST /assets/{asset_id}/restore`.\n\n`reason` is operator-supplied free text (audit-log breadcrumb)\nexplaining the repair. Examples: \"replaced flat cable\", \"cleaned\nsample stage and recalibrated\", \"vacuum pump rebuild complete\".", "properties": { @@ -39378,6 +39447,278 @@ ] } }, + "/ratifications": { + "post": { + "operationId": "post_ratifications_ratifications_post", + "parameters": [ + { + "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/RequestRatificationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestRatificationResponse" + } + } + }, + "description": "Successful Response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Domain invariant violated (e.g. empty consequence_class)." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the command." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Ratification with this id already exists." + }, + "422": { + "description": "Request body failed schema validation." + } + }, + "summary": "Request a second-principal co-signature for a consequential action", + "tags": [ + "trust" + ] + } + }, + "/ratifications/{ratification_id}/deny": { + "post": { + "operationId": "post_ratifications_deny_ratifications__ratification_id__deny_post", + "parameters": [ + { + "description": "Target Ratification's id.", + "in": "path", + "name": "ratification_id", + "required": true, + "schema": { + "description": "Target Ratification's id.", + "format": "uuid", + "title": "Ratification 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/DenyRatificationRequest" + } + } + }, + "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 Ratification exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Ratification is not in Requested status, or denier is the requester." + }, + "422": { + "description": "Request body failed schema validation." + } + }, + "summary": "Deny (refuse) a Requested Ratification (Requested -> Denied)", + "tags": [ + "trust" + ] + } + }, + "/ratifications/{ratification_id}/grant": { + "post": { + "operationId": "post_ratifications_grant_ratifications__ratification_id__grant_post", + "parameters": [ + { + "description": "Target Ratification's id.", + "in": "path", + "name": "ratification_id", + "required": true, + "schema": { + "description": "Target Ratification's id.", + "format": "uuid", + "title": "Ratification 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" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "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 Ratification exists with the given id." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Ratification is not in Requested status, or grantor is the requester." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Grant (co-sign) a Requested Ratification (Requested -> Granted)", + "tags": [ + "trust" + ] + } + }, "/recipes": { "post": { "operationId": "post_recipes_recipes_post", diff --git a/apps/api/src/cora/trust/aggregates/ratification/__init__.py b/apps/api/src/cora/trust/aggregates/ratification/__init__.py new file mode 100644 index 00000000000..3f64a5034cf --- /dev/null +++ b/apps/api/src/cora/trust/aggregates/ratification/__init__.py @@ -0,0 +1,59 @@ +"""Ratification aggregate: state, errors, events, evolver, read repo. + +The consequence gate's aggregate: the co-signature record that a consequential +action is awaiting, or has received, a second independent principal's approval. +Vertical slices live under `cora.trust.features._ratification/` and import +state + event types from here. + +3-state FSM: Requested -> Granted | Denied. `Requested` is genesis; both others +are terminal. The independence invariant (grantor/denier must differ from the +requester) is the four-eyes property the gate provides. Lands DORMANT: nothing +gates on it yet. +""" + +from cora.trust.aggregates.ratification.events import ( + RatificationDenied, + RatificationEvent, + RatificationGranted, + RatificationRequested, + event_type_name, + from_stored, + to_payload, +) +from cora.trust.aggregates.ratification.evolver import evolve, fold +from cora.trust.aggregates.ratification.read import load_ratification +from cora.trust.aggregates.ratification.state import ( + CONSEQUENCE_CLASS_MAX_LENGTH, + InvalidConsequenceClassError, + InvalidRatificationReasonError, + Ratification, + RatificationAlreadyExistsError, + RatificationCannotDenyError, + RatificationCannotGrantError, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, +) + +__all__ = [ + "CONSEQUENCE_CLASS_MAX_LENGTH", + "InvalidConsequenceClassError", + "InvalidRatificationReasonError", + "Ratification", + "RatificationAlreadyExistsError", + "RatificationCannotDenyError", + "RatificationCannotGrantError", + "RatificationDenied", + "RatificationEvent", + "RatificationGranted", + "RatificationNotFoundError", + "RatificationRequested", + "RatificationRequesterCannotSelfRatifyError", + "RatificationStatus", + "event_type_name", + "evolve", + "fold", + "from_stored", + "load_ratification", + "to_payload", +] diff --git a/apps/api/src/cora/trust/aggregates/ratification/events.py b/apps/api/src/cora/trust/aggregates/ratification/events.py new file mode 100644 index 00000000000..c0d7ba6b235 --- /dev/null +++ b/apps/api/src/cora/trust/aggregates/ratification/events.py @@ -0,0 +1,146 @@ +"""Domain events emitted by the Ratification aggregate, plus the union. + +Lifecycle events (1 genesis + 2 terminal transitions): + + - `RatificationRequested` -- genesis (Requested status). Carries the target + action reference, the gated command name, the + triggering consequence class, and the requester. + - `RatificationGranted` -- Requested -> Granted (a different, independent + principal co-signed). + - `RatificationDenied` -- Requested -> Denied (+ reason). + +The granting/denying principal is the envelope `principal_id` per CORA +convention, not a payload field, so the events carry no `granted_by` / +`denied_by`. The requester is on the genesis payload because independence is +checked against it at grant/deny time and must survive replay. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, assert_never +from uuid import UUID + +from cora.infrastructure.event_payload import deserialize_or_raise +from cora.infrastructure.ports.event_store import StoredEvent + + +@dataclass(frozen=True) +class RatificationRequested: + """A co-signature was requested for a consequential target action.""" + + ratification_id: UUID + target_action_id: UUID + command_name: str + consequence_class: str + requested_by: UUID + occurred_at: datetime + + +@dataclass(frozen=True) +class RatificationGranted: + """A different, independent principal co-signed the pending action.""" + + ratification_id: UUID + occurred_at: datetime + + +@dataclass(frozen=True) +class RatificationDenied: + """A different, independent principal refused the pending action (+ reason).""" + + ratification_id: UUID + reason: str + occurred_at: datetime + + +RatificationEvent = RatificationRequested | RatificationGranted | RatificationDenied + + +def event_type_name(event: RatificationEvent) -> str: + """Discriminator string written into StoredEvent.event_type.""" + return type(event).__name__ + + +def to_payload(event: RatificationEvent) -> dict[str, Any]: + """Serialize a Ratification event to a JSON-friendly dict for jsonb storage.""" + match event: + case RatificationRequested( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + requested_by=requested_by, + occurred_at=occurred_at, + ): + return { + "ratification_id": str(ratification_id), + "target_action_id": str(target_action_id), + "command_name": command_name, + "consequence_class": consequence_class, + "requested_by": str(requested_by), + "occurred_at": occurred_at.isoformat(), + } + case RatificationGranted(ratification_id=ratification_id, occurred_at=occurred_at): + return { + "ratification_id": str(ratification_id), + "occurred_at": occurred_at.isoformat(), + } + case RatificationDenied( + ratification_id=ratification_id, reason=reason, occurred_at=occurred_at + ): + return { + "ratification_id": str(ratification_id), + "reason": reason, + "occurred_at": occurred_at.isoformat(), + } + case _: # pragma: no cover - exhaustiveness guard + assert_never(event) + + +def from_stored(stored: StoredEvent) -> RatificationEvent: + """Rebuild a Ratification event from a StoredEvent loaded from the store.""" + payload = stored.payload + match stored.event_type: + case "RatificationRequested": + return deserialize_or_raise( + "RatificationRequested", + lambda: RatificationRequested( + ratification_id=UUID(payload["ratification_id"]), + target_action_id=UUID(payload["target_action_id"]), + command_name=payload["command_name"], + consequence_class=payload["consequence_class"], + requested_by=UUID(payload["requested_by"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "RatificationGranted": + return deserialize_or_raise( + "RatificationGranted", + lambda: RatificationGranted( + ratification_id=UUID(payload["ratification_id"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case "RatificationDenied": + return deserialize_or_raise( + "RatificationDenied", + lambda: RatificationDenied( + ratification_id=UUID(payload["ratification_id"]), + reason=payload["reason"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) + case _: + msg = f"Unknown RatificationEvent event_type: {stored.event_type!r}" + raise ValueError(msg) + + +__all__ = [ + "RatificationDenied", + "RatificationEvent", + "RatificationGranted", + "RatificationRequested", + "event_type_name", + "from_stored", + "to_payload", +] diff --git a/apps/api/src/cora/trust/aggregates/ratification/evolver.py b/apps/api/src/cora/trust/aggregates/ratification/evolver.py new file mode 100644 index 00000000000..214a587bfd1 --- /dev/null +++ b/apps/api/src/cora/trust/aggregates/ratification/evolver.py @@ -0,0 +1,63 @@ +"""Evolver: replay events to reconstruct Ratification state. + +Mirror of the Visit / Policy evolvers for Ratification's 3-state FSM. The +terminal `assert_never` case forces pyright (and the runtime) to error if a new +event type is added to `RatificationEvent` without a matching match arm. + +`RatificationRequested` is genesis and ignores prior state. `RatificationGranted` +/ `RatificationDenied` update the existing state's `status` (and `last_reason` on +deny), preserving everything else. +""" + +from collections.abc import Sequence +from dataclasses import replace +from typing import assert_never + +from cora.trust.aggregates.ratification.events import ( + RatificationDenied, + RatificationEvent, + RatificationGranted, + RatificationRequested, +) +from cora.trust.aggregates.ratification.state import Ratification, RatificationStatus + + +def evolve(state: Ratification | None, event: RatificationEvent) -> Ratification: + """Apply one event to the current state.""" + match event: + case RatificationRequested( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + requested_by=requested_by, + ): + _ = state # RatificationRequested is genesis; prior state ignored + return Ratification( + id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + requested_by=requested_by, + status=RatificationStatus.REQUESTED, + last_reason=None, + ) + case RatificationGranted(): + assert state is not None, "RatificationGranted requires prior state" + return replace(state, status=RatificationStatus.GRANTED) + case RatificationDenied(reason=reason): + assert state is not None, "RatificationDenied requires prior state" + return replace(state, status=RatificationStatus.DENIED, last_reason=reason) + case _: # pragma: no cover - exhaustiveness guard + assert_never(event) + + +def fold(events: Sequence[RatificationEvent]) -> Ratification | None: + """Replay a stream of events from the empty initial state.""" + state: Ratification | None = None + for event in events: + state = evolve(state, event) + return state + + +__all__ = ["evolve", "fold"] diff --git a/apps/api/src/cora/trust/aggregates/ratification/read.py b/apps/api/src/cora/trust/aggregates/ratification/read.py new file mode 100644 index 00000000000..f2c837b3cc2 --- /dev/null +++ b/apps/api/src/cora/trust/aggregates/ratification/read.py @@ -0,0 +1,24 @@ +"""Load + fold a Ratification stream into current state. + +The write-side load path used by the transition slices (grant / deny) to +reconstruct current state before deciding. Mirrors `load_visit`. +""" + +from uuid import UUID + +from cora.infrastructure.ports import EventStore +from cora.trust.aggregates.ratification.events import from_stored +from cora.trust.aggregates.ratification.evolver import fold +from cora.trust.aggregates.ratification.state import Ratification + +_STREAM_TYPE = "Ratification" + + +async def load_ratification(event_store: EventStore, ratification_id: UUID) -> Ratification | None: + """Load and fold a Ratification's event stream into current state.""" + stored, _version = await event_store.load(_STREAM_TYPE, ratification_id) + events = [from_stored(s) for s in stored] + return fold(events) + + +__all__ = ["load_ratification"] diff --git a/apps/api/src/cora/trust/aggregates/ratification/state.py b/apps/api/src/cora/trust/aggregates/ratification/state.py new file mode 100644 index 00000000000..e1bb18fc92b --- /dev/null +++ b/apps/api/src/cora/trust/aggregates/ratification/state.py @@ -0,0 +1,200 @@ +"""Ratification aggregate state, value objects, enum, and domain errors. + +`Ratification` is the consequence gate's aggregate: the record that a +consequential action is awaiting, or has received, a second independent +principal's co-signature before it may proceed. It is the "must a second +principal co-sign?" member of the governance-gate family, the deontic +co-action counterpart to Trust's permission gate (Policy / Authorize). + +Where it sits +------------- +A Ratification is requested for a target action (a command against a run or +other aggregate) whose declared consequence class requires co-signature. The +pending action is parked in the shared reversible hold; a second, independent +principal grants or denies the ratification; a grant releases the hold and lets +the action proceed. This aggregate owns only the co-signature lifecycle; the +consequence-class declaration and the decider gate that consults it are the +wiring increment (see the implementation plan), and the hold reuse is the +shipped kill-switch. This increment lands the aggregate DORMANT: nothing gates +on it yet, so it changes no existing behavior. + +3-state FSM +----------- + + Requested -> Granted (a different, independent principal co-signs) + Requested -> Denied (a different, independent principal refuses; + reason) + +`Requested` is genesis; `Granted` and `Denied` are terminal. There is no +re-open: a denied action's requester issues a fresh request if warranted, the +same strict-not-idempotent stance the sibling FSM aggregates take. + +Kind-blindness (consequence-gate invariant) +------------------------------------------- +The co-signature requirement attaches to the action's consequence, never to the +actor's kind: a human running a first-of-a-kind irreversible action is held for +co-signature identically to an agent. `requested_by` and the granting/denying +principal are bare identifiers; no decider in this aggregate reads actor kind. + +Independence +------------ +The grant/deny principal must differ from `requested_by`: a principal cannot +ratify its own pending action. This is the four-eyes property the gate exists to +provide, enforced in the grant/deny deciders and pinned by a unit test. +""" + +from dataclasses import dataclass +from enum import StrEnum +from uuid import UUID + +from cora.shared.text_bounds import REASON_MAX_LENGTH + + +class RatificationStatus(StrEnum): + """The Ratification's lifecycle state. + + Three values, locked day one: + + - `Requested` -- genesis; a co-signature is required and not yet given. + The target action is parked in the shared reversible hold. + - `Granted` -- a different, independent principal co-signed; terminal. + The hold on the target action may be released. + - `Denied` -- a different, independent principal refused; terminal. + The target action stays held (+ operator-supplied reason). + """ + + REQUESTED = "Requested" + GRANTED = "Granted" + DENIED = "Denied" + + +# --------------------------------------------------------------------------- +# Domain validation + transition-guard errors (raised by decider invariants) +# --------------------------------------------------------------------------- + + +class RatificationAlreadyExistsError(Exception): + """A ratification with the requested id already exists (genesis collision).""" + + def __init__(self, ratification_id: UUID) -> None: + super().__init__(f"Ratification {ratification_id} already exists") + self.ratification_id = ratification_id + + +class RatificationNotFoundError(Exception): + """No ratification exists for the given id (transition on an empty stream).""" + + def __init__(self, ratification_id: UUID) -> None: + super().__init__(f"Ratification {ratification_id} not found") + self.ratification_id = ratification_id + + +class RatificationCannotGrantError(Exception): + """Grant attempted from a non-`Requested` status (already terminal).""" + + def __init__(self, ratification_id: UUID, current_status: RatificationStatus) -> None: + super().__init__( + f"Ratification {ratification_id} cannot be granted: currently " + f"{current_status.value} (grant requires Requested)" + ) + self.ratification_id = ratification_id + self.current_status = current_status + + +class RatificationCannotDenyError(Exception): + """Deny attempted from a non-`Requested` status (already terminal).""" + + def __init__(self, ratification_id: UUID, current_status: RatificationStatus) -> None: + super().__init__( + f"Ratification {ratification_id} cannot be denied: currently " + f"{current_status.value} (deny requires Requested)" + ) + self.ratification_id = ratification_id + self.current_status = current_status + + +class RatificationRequesterCannotSelfRatifyError(Exception): + """The grant/deny principal is the same as the requester (independence breach). + + The consequence gate requires a DIFFERENT, INDEPENDENT second principal. A + principal cannot ratify its own pending action; this is the four-eyes + invariant. Kind-blind: the check compares bare principal identifiers and + never reads actor kind. + """ + + def __init__(self, ratification_id: UUID, principal_id: UUID) -> None: + super().__init__( + f"Ratification {ratification_id} cannot be co-signed by its requester " + f"{principal_id}: an independent second principal is required" + ) + self.ratification_id = ratification_id + self.principal_id = principal_id + + +class InvalidConsequenceClassError(ValueError): + """`consequence_class` is empty or too long after trim.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"consequence_class must be 1-{CONSEQUENCE_CLASS_MAX_LENGTH} chars " + f"after trimming (got: {value!r})" + ) + self.value = value + + +class InvalidRatificationReasonError(ValueError): + """Reason text on deny is empty or too long after trim.""" + + def __init__(self, value: str) -> None: + super().__init__( + f"Ratification reason must be 1-{REASON_MAX_LENGTH} chars after trimming " + f"(got: {value!r})" + ) + self.value = value + + +CONSEQUENCE_CLASS_MAX_LENGTH = 100 +"""Max length of a `consequence_class` label, after trim. + +A consequence class is a short bare-string label (e.g. `first_of_kind`, +`irreversible`) a capability declares for an action. Bare-str now, following +the Supply.kind / Procedure.kind precedent; graduates to a closed StrEnum when +the vocabulary stabilizes. +""" + + +# --------------------------------------------------------------------------- +# Aggregate +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Ratification: + """A co-signature record for a consequential target action. + + Slim by design: the aggregate owns the co-signature lifecycle and a + reference to the target action it gates, nothing more. The target's own + state (its hold) lives on the target aggregate; this record only tracks + whether the required second signature is pending, given, or refused. + + Fields: + - `id` -- the Ratification stream id. + - `target_action_id` -- opaque identifier of the action being gated + (e.g. the run id whose consequential command is + held). Not existence-checked here per the + cross-BC eventual-consistency stance. + - `command_name` -- the canonical name of the gated command. + - `consequence_class` -- the declared class that triggered the requirement. + - `requested_by` -- bare principal id of the requester (the actor + whose action is held). Independence is checked + against this on grant/deny. + - `status` -- lifecycle state. + - `last_reason` -- operator-supplied reason on deny (None otherwise). + """ + + id: UUID + target_action_id: UUID + command_name: str + consequence_class: str + requested_by: UUID + status: RatificationStatus + last_reason: str | None diff --git a/apps/api/src/cora/trust/features/deny_ratification/__init__.py b/apps/api/src/cora/trust/features/deny_ratification/__init__.py new file mode 100644 index 00000000000..c0f58a0f42f --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/__init__.py @@ -0,0 +1,9 @@ +"""Vertical slice for the `DenyRatification` command.""" + +from cora.trust.features.deny_ratification import tool +from cora.trust.features.deny_ratification.command import DenyRatification +from cora.trust.features.deny_ratification.decider import decide +from cora.trust.features.deny_ratification.handler import Handler, bind +from cora.trust.features.deny_ratification.route import router + +__all__ = ["DenyRatification", "Handler", "bind", "decide", "router", "tool"] diff --git a/apps/api/src/cora/trust/features/deny_ratification/command.py b/apps/api/src/cora/trust/features/deny_ratification/command.py new file mode 100644 index 00000000000..0e2f7f6cabe --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/command.py @@ -0,0 +1,21 @@ +"""The `DenyRatification` command -- intent dataclass for this slice. + +Transition command: Requested -> Denied. The refusing principal is the envelope +`principal_id`, threaded into the decider by the handler, NOT a command field. +Carries an operator-supplied `reason` (1-500 chars after trim). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class DenyRatification: + """Refuse (deny) a pending ratification. + + `ratification_id`: REQUIRED id of the Requested ratification to deny. + `reason`: REQUIRED operator-supplied reason (1-500 chars after trim). + """ + + ratification_id: UUID + reason: str diff --git a/apps/api/src/cora/trust/features/deny_ratification/decider.py b/apps/api/src/cora/trust/features/deny_ratification/decider.py new file mode 100644 index 00000000000..f3f0ef3ccc7 --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/decider.py @@ -0,0 +1,61 @@ +"""Pure decider for the `DenyRatification` command. + +Single-source transition: Requested -> Denied. Strict-not-idempotent. Reason is +mandatory and validated 1-500 chars after trim. + +The refusing principal `denied_by` is threaded in by the handler from the +envelope `principal_id`, and the same independence invariant as grant applies: +the denier must differ from the requester. Kind-blind: compares bare principal +identifiers, never actor kind. +""" + +from datetime import datetime +from uuid import UUID + +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.aggregates.ratification import ( + InvalidRatificationReasonError, + Ratification, + RatificationCannotDenyError, + RatificationDenied, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, +) +from cora.trust.features.deny_ratification.command import DenyRatification + +_PERMITTED: tuple[RatificationStatus, ...] = (RatificationStatus.REQUESTED,) + + +def decide( + state: Ratification | None, + command: DenyRatification, + *, + denied_by: UUID, + now: datetime, +) -> list[RatificationDenied]: + """Decide events for denying a Requested ratification. + + Invariants: + - State must not be None -> RatificationNotFoundError + - Status must be Requested -> RatificationCannotDenyError + - Denier must differ from the requester (independence / four-eyes) + -> RatificationRequesterCannotSelfRatifyError + - Reason 1-500 chars after trim -> InvalidRatificationReasonError + """ + if state is None: + raise RatificationNotFoundError(command.ratification_id) + if state.status not in _PERMITTED: + raise RatificationCannotDenyError( + ratification_id=state.id, + current_status=state.status, + ) + if denied_by == state.requested_by: + raise RatificationRequesterCannotSelfRatifyError( + ratification_id=state.id, + principal_id=denied_by, + ) + trimmed = command.reason.strip() + if not trimmed or len(trimmed) > REASON_MAX_LENGTH: + raise InvalidRatificationReasonError(command.reason) + return [RatificationDenied(ratification_id=state.id, reason=trimmed, occurred_at=now)] diff --git a/apps/api/src/cora/trust/features/deny_ratification/handler.py b/apps/api/src/cora/trust/features/deny_ratification/handler.py new file mode 100644 index 00000000000..9c74d70a6bc --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/handler.py @@ -0,0 +1,131 @@ +"""Application handler for the `deny_ratification` slice. + +Transition longhand handler: loads current Ratification state, threads the +envelope `principal_id` into the decider as `denied_by` (the independence check +compares it against the requester), and appends the resulting event with +optimistic concurrency at the loaded version. Mirrors the `supersede_caution` +principal-threading convention rather than the update-handler factory, because +the factory does not pass the principal into the decider. +""" + +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.ratification import ( + RatificationNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.errors import UnauthorizedError +from cora.trust.features.deny_ratification.command import DenyRatification +from cora.trust.features.deny_ratification.decider import decide + +_STREAM_TYPE = "Ratification" +_COMMAND_NAME = "DenyRatification" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare deny_ratification handler -- what `bind()` returns.""" + + async def __call__( + self, + command: DenyRatification, + *, + 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 deny_ratification handler closed over the shared deps.""" + + async def handler( + command: DenyRatification, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "deny_ratification.start", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + 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( + "deny_ratification.denied", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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.ratification_id) + if not stored: + raise RatificationNotFoundError(command.ratification_id) + state = fold([from_stored(s) for s in stored]) + + domain_events = decide( + state=state, + command=command, + denied_by=principal_id, + now=now, + ) + + 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.ratification_id, + expected_version=version, + events=new_events, + ) + + _log.info( + "deny_ratification.success", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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/deny_ratification/route.py b/apps/api/src/cora/trust/features/deny_ratification/route.py new file mode 100644 index 00000000000..3298b53b02c --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/route.py @@ -0,0 +1,86 @@ +"""HTTP route for the `deny_ratification` slice. + +Action endpoint at `POST /ratifications/{ratification_id}/deny`. Body carries +`reason`. 204 on success. +""" + +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.deny_ratification.command import DenyRatification +from cora.trust.features.deny_ratification.handler import Handler + + +class DenyRatificationRequest(BaseModel): + """Body for `POST /ratifications/{ratification_id}/deny`. + + `reason` is operator-supplied free text (audit-log breadcrumb) explaining + the refusal. MUST NOT contain PII. + """ + + reason: str = Field( + ..., + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Operator-supplied reason for the denial (audit-log breadcrumb; no PII).", + ) + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.trust.deny_ratification + return handler + + +router = APIRouter(tags=["trust"]) + + +@router.post( + "/ratifications/{ratification_id}/deny", + 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 Ratification exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "Ratification is not in Requested status, or denier is the requester.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Request body failed schema validation.", + }, + }, + summary="Deny (refuse) a Requested Ratification (Requested -> Denied)", +) +async def post_ratifications_deny( + ratification_id: Annotated[UUID, Path(description="Target Ratification's id.")], + body: DenyRatificationRequest, + 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( + DenyRatification(ratification_id=ratification_id, reason=body.reason), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/trust/features/deny_ratification/tool.py b/apps/api/src/cora/trust/features/deny_ratification/tool.py new file mode 100644 index 00000000000..53ec1d63188 --- /dev/null +++ b/apps/api/src/cora/trust/features/deny_ratification/tool.py @@ -0,0 +1,54 @@ +"""MCP tool for the `deny_ratification` 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.deny_ratification.command import DenyRatification +from cora.trust.features.deny_ratification.handler import Handler + + +class DenyRatificationOutput(BaseModel): + """Structured output of the `deny_ratification` MCP tool.""" + + ratification_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `deny_ratification` tool on the given MCP server.""" + + @mcp.tool( + name="deny_ratification", + description=( + "Deny (refuse) a Requested Ratification (Requested -> Denied). The " + "denying principal must be independent of the requester (four-eyes). " + "Reason REQUIRED. Reason MUST NOT contain PII." + ), + ) + async def deny_ratification_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + ratification_id: Annotated[UUID, Field(description="Target Ratification's id.")], + reason: Annotated[ + str, + Field( + min_length=1, + max_length=REASON_MAX_LENGTH, + description="Operator-supplied reason for the denial (no PII).", + ), + ], + ) -> DenyRatificationOutput: + handler = get_handler() + await handler( + DenyRatification(ratification_id=ratification_id, reason=reason), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return DenyRatificationOutput(ratification_id=ratification_id) diff --git a/apps/api/src/cora/trust/features/grant_ratification/__init__.py b/apps/api/src/cora/trust/features/grant_ratification/__init__.py new file mode 100644 index 00000000000..ee1e489a5d4 --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/__init__.py @@ -0,0 +1,9 @@ +"""Vertical slice for the `GrantRatification` command.""" + +from cora.trust.features.grant_ratification import tool +from cora.trust.features.grant_ratification.command import GrantRatification +from cora.trust.features.grant_ratification.decider import decide +from cora.trust.features.grant_ratification.handler import Handler, bind +from cora.trust.features.grant_ratification.route import router + +__all__ = ["GrantRatification", "Handler", "bind", "decide", "router", "tool"] diff --git a/apps/api/src/cora/trust/features/grant_ratification/command.py b/apps/api/src/cora/trust/features/grant_ratification/command.py new file mode 100644 index 00000000000..b236b2ffd8e --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/command.py @@ -0,0 +1,20 @@ +"""The `GrantRatification` command -- intent dataclass for this slice. + +Transition command: Requested -> Granted. The co-signing principal is the +envelope `principal_id`, threaded into the decider by the handler, NOT a command +field, so a caller cannot claim a different co-signer. Mirrors the +`supersede_caution` author-threading convention. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GrantRatification: + """Co-sign (grant) a pending ratification. + + `ratification_id`: REQUIRED id of the Requested ratification to grant. + """ + + ratification_id: UUID diff --git a/apps/api/src/cora/trust/features/grant_ratification/decider.py b/apps/api/src/cora/trust/features/grant_ratification/decider.py new file mode 100644 index 00000000000..2959af68f46 --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/decider.py @@ -0,0 +1,56 @@ +"""Pure decider for the `GrantRatification` command. + +Single-source transition: Requested -> Granted. Strict-not-idempotent. + +The co-signing principal `granted_by` is threaded in by the handler from the +envelope `principal_id`. The independence invariant, the four-eyes property the +consequence gate exists to provide, is enforced here: the grantor must differ +from the requester. Kind-blind: the check compares bare principal identifiers and +never reads actor kind, so a human co-signs for an agent (and vice versa) +identically. +""" + +from datetime import datetime +from uuid import UUID + +from cora.trust.aggregates.ratification import ( + Ratification, + RatificationCannotGrantError, + RatificationGranted, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, +) +from cora.trust.features.grant_ratification.command import GrantRatification + +_PERMITTED: tuple[RatificationStatus, ...] = (RatificationStatus.REQUESTED,) + + +def decide( + state: Ratification | None, + command: GrantRatification, + *, + granted_by: UUID, + now: datetime, +) -> list[RatificationGranted]: + """Decide events for granting a Requested ratification. + + Invariants: + - State must not be None -> RatificationNotFoundError + - Status must be Requested -> RatificationCannotGrantError + - Grantor must differ from the requester (independence / four-eyes) + -> RatificationRequesterCannotSelfRatifyError + """ + if state is None: + raise RatificationNotFoundError(command.ratification_id) + if state.status not in _PERMITTED: + raise RatificationCannotGrantError( + ratification_id=state.id, + current_status=state.status, + ) + if granted_by == state.requested_by: + raise RatificationRequesterCannotSelfRatifyError( + ratification_id=state.id, + principal_id=granted_by, + ) + return [RatificationGranted(ratification_id=state.id, occurred_at=now)] diff --git a/apps/api/src/cora/trust/features/grant_ratification/handler.py b/apps/api/src/cora/trust/features/grant_ratification/handler.py new file mode 100644 index 00000000000..64ec705a979 --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/handler.py @@ -0,0 +1,131 @@ +"""Application handler for the `grant_ratification` slice. + +Transition longhand handler: loads current Ratification state, threads the +envelope `principal_id` into the decider as `granted_by` (the independence check +compares it against the requester), and appends the resulting event with +optimistic concurrency at the loaded version. Mirrors the `supersede_caution` +principal-threading convention rather than the update-handler factory, because +the factory does not pass the principal into the decider. +""" + +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.ratification import ( + RatificationNotFoundError, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.errors import UnauthorizedError +from cora.trust.features.grant_ratification.command import GrantRatification +from cora.trust.features.grant_ratification.decider import decide + +_STREAM_TYPE = "Ratification" +_COMMAND_NAME = "GrantRatification" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare grant_ratification handler -- what `bind()` returns.""" + + async def __call__( + self, + command: GrantRatification, + *, + 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 grant_ratification handler closed over the shared deps.""" + + async def handler( + command: GrantRatification, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "grant_ratification.start", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + 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( + "grant_ratification.denied", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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.ratification_id) + if not stored: + raise RatificationNotFoundError(command.ratification_id) + state = fold([from_stored(s) for s in stored]) + + domain_events = decide( + state=state, + command=command, + granted_by=principal_id, + now=now, + ) + + 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.ratification_id, + expected_version=version, + events=new_events, + ) + + _log.info( + "grant_ratification.success", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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/grant_ratification/route.py b/apps/api/src/cora/trust/features/grant_ratification/route.py new file mode 100644 index 00000000000..6c3ff4460a4 --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/route.py @@ -0,0 +1,61 @@ +"""HTTP route for the `grant_ratification` slice. + +Action endpoint at `POST /ratifications/{ratification_id}/grant`. No body. +204 on success. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Path, Request, status + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.trust.features.grant_ratification.command import GrantRatification +from cora.trust.features.grant_ratification.handler import Handler + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.trust.grant_ratification + return handler + + +router = APIRouter(tags=["trust"]) + + +@router.post( + "/ratifications/{ratification_id}/grant", + status_code=status.HTTP_204_NO_CONTENT, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No Ratification exists with the given id.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "Ratification is not in Requested status, or grantor is the requester.", + }, + }, + summary="Grant (co-sign) a Requested Ratification (Requested -> Granted)", +) +async def post_ratifications_grant( + ratification_id: Annotated[UUID, Path(description="Target Ratification's id.")], + 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( + GrantRatification(ratification_id=ratification_id), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) diff --git a/apps/api/src/cora/trust/features/grant_ratification/tool.py b/apps/api/src/cora/trust/features/grant_ratification/tool.py new file mode 100644 index 00000000000..f9f44dad7ef --- /dev/null +++ b/apps/api/src/cora/trust/features/grant_ratification/tool.py @@ -0,0 +1,45 @@ +"""MCP tool for the `grant_ratification` 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.trust.features.grant_ratification.command import GrantRatification +from cora.trust.features.grant_ratification.handler import Handler + + +class GrantRatificationOutput(BaseModel): + """Structured output of the `grant_ratification` MCP tool.""" + + ratification_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `grant_ratification` tool on the given MCP server.""" + + @mcp.tool( + name="grant_ratification", + description=( + "Grant (co-sign) a Requested Ratification (Requested -> Granted). " + "The granting principal must be independent of the requester: a " + "principal cannot co-sign its own pending action (four-eyes)." + ), + ) + async def grant_ratification_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + ratification_id: Annotated[UUID, Field(description="Target Ratification's id.")], + ) -> GrantRatificationOutput: + handler = get_handler() + await handler( + GrantRatification(ratification_id=ratification_id), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return GrantRatificationOutput(ratification_id=ratification_id) diff --git a/apps/api/src/cora/trust/features/request_ratification/__init__.py b/apps/api/src/cora/trust/features/request_ratification/__init__.py new file mode 100644 index 00000000000..fdb790b924b --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/__init__.py @@ -0,0 +1,9 @@ +"""Vertical slice for the `RequestRatification` command (genesis).""" + +from cora.trust.features.request_ratification import tool +from cora.trust.features.request_ratification.command import RequestRatification +from cora.trust.features.request_ratification.decider import decide +from cora.trust.features.request_ratification.handler import Handler, bind +from cora.trust.features.request_ratification.route import router + +__all__ = ["Handler", "RequestRatification", "bind", "decide", "router", "tool"] diff --git a/apps/api/src/cora/trust/features/request_ratification/command.py b/apps/api/src/cora/trust/features/request_ratification/command.py new file mode 100644 index 00000000000..6ed7fc3e76d --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/command.py @@ -0,0 +1,39 @@ +"""The `RequestRatification` command -- intent dataclass for this slice. + +Genesis command. Caller-supplied `ratification_id` so a subscriber (the +consequence-gate decider that detects a co-signature is required) can mint a +deterministic id for replay-safe ingest, while operator-direct requests can also +supply one. + +The requester (`requested_by`) is NOT a command field: it is the envelope +`principal_id`, threaded into the decider by the handler, so a caller cannot +claim a different requester than the one issuing the request. This mirrors the +`supersede_caution` author-threading convention (command surface omits the +principal; the handler supplies it). +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class RequestRatification: + """Request a second-principal co-signature for a consequential action. + + `ratification_id`: REQUIRED caller-supplied id (genesis collision raises + `RatificationAlreadyExistsError`). + + `target_action_id`: REQUIRED opaque id of the action being gated (e.g. the run id + whose consequential command is held). Not existence-checked at the decider + per the cross-BC eventual-consistency stance. + + `command_name`: REQUIRED canonical name of the gated command. + + `consequence_class`: REQUIRED declared class that triggered the requirement + (bare-str label, 1-100 chars after trim). + """ + + ratification_id: UUID + target_action_id: UUID + command_name: str + consequence_class: str diff --git a/apps/api/src/cora/trust/features/request_ratification/decider.py b/apps/api/src/cora/trust/features/request_ratification/decider.py new file mode 100644 index 00000000000..a9816f05293 --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/decider.py @@ -0,0 +1,55 @@ +"""Pure decider for the `RequestRatification` command. + +Genesis transition: (empty) -> Requested. Caller supplies `ratification_id`; +collision raises `RatificationAlreadyExistsError` (state is None means no prior +events; non-None means it already exists). + +`requested_by` is threaded in by the handler from the envelope `principal_id` +(not a command field), so the requester recorded on genesis is the actual +issuing principal. Kind-blind: the decider records a bare principal id and never +reads actor kind. +""" + +from datetime import datetime +from uuid import UUID + +from cora.trust.aggregates.ratification import ( + CONSEQUENCE_CLASS_MAX_LENGTH, + InvalidConsequenceClassError, + Ratification, + RatificationAlreadyExistsError, + RatificationRequested, +) +from cora.trust.features.request_ratification.command import RequestRatification + + +def decide( + state: Ratification | None, + command: RequestRatification, + *, + requested_by: UUID, + now: datetime, +) -> list[RatificationRequested]: + """Decide the events produced by requesting a ratification. + + Invariants: + - State must be None (caller-supplied id collision guard) + -> RatificationAlreadyExistsError + - consequence_class 1-100 chars after trim + -> InvalidConsequenceClassError + """ + if state is not None: + raise RatificationAlreadyExistsError(command.ratification_id) + trimmed_class = command.consequence_class.strip() + if not trimmed_class or len(trimmed_class) > CONSEQUENCE_CLASS_MAX_LENGTH: + raise InvalidConsequenceClassError(command.consequence_class) + return [ + RatificationRequested( + ratification_id=command.ratification_id, + target_action_id=command.target_action_id, + command_name=command.command_name, + consequence_class=trimmed_class, + requested_by=requested_by, + occurred_at=now, + ) + ] diff --git a/apps/api/src/cora/trust/features/request_ratification/handler.py b/apps/api/src/cora/trust/features/request_ratification/handler.py new file mode 100644 index 00000000000..6a94375731f --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/handler.py @@ -0,0 +1,126 @@ +"""Application handler for the `request_ratification` slice. + +Genesis-style longhand handler, mirroring `register_visit.handler` plus the +`supersede_caution` principal-threading convention: the requester is the +envelope `principal_id`, passed into the decider as `requested_by` (the command +surface omits it, so a caller cannot claim a different requester). + +Caller-supplied `ratification_id` means the handler does NOT mint the stream id; +it still uses the id generator for the per-event `event_id`. `expected_version=0` +per genesis: the stream must be empty; collision surfaces as +`RatificationAlreadyExistsError` (decider) or `ConcurrencyError` (event store). +""" + +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.ratification import event_type_name, load_ratification, to_payload +from cora.trust.errors import UnauthorizedError +from cora.trust.features.request_ratification.command import RequestRatification +from cora.trust.features.request_ratification.decider import decide + +_STREAM_TYPE = "Ratification" +_COMMAND_NAME = "RequestRatification" + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare request_ratification handler -- what `bind()` returns.""" + + async def __call__( + self, + command: RequestRatification, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a request_ratification handler closed over the shared deps.""" + + async def handler( + command: RequestRatification, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "request_ratification.start", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + 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( + "request_ratification.denied", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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() + state = await load_ratification(deps.event_store, command.ratification_id) + + domain_events = decide( + state=state, + command=command, + requested_by=principal_id, + now=now, + ) + + 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.ratification_id, + expected_version=0, + events=new_events, + ) + + _log.info( + "request_ratification.success", + command_name=_COMMAND_NAME, + ratification_id=str(command.ratification_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 command.ratification_id + + return handler diff --git a/apps/api/src/cora/trust/features/request_ratification/route.py b/apps/api/src/cora/trust/features/request_ratification/route.py new file mode 100644 index 00000000000..76b610d2941 --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/route.py @@ -0,0 +1,107 @@ +"""HTTP route for the `request_ratification` slice. + +Endpoint at `POST /ratifications`. Caller supplies the `ratification_id` +(genesis collision raises 409 via central `_handle_already_exists`). The +requester is the envelope `principal_id`, threaded into the decider by the +handler, not a body field. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Request, status +from pydantic import BaseModel, Field + +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) +from cora.trust.aggregates.ratification import CONSEQUENCE_CLASS_MAX_LENGTH +from cora.trust.features.request_ratification.command import RequestRatification +from cora.trust.features.request_ratification.handler import Handler + + +class RequestRatificationRequest(BaseModel): + """Body for `POST /ratifications`.""" + + ratification_id: UUID = Field( + ..., + description=( + "Caller-supplied UUID. A subscriber may mint a deterministic uuid5 " + "for replay-safe ingest; operator-direct may use uuid4." + ), + ) + target_action_id: UUID = Field( + ..., + description=( + "Opaque id of the action being gated (e.g. the run id whose " + "consequential command is held). Not existence-checked at the decider." + ), + ) + command_name: str = Field(..., description="Canonical name of the gated command.") + consequence_class: str = Field( + ..., + min_length=1, + max_length=CONSEQUENCE_CLASS_MAX_LENGTH, + description="Declared class that triggered the requirement (bare-str label).", + ) + + +class RequestRatificationResponse(BaseModel): + """Response body for `POST /ratifications`.""" + + ratification_id: UUID + + +def _get_handler(request: Request) -> Handler: + handler: Handler = request.app.state.trust.request_ratification + return handler + + +router = APIRouter(tags=["trust"]) + + +@router.post( + "/ratifications", + status_code=status.HTTP_201_CREATED, + response_model=RequestRatificationResponse, + responses={ + status.HTTP_400_BAD_REQUEST: { + "model": ErrorResponse, + "description": "Domain invariant violated (e.g. empty consequence_class).", + }, + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the command.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": "Ratification with this id already exists.", + }, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "Request body failed schema validation.", + }, + }, + summary="Request a second-principal co-signature for a consequential action", +) +async def post_ratifications( + body: RequestRatificationRequest, + 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)], +) -> RequestRatificationResponse: + ratification_id = await handler( + RequestRatification( + ratification_id=body.ratification_id, + target_action_id=body.target_action_id, + command_name=body.command_name, + consequence_class=body.consequence_class, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + ) + return RequestRatificationResponse(ratification_id=ratification_id) diff --git a/apps/api/src/cora/trust/features/request_ratification/tool.py b/apps/api/src/cora/trust/features/request_ratification/tool.py new file mode 100644 index 00000000000..86222d1ef4e --- /dev/null +++ b/apps/api/src/cora/trust/features/request_ratification/tool.py @@ -0,0 +1,68 @@ +"""MCP tool for the `request_ratification` 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.trust.aggregates.ratification import CONSEQUENCE_CLASS_MAX_LENGTH +from cora.trust.features.request_ratification.command import RequestRatification +from cora.trust.features.request_ratification.handler import Handler + + +class RequestRatificationOutput(BaseModel): + """Structured output of the `request_ratification` MCP tool.""" + + ratification_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], Handler]) -> None: + """Register the `request_ratification` tool on the given MCP server.""" + + @mcp.tool( + name="request_ratification", + description=( + "Request a second, independent principal's co-signature for a " + "consequential action. Caller supplies ratification_id. Status " + "starts at 'Requested'. The requester is the calling principal; an " + "independent principal must later grant or deny." + ), + ) + async def request_ratification_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + ratification_id: Annotated[UUID, Field(description="Caller-supplied UUID.")], + target_action_id: Annotated[ + UUID, + Field(description="Opaque id of the action being gated (e.g. the held run id)."), + ], + command_name: Annotated[ + str, + Field(description="Canonical name of the gated command."), + ], + consequence_class: Annotated[ + str, + Field( + min_length=1, + max_length=CONSEQUENCE_CLASS_MAX_LENGTH, + description="Declared class that triggered the requirement (bare-str label).", + ), + ], + ) -> RequestRatificationOutput: + handler = get_handler() + await handler( + RequestRatification( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + ) + return RequestRatificationOutput(ratification_id=ratification_id) diff --git a/apps/api/src/cora/trust/routes.py b/apps/api/src/cora/trust/routes.py index 4a0ad425f1f..59361134d7f 100644 --- a/apps/api/src/cora/trust/routes.py +++ b/apps/api/src/cora/trust/routes.py @@ -51,6 +51,15 @@ PolicyAlreadyExistsError, PolicyNotFoundError, ) +from cora.trust.aggregates.ratification import ( + InvalidConsequenceClassError, + InvalidRatificationReasonError, + RatificationAlreadyExistsError, + RatificationCannotDenyError, + RatificationCannotGrantError, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, +) from cora.trust.aggregates.surface import InvalidSurfaceNameError, SurfaceAlreadyExistsError from cora.trust.aggregates.visit import ( InvalidVisitPlannedPeriodError, @@ -85,8 +94,10 @@ define_policy, define_surface, define_zone, + deny_ratification, evaluate_policy, get_surface, + grant_ratification, hold_visit, list_conduits, list_permissions, @@ -95,6 +106,7 @@ record_visit_arrival, register_visit, release_control_of_surface, + request_ratification, resume_visit, revoke_grant, start_visit, @@ -214,6 +226,10 @@ def register_trust_routes(app: FastAPI) -> None: # Visit Surface-control slices. app.include_router(take_control_of_surface.router) app.include_router(release_control_of_surface.router) + # Ratification (consequence gate) slices. + app.include_router(request_ratification.router) + app.include_router(grant_ratification.router) + app.include_router(deny_ratification.router) for invalid_name_cls in ( InvalidZoneNameError, InvalidConduitNameError, @@ -229,6 +245,7 @@ def register_trust_routes(app: FastAPI) -> None: VisitAlreadyExistsError, # VisitAlreadyCheckedInError reuses 409 (semantically an exists-conflict). VisitAlreadyCheckedInError, + RatificationAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) for logbook_state_cls in ( @@ -244,6 +261,7 @@ def register_trust_routes(app: FastAPI) -> None: VisitActorNotCheckedInError, VisitParentNotFoundError, PolicyNotFoundError, + RatificationNotFoundError, ): app.add_exception_handler(not_found_cls, _handle_not_found) for invalid_400_cls in ( @@ -251,6 +269,8 @@ def register_trust_routes(app: FastAPI) -> None: InvalidPolicySurfaceError, InvalidVisitPlannedPeriodError, InvalidVisitReasonError, + InvalidConsequenceClassError, + InvalidRatificationReasonError, ): app.add_exception_handler(invalid_400_cls, _handle_invalid_400) for cannot_409_cls in ( @@ -266,6 +286,9 @@ def register_trust_routes(app: FastAPI) -> None: VisitCannotTakeControlError, VisitCannotVoidError, VisitParentMismatchedSurfaceError, + RatificationCannotGrantError, + RatificationCannotDenyError, + RatificationRequesterCannotSelfRatifyError, ): app.add_exception_handler(cannot_409_cls, _handle_visit_conflict_409) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/trust/tools.py b/apps/api/src/cora/trust/tools.py index 2a5e5f771fd..45d72859b52 100644 --- a/apps/api/src/cora/trust/tools.py +++ b/apps/api/src/cora/trust/tools.py @@ -19,8 +19,10 @@ from cora.trust.features.define_policy import tool as define_policy_tool from cora.trust.features.define_surface import tool as define_surface_tool from cora.trust.features.define_zone import tool as define_zone_tool +from cora.trust.features.deny_ratification import tool as deny_ratification_tool from cora.trust.features.evaluate_policy import tool as evaluate_policy_tool from cora.trust.features.get_surface import tool as get_surface_tool +from cora.trust.features.grant_ratification import tool as grant_ratification_tool from cora.trust.features.hold_visit import tool as hold_visit_tool from cora.trust.features.list_conduits import tool as list_conduits_tool from cora.trust.features.list_permissions import tool as list_permissions_tool @@ -29,6 +31,7 @@ from cora.trust.features.record_visit_arrival import tool as record_visit_arrival_tool 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.request_ratification import tool as request_ratification_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 @@ -74,3 +77,7 @@ def register_trust_tools( release_control_of_surface_tool.register( mcp, get_handler=lambda: get_handlers().release_control_of_surface ) + # Ratification (consequence gate) tools. + request_ratification_tool.register(mcp, get_handler=lambda: get_handlers().request_ratification) + grant_ratification_tool.register(mcp, get_handler=lambda: get_handlers().grant_ratification) + deny_ratification_tool.register(mcp, get_handler=lambda: get_handlers().deny_ratification) diff --git a/apps/api/src/cora/trust/wire.py b/apps/api/src/cora/trust/wire.py index 284a75f2f39..552d47023fe 100644 --- a/apps/api/src/cora/trust/wire.py +++ b/apps/api/src/cora/trust/wire.py @@ -33,8 +33,10 @@ define_policy, define_surface, define_zone, + deny_ratification, evaluate_policy, get_surface, + grant_ratification, hold_visit, list_conduits, list_permissions, @@ -43,6 +45,7 @@ record_visit_arrival, register_visit, release_control_of_surface, + request_ratification, resume_visit, revoke_grant, start_visit, @@ -85,6 +88,9 @@ class TrustHandlers: take_control_of_surface: take_control_of_surface.Handler release_control_of_surface: release_control_of_surface.Handler revoke_grant: revoke_grant.Handler + request_ratification: request_ratification.Handler + grant_ratification: grant_ratification.Handler + deny_ratification: deny_ratification.Handler evaluate_policy: evaluate_policy.Handler get_surface: get_surface.Handler list_zones: list_zones.Handler @@ -221,6 +227,21 @@ def wire_trust(deps: Kernel) -> TrustHandlers: command_name="RevokePolicyGrant", bc=_BC, ), + request_ratification=with_tracing( + request_ratification.bind(deps), + command_name="RequestRatification", + bc=_BC, + ), + grant_ratification=with_tracing( + grant_ratification.bind(deps), + command_name="GrantRatification", + bc=_BC, + ), + deny_ratification=with_tracing( + deny_ratification.bind(deps), + command_name="DenyRatification", + bc=_BC, + ), evaluate_policy=with_tracing( evaluate_policy.bind(deps), command_name="EvaluatePolicy", diff --git a/apps/api/tests/architecture/test_event_class_defined_vs_registered.py b/apps/api/tests/architecture/test_event_class_defined_vs_registered.py index 33c222bbec6..f9ffff1fc9f 100644 --- a/apps/api/tests/architecture/test_event_class_defined_vs_registered.py +++ b/apps/api/tests/architecture/test_event_class_defined_vs_registered.py @@ -106,6 +106,15 @@ "moment, not an instance template or a runtime binding; renaming " "to AcquisitionRegistered would mislabel a fact as an instance." ), + ("trust", "ratification"): ( + "RatificationRequested names its own lifecycle state: RatificationStatus " + "is {Requested, Granted, Denied}, so the genesis event is the transition " + "into Requested. A RatificationRegistered genesis would emit an event " + "matching no state it produces, breaking the event/status symmetry. This " + "is the RunStarted shape (genesis encodes the transition into the first " + "state; the enum has no Registered member), not a template-definition or " + "an instance-registration the convention covers." + ), ("data", "attestation"): ( "AttestationRecorded is a terminal-at-genesis recorded-fact-chain " "(one stream per Attestation; a single AttestationRecorded event). " 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 647ec96b43d..2e250828d51 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -68,6 +68,7 @@ "policy", "practice", "procedure", + "ratification", "recipe", "role", "run", diff --git a/apps/api/tests/contract/test_deny_ratification_mcp_tool.py b/apps/api/tests/contract/test_deny_ratification_mcp_tool.py new file mode 100644 index 00000000000..980f4855b44 --- /dev/null +++ b/apps/api/tests/contract/test_deny_ratification_mcp_tool.py @@ -0,0 +1,129 @@ +"""Contract tests for the `deny_ratification` MCP tool. + +Mirror of `test_define_conduit_mcp_tool.py`. Shared MCP helpers live in +`tests/contract/_mcp_helpers.py`. + +The MCP tool runs as SYSTEM_PRINCIPAL_ID in legacy posture. To exercise the +happy path (an independent co-signer) the Ratification is first requested over +REST with a distinct `X-Principal-Id`, so the MCP deny is by a different +principal and does not trip the four-eyes independence guard. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + +_REQUESTER = "01910000-0000-7000-8000-0000000000d1" + + +def _request_ratification(client: TestClient) -> str: + """Request a Ratification over REST as a non-SYSTEM requester; return its id.""" + ratification_id = str(uuid4()) + response = client.post( + "/ratifications", + json={ + "ratification_id": ratification_id, + "target_action_id": str(uuid4()), + "command_name": "AbortRun", + "consequence_class": "first_of_kind", + }, + headers={"X-Principal-Id": _REQUESTER}, + ) + assert response.status_code == 201, response.text + return ratification_id + + +@pytest.mark.contract +def test_mcp_lists_deny_ratification_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 "deny_ratification" in tool_names + + +@pytest.mark.contract +def test_mcp_deny_ratification_tool_returns_structured_ratification_id() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "deny_ratification", + "arguments": { + "ratification_id": rid, + "reason": "unsafe first-of-kind action", + }, + }, + }, + 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"]["ratification_id"] == rid + + +@pytest.mark.contract +def test_mcp_deny_ratification_tool_returns_iserror_on_unknown_id() -> None: + """Unknown ratification_id raises RatificationNotFoundError; FastMCP wraps it + as isError: true with a text diagnostic.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "deny_ratification", + "arguments": { + "ratification_id": str(uuid4()), + "reason": "unsafe first-of-kind action", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_deny_ratification_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "deny_ratification", + "arguments": {"ratification_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/contract/test_grant_ratification_mcp_tool.py b/apps/api/tests/contract/test_grant_ratification_mcp_tool.py new file mode 100644 index 00000000000..24f28e23e4d --- /dev/null +++ b/apps/api/tests/contract/test_grant_ratification_mcp_tool.py @@ -0,0 +1,123 @@ +"""Contract tests for the `grant_ratification` MCP tool. + +Mirror of `test_define_conduit_mcp_tool.py`. Shared MCP helpers live in +`tests/contract/_mcp_helpers.py`. + +The MCP tool runs as SYSTEM_PRINCIPAL_ID in legacy posture. To exercise the +happy path (an independent co-signer) the Ratification is first requested over +REST with a distinct `X-Principal-Id`, so the MCP grant is by a different +principal and does not trip the four-eyes independence guard. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + +_REQUESTER = "01910000-0000-7000-8000-0000000000d1" + + +def _request_ratification(client: TestClient) -> str: + """Request a Ratification over REST as a non-SYSTEM requester; return its id.""" + ratification_id = str(uuid4()) + response = client.post( + "/ratifications", + json={ + "ratification_id": ratification_id, + "target_action_id": str(uuid4()), + "command_name": "AbortRun", + "consequence_class": "first_of_kind", + }, + headers={"X-Principal-Id": _REQUESTER}, + ) + assert response.status_code == 201, response.text + return ratification_id + + +@pytest.mark.contract +def test_mcp_lists_grant_ratification_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 "grant_ratification" in tool_names + + +@pytest.mark.contract +def test_mcp_grant_ratification_tool_returns_structured_ratification_id() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "grant_ratification", + "arguments": {"ratification_id": rid}, + }, + }, + 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"]["ratification_id"] == rid + + +@pytest.mark.contract +def test_mcp_grant_ratification_tool_returns_iserror_on_unknown_id() -> None: + """Unknown ratification_id raises RatificationNotFoundError; FastMCP wraps it + as isError: true with a text diagnostic.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "grant_ratification", + "arguments": {"ratification_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_grant_ratification_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "grant_ratification", + "arguments": {}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/contract/test_ratification_endpoints.py b/apps/api/tests/contract/test_ratification_endpoints.py new file mode 100644 index 00000000000..8cd7f3406b5 --- /dev/null +++ b/apps/api/tests/contract/test_ratification_endpoints.py @@ -0,0 +1,232 @@ +"""HTTP contract tests for the 3 Ratification endpoints. + +Consolidated coverage file: covers `request_ratification`, `grant_ratification`, +and `deny_ratification` per the arch-fitness substring-match rule. Pins the REST +surface: status codes, body shapes, the four-eyes independence mapping, and the +404 / 409 / 400 error mappings. + +Principal note: the pool-less TestClient runs in legacy posture, so a request +with no `X-Principal-Id` header runs as SYSTEM_PRINCIPAL_ID. Two requests both +sent without a header therefore share one principal, which is exactly the +self-sign (independence-breach) case; an independent co-signer is simulated by +sending a distinct `X-Principal-Id` header on the grant / deny call. +""" + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app + +_COMMAND_NAME = "AbortRun" +_CONSEQUENCE_CLASS = "first_of_kind" +_OTHER_PRINCIPAL = "01910000-0000-7000-8000-0000000000c1" + + +def _request_ratification(client: TestClient) -> str: + """Request a fresh Ratification, return its id. Status starts at Requested. + + Sent with no X-Principal-Id, so the requester is SYSTEM_PRINCIPAL_ID. + """ + ratification_id = str(uuid4()) + response = client.post( + "/ratifications", + json={ + "ratification_id": ratification_id, + "target_action_id": str(uuid4()), + "command_name": _COMMAND_NAME, + "consequence_class": _CONSEQUENCE_CLASS, + }, + ) + assert response.status_code == 201, response.text + assert response.json()["ratification_id"] == ratification_id + return ratification_id + + +# --------------------------------------------------------------------------- +# request_ratification (POST /ratifications) +# --------------------------------------------------------------------------- + + +@pytest.mark.contract +def test_post_ratifications_returns_201_with_caller_supplied_id() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + assert rid + + +@pytest.mark.contract +def test_post_ratifications_returns_409_when_ratification_id_collides() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + second = client.post( + "/ratifications", + json={ + "ratification_id": rid, + "target_action_id": str(uuid4()), + "command_name": _COMMAND_NAME, + "consequence_class": _CONSEQUENCE_CLASS, + }, + ) + assert second.status_code == 409 + + +@pytest.mark.contract +def test_post_ratifications_returns_400_on_whitespace_only_consequence_class() -> None: + """Whitespace-only consequence_class trips the domain guard -> 400.""" + with TestClient(create_app()) as client: + response = client.post( + "/ratifications", + json={ + "ratification_id": str(uuid4()), + "target_action_id": str(uuid4()), + "command_name": _COMMAND_NAME, + "consequence_class": " ", + }, + ) + assert response.status_code == 400 + + +@pytest.mark.contract +def test_post_ratifications_returns_422_when_consequence_class_missing() -> None: + with TestClient(create_app()) as client: + response = client.post( + "/ratifications", + json={ + "ratification_id": str(uuid4()), + "target_action_id": str(uuid4()), + "command_name": _COMMAND_NAME, + }, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# grant_ratification (POST /ratifications/{id}/grant) +# --------------------------------------------------------------------------- + + +@pytest.mark.contract +def test_grant_returns_204_for_independent_principal() -> None: + """An independent co-signer (distinct X-Principal-Id) grants -> 204.""" + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post( + f"/ratifications/{rid}/grant", + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_grant_returns_409_when_requester_self_signs() -> None: + """Four-eyes: same principal (no header on either call) grants -> 409.""" + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post(f"/ratifications/{rid}/grant") + assert response.status_code == 409 + + +@pytest.mark.contract +def test_grant_returns_404_when_ratification_absent() -> None: + with TestClient(create_app()) as client: + response = client.post( + f"/ratifications/{uuid4()}/grant", + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_grant_returns_409_when_already_granted() -> None: + """Grant from a terminal (Granted) status -> 409.""" + with TestClient(create_app()) as client: + rid = _request_ratification(client) + assert ( + client.post( + f"/ratifications/{rid}/grant", + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ).status_code + == 204 + ) + response = client.post( + f"/ratifications/{rid}/grant", + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 409 + + +# --------------------------------------------------------------------------- +# deny_ratification (POST /ratifications/{id}/deny) +# --------------------------------------------------------------------------- + + +@pytest.mark.contract +def test_deny_returns_204_for_independent_principal_with_reason() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post( + f"/ratifications/{rid}/deny", + json={"reason": "unsafe first-of-kind action"}, + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 204, response.text + + +@pytest.mark.contract +def test_deny_returns_409_when_requester_self_signs() -> None: + """Four-eyes: same principal (no header) denies -> 409.""" + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post(f"/ratifications/{rid}/deny", json={"reason": "no"}) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_deny_returns_404_when_ratification_absent() -> None: + with TestClient(create_app()) as client: + response = client.post( + f"/ratifications/{uuid4()}/deny", + json={"reason": "no"}, + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 404 + + +@pytest.mark.contract +def test_deny_returns_409_when_already_granted() -> None: + """Deny from a terminal (Granted) status -> 409.""" + with TestClient(create_app()) as client: + rid = _request_ratification(client) + client.post(f"/ratifications/{rid}/grant", headers={"X-Principal-Id": _OTHER_PRINCIPAL}) + response = client.post( + f"/ratifications/{rid}/deny", + json={"reason": "too late"}, + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 409 + + +@pytest.mark.contract +def test_deny_returns_400_on_whitespace_only_reason() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post( + f"/ratifications/{rid}/deny", + json={"reason": " "}, + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 400 + + +@pytest.mark.contract +def test_deny_returns_422_when_reason_missing() -> None: + with TestClient(create_app()) as client: + rid = _request_ratification(client) + response = client.post( + f"/ratifications/{rid}/deny", + json={}, + headers={"X-Principal-Id": _OTHER_PRINCIPAL}, + ) + assert response.status_code == 422 diff --git a/apps/api/tests/contract/test_request_ratification_mcp_tool.py b/apps/api/tests/contract/test_request_ratification_mcp_tool.py new file mode 100644 index 00000000000..3a9d649d333 --- /dev/null +++ b/apps/api/tests/contract/test_request_ratification_mcp_tool.py @@ -0,0 +1,110 @@ +"""Contract tests for the `request_ratification` MCP tool. + +Mirror of `test_define_conduit_mcp_tool.py`. Shared MCP helpers live in +`tests/contract/_mcp_helpers.py`. +""" + +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +@pytest.mark.contract +def test_mcp_lists_request_ratification_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 "request_ratification" in tool_names + + +@pytest.mark.contract +def test_mcp_request_ratification_tool_returns_structured_ratification_id() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "request_ratification", + "arguments": { + "ratification_id": str(uuid4()), + "target_action_id": str(uuid4()), + "command_name": "AbortRun", + "consequence_class": "first_of_kind", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + result = body["result"] + assert result["isError"] is False + assert "ratification_id" in result["structuredContent"] + UUID(result["structuredContent"]["ratification_id"]) # parses without raising + + +@pytest.mark.contract +def test_mcp_request_ratification_tool_returns_iserror_on_invalid_input() -> None: + """Whitespace-only consequence_class passes Pydantic min_length=1 but trips + the domain guard; FastMCP wraps the raised InvalidConsequenceClassError as + isError: true with a text diagnostic.""" + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 4, + "method": "tools/call", + "params": { + "name": "request_ratification", + "arguments": { + "ratification_id": str(uuid4()), + "target_action_id": str(uuid4()), + "command_name": "AbortRun", + "consequence_class": " ", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True + + +@pytest.mark.contract +def test_mcp_request_ratification_tool_rejects_missing_argument() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 5, + "method": "tools/call", + "params": { + "name": "request_ratification", + "arguments": {"ratification_id": str(uuid4())}, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200 + body = parse_sse_data(response.text) + assert body["result"]["isError"] is True diff --git a/apps/api/tests/unit/deployments/test_architecture_introspect.py b/apps/api/tests/unit/deployments/test_architecture_introspect.py index 9300a0ec533..0d7c3fc04e3 100644 --- a/apps/api/tests/unit/deployments/test_architecture_introspect.py +++ b/apps/api/tests/unit/deployments/test_architecture_introspect.py @@ -74,11 +74,12 @@ def test_introspection_aggregates_match_filesystem() -> None: assert generated == _filesystem_aggregates() -def test_counts_are_seventeen_bcs_and_forty_aggregates() -> None: +def test_counts_are_seventeen_bcs_and_forty_one_aggregates() -> None: # Anti-drift pins for the model.md headline; bump deliberately on a BC/aggregate add. + # 41 aggregates: the Trust BC gained Ratification (the consequence gate). model = ai.introspect(_CORA) assert model.bc_count == 17 - assert model.aggregate_count == 40 + assert model.aggregate_count == 41 def test_enclosure_bc_and_equipment_role_are_present() -> None: @@ -139,7 +140,7 @@ def test_bc_table_group_map_covers_every_bc() -> None: def test_count_renderer() -> None: assert ap.render_count(_MODEL, {"kind": "bc", "spell": "true", "cap": "true"}) == "Seventeen" - assert ap.render_count(_MODEL, {"kind": "aggregate", "spell": "true"}) == "forty" + assert ap.render_count(_MODEL, {"kind": "aggregate", "spell": "true"}) == "forty-one" assert ap.render_count(_MODEL, {"kind": "bc"}) == "17" assert ap.render_count(_MODEL, {"kind": "event", "bc": "decision"}) == "4" assert ap.render_count(_MODEL, {"kind": "slice", "bc": "equipment"}) == "60" diff --git a/apps/api/tests/unit/trust/ratification/__init__.py b/apps/api/tests/unit/trust/ratification/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/api/tests/unit/trust/ratification/_fixtures.py b/apps/api/tests/unit/trust/ratification/_fixtures.py new file mode 100644 index 00000000000..2bf5f15f6c2 --- /dev/null +++ b/apps/api/tests/unit/trust/ratification/_fixtures.py @@ -0,0 +1,46 @@ +"""Shared fixtures + helpers for Ratification slice tests. + +Per-slice tests build state via the canonical fixtures here to avoid +copy-pasted construction across the decider + evolver test files. +""" + +from datetime import UTC, datetime +from uuid import UUID + +from cora.trust.aggregates.ratification import ( + Ratification, + RatificationEvent, + RatificationRequested, + RatificationStatus, + fold, +) + +NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC) +RATIFICATION_ID = UUID("01910000-0000-7000-8000-00000000b001") +TARGET_REF = UUID("01910000-0000-7000-8000-00000000b002") +REQUESTER_ID = UUID("01910000-0000-7000-8000-00000000b003") +OTHER_PRINCIPAL_ID = UUID("01910000-0000-7000-8000-00000000b004") +COMMAND_NAME = "AbortRun" +CONSEQUENCE_CLASS = "first_of_kind" + + +def make_requested( + *, + requested_by: UUID = REQUESTER_ID, + consequence_class: str = CONSEQUENCE_CLASS, +) -> Ratification: + """Build a Ratification in the Requested (genesis) state.""" + events: list[RatificationEvent] = [ + RatificationRequested( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class=consequence_class, + requested_by=requested_by, + occurred_at=NOW, + ) + ] + state = fold(events) + assert state is not None + assert state.status is RatificationStatus.REQUESTED + return state diff --git a/apps/api/tests/unit/trust/ratification/test_deny_ratification_decider.py b/apps/api/tests/unit/trust/ratification/test_deny_ratification_decider.py new file mode 100644 index 00000000000..8677408794b --- /dev/null +++ b/apps/api/tests/unit/trust/ratification/test_deny_ratification_decider.py @@ -0,0 +1,82 @@ +"""Decider tests for the `deny_ratification` slice (Requested -> Denied). + +Same independence (four-eyes) invariant as grant, plus mandatory reason +validation. +""" + +import pytest + +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.aggregates.ratification import ( + InvalidRatificationReasonError, + RatificationCannotDenyError, + RatificationDenied, + RatificationGranted, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + evolve, +) +from cora.trust.features.deny_ratification import DenyRatification +from cora.trust.features.deny_ratification.decider import decide +from tests.unit.trust.ratification._fixtures import ( + NOW, + OTHER_PRINCIPAL_ID, + RATIFICATION_ID, + REQUESTER_ID, + make_requested, +) + +_CMD = DenyRatification(ratification_id=RATIFICATION_ID, reason="unsafe first-of-kind action") + + +@pytest.mark.unit +def test_independent_principal_denies_with_reason() -> None: + events = decide(state=make_requested(), command=_CMD, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + assert len(events) == 1 + [e] = events + assert isinstance(e, RatificationDenied) + assert e.ratification_id == RATIFICATION_ID + assert e.reason == "unsafe first-of-kind action" + assert e.occurred_at == NOW + + +@pytest.mark.unit +def test_requester_cannot_self_deny() -> None: + with pytest.raises(RatificationRequesterCannotSelfRatifyError): + decide(state=make_requested(), command=_CMD, denied_by=REQUESTER_ID, now=NOW) + + +@pytest.mark.unit +def test_not_found_raises() -> None: + with pytest.raises(RatificationNotFoundError): + decide(state=None, command=_CMD, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + + +@pytest.mark.unit +def test_deny_from_terminal_raises_cannot_deny() -> None: + granted = evolve( + make_requested(), RatificationGranted(ratification_id=RATIFICATION_ID, occurred_at=NOW) + ) + with pytest.raises(RatificationCannotDenyError): + decide(state=granted, command=_CMD, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + + +@pytest.mark.unit +def test_blank_reason_raises() -> None: + cmd = DenyRatification(ratification_id=RATIFICATION_ID, reason=" ") + with pytest.raises(InvalidRatificationReasonError): + decide(state=make_requested(), command=cmd, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + + +@pytest.mark.unit +def test_overlong_reason_raises() -> None: + cmd = DenyRatification(ratification_id=RATIFICATION_ID, reason="x" * (REASON_MAX_LENGTH + 1)) + with pytest.raises(InvalidRatificationReasonError): + decide(state=make_requested(), command=cmd, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + + +@pytest.mark.unit +def test_reason_trimmed_on_event() -> None: + cmd = DenyRatification(ratification_id=RATIFICATION_ID, reason=" no ") + [e] = decide(state=make_requested(), command=cmd, denied_by=OTHER_PRINCIPAL_ID, now=NOW) + assert e.reason == "no" diff --git a/apps/api/tests/unit/trust/ratification/test_grant_ratification_decider.py b/apps/api/tests/unit/trust/ratification/test_grant_ratification_decider.py new file mode 100644 index 00000000000..2ae78c1a201 --- /dev/null +++ b/apps/api/tests/unit/trust/ratification/test_grant_ratification_decider.py @@ -0,0 +1,68 @@ +"""Decider tests for the `grant_ratification` slice (Requested -> Granted). + +The centerpiece is the independence (four-eyes) invariant: the grantor must +differ from the requester, and the check is kind-blind (bare principal ids). +""" + +import pytest + +from cora.trust.aggregates.ratification import ( + RatificationCannotGrantError, + RatificationGranted, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, + evolve, +) +from cora.trust.features.grant_ratification import GrantRatification +from cora.trust.features.grant_ratification.decider import decide +from tests.unit.trust.ratification._fixtures import ( + NOW, + OTHER_PRINCIPAL_ID, + RATIFICATION_ID, + REQUESTER_ID, + make_requested, +) + +_CMD = GrantRatification(ratification_id=RATIFICATION_ID) + + +@pytest.mark.unit +def test_independent_principal_grants() -> None: + events = decide(state=make_requested(), command=_CMD, granted_by=OTHER_PRINCIPAL_ID, now=NOW) + assert len(events) == 1 + [e] = events + assert isinstance(e, RatificationGranted) + assert e.ratification_id == RATIFICATION_ID + assert e.occurred_at == NOW + + +@pytest.mark.unit +def test_requester_cannot_self_sign() -> None: + # The four-eyes invariant: the requester may not grant its own request. + with pytest.raises(RatificationRequesterCannotSelfRatifyError): + decide(state=make_requested(), command=_CMD, granted_by=REQUESTER_ID, now=NOW) + + +@pytest.mark.unit +def test_grant_is_kind_blind() -> None: + # The decider compares bare principal ids; there is no actor-kind branch. Two + # different independent principals both grant identically. + [e] = decide(state=make_requested(), command=_CMD, granted_by=OTHER_PRINCIPAL_ID, now=NOW) + assert isinstance(e, RatificationGranted) + + +@pytest.mark.unit +def test_not_found_raises() -> None: + with pytest.raises(RatificationNotFoundError): + decide(state=None, command=_CMD, granted_by=OTHER_PRINCIPAL_ID, now=NOW) + + +@pytest.mark.unit +def test_grant_from_granted_raises_cannot_grant() -> None: + granted = evolve( + make_requested(), RatificationGranted(ratification_id=RATIFICATION_ID, occurred_at=NOW) + ) + assert granted.status is RatificationStatus.GRANTED + with pytest.raises(RatificationCannotGrantError): + decide(state=granted, command=_CMD, granted_by=OTHER_PRINCIPAL_ID, now=NOW) diff --git a/apps/api/tests/unit/trust/ratification/test_ratification_evolver.py b/apps/api/tests/unit/trust/ratification/test_ratification_evolver.py new file mode 100644 index 00000000000..4efcfc9430e --- /dev/null +++ b/apps/api/tests/unit/trust/ratification/test_ratification_evolver.py @@ -0,0 +1,81 @@ +"""Evolver tests for the Ratification aggregate (3-state FSM).""" + +import pytest + +from cora.trust.aggregates.ratification import ( + Ratification, + RatificationDenied, + RatificationGranted, + RatificationRequested, + RatificationStatus, + evolve, + fold, +) +from tests.unit.trust.ratification._fixtures import ( + COMMAND_NAME, + CONSEQUENCE_CLASS, + NOW, + RATIFICATION_ID, + REQUESTER_ID, + TARGET_REF, +) + +_REQUESTED = RatificationRequested( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class=CONSEQUENCE_CLASS, + requested_by=REQUESTER_ID, + occurred_at=NOW, +) + + +@pytest.mark.unit +def test_genesis_builds_requested_state() -> None: + state = evolve(None, _REQUESTED) + assert isinstance(state, Ratification) + assert state.id == RATIFICATION_ID + assert state.target_action_id == TARGET_REF + assert state.command_name == COMMAND_NAME + assert state.consequence_class == CONSEQUENCE_CLASS + assert state.requested_by == REQUESTER_ID + assert state.status is RatificationStatus.REQUESTED + assert state.last_reason is None + + +@pytest.mark.unit +def test_granted_transitions_to_granted() -> None: + state = fold( + [_REQUESTED, RatificationGranted(ratification_id=RATIFICATION_ID, occurred_at=NOW)] + ) + assert state is not None + assert state.status is RatificationStatus.GRANTED + assert state.last_reason is None + + +@pytest.mark.unit +def test_denied_transitions_to_denied_with_reason() -> None: + state = fold( + [ + _REQUESTED, + RatificationDenied( + ratification_id=RATIFICATION_ID, reason="too risky", occurred_at=NOW + ), + ] + ) + assert state is not None + assert state.status is RatificationStatus.DENIED + assert state.last_reason == "too risky" + + +@pytest.mark.unit +def test_genesis_ignores_prior_state() -> None: + prior = evolve(None, _REQUESTED) + # A second genesis event replaces rather than merges (genesis ignores state). + rebuilt = evolve(prior, _REQUESTED) + assert rebuilt.status is RatificationStatus.REQUESTED + + +@pytest.mark.unit +def test_fold_empty_is_none() -> None: + assert fold([]) is None diff --git a/apps/api/tests/unit/trust/ratification/test_request_ratification_decider.py b/apps/api/tests/unit/trust/ratification/test_request_ratification_decider.py new file mode 100644 index 00000000000..eab195e6364 --- /dev/null +++ b/apps/api/tests/unit/trust/ratification/test_request_ratification_decider.py @@ -0,0 +1,95 @@ +"""Decider tests for the `request_ratification` slice (genesis).""" + +import pytest + +from cora.trust.aggregates.ratification import ( + CONSEQUENCE_CLASS_MAX_LENGTH, + InvalidConsequenceClassError, + RatificationAlreadyExistsError, + RatificationRequested, +) +from cora.trust.features.request_ratification import RequestRatification +from cora.trust.features.request_ratification.decider import decide +from tests.unit.trust.ratification._fixtures import ( + COMMAND_NAME, + CONSEQUENCE_CLASS, + NOW, + OTHER_PRINCIPAL_ID, + RATIFICATION_ID, + REQUESTER_ID, + TARGET_REF, + make_requested, +) + +_BASE_CMD = RequestRatification( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class=CONSEQUENCE_CLASS, +) + + +@pytest.mark.unit +def test_genesis_emits_ratification_requested() -> None: + events = decide(state=None, command=_BASE_CMD, requested_by=REQUESTER_ID, now=NOW) + assert len(events) == 1 + [e] = events + assert isinstance(e, RatificationRequested) + assert e.ratification_id == RATIFICATION_ID + assert e.target_action_id == TARGET_REF + assert e.command_name == COMMAND_NAME + assert e.consequence_class == CONSEQUENCE_CLASS + assert e.requested_by == REQUESTER_ID + assert e.occurred_at == NOW + + +@pytest.mark.unit +def test_requester_is_the_threaded_principal_not_a_command_field() -> None: + # requested_by comes from the handler-threaded principal, so the same command + # records whoever issues it (unspoofable). Two principals -> two requesters. + [e1] = decide(state=None, command=_BASE_CMD, requested_by=REQUESTER_ID, now=NOW) + [e2] = decide(state=None, command=_BASE_CMD, requested_by=OTHER_PRINCIPAL_ID, now=NOW) + assert e1.requested_by == REQUESTER_ID + assert e2.requested_by == OTHER_PRINCIPAL_ID + + +@pytest.mark.unit +def test_collision_raises_already_exists() -> None: + with pytest.raises(RatificationAlreadyExistsError): + decide(state=make_requested(), command=_BASE_CMD, requested_by=REQUESTER_ID, now=NOW) + + +@pytest.mark.unit +def test_blank_consequence_class_raises() -> None: + cmd = RequestRatification( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class=" ", + ) + with pytest.raises(InvalidConsequenceClassError): + decide(state=None, command=cmd, requested_by=REQUESTER_ID, now=NOW) + + +@pytest.mark.unit +def test_overlong_consequence_class_raises() -> None: + cmd = RequestRatification( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class="x" * (CONSEQUENCE_CLASS_MAX_LENGTH + 1), + ) + with pytest.raises(InvalidConsequenceClassError): + decide(state=None, command=cmd, requested_by=REQUESTER_ID, now=NOW) + + +@pytest.mark.unit +def test_consequence_class_trimmed_on_event() -> None: + cmd = RequestRatification( + ratification_id=RATIFICATION_ID, + target_action_id=TARGET_REF, + command_name=COMMAND_NAME, + consequence_class=" irreversible ", + ) + [e] = decide(state=None, command=cmd, requested_by=REQUESTER_ID, now=NOW) + assert e.consequence_class == "irreversible" diff --git a/apps/api/tests/unit/trust/test_deny_ratification_decider_properties.py b/apps/api/tests/unit/trust/test_deny_ratification_decider_properties.py new file mode 100644 index 00000000000..3d80d82d3af --- /dev/null +++ b/apps/api/tests/unit/trust/test_deny_ratification_decider_properties.py @@ -0,0 +1,232 @@ +"""Property-based tests for `deny_ratification.decide` (Trust BC, Ratification). + +Complements the example-based `ratification/test_deny_ratification_decider.py` +with universal claims across generated inputs. The decider is a pure +single-source FSM transition with a mandatory reason + + (state, command, denied_by, now) -> list[RatificationDenied] + +Load-bearing properties: + + - state=None always raises `RatificationNotFoundError` carrying + command.ratification_id. + - The source-state partition is total over `RatificationStatus`: only + `Requested` is deniable; every terminal status raises + `RatificationCannotDenyError` carrying the current status (the status + guard runs before the independence check), so a future status value + cannot silently fall through. + - Independence (four-eyes): on a Requested state, denied_by==requested_by + always raises `RatificationRequesterCannotSelfRatifyError`. + - Reason validation runs after the independence check: an independent + principal with a blank or over-REASON_MAX_LENGTH reason raises + `InvalidRatificationReasonError`; a valid reason emits exactly one + `RatificationDenied` (ratification_id=state.id, reason trimmed, + occurred_at=now). + - Pure: same (state, command, denied_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 + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +from cora.shared.text_bounds import REASON_MAX_LENGTH +from cora.trust.aggregates.ratification import ( + InvalidRatificationReasonError, + Ratification, + RatificationCannotDenyError, + RatificationDenied, + RatificationGranted, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, + evolve, +) +from cora.trust.features.deny_ratification import DenyRatification +from cora.trust.features.deny_ratification.decider import decide +from tests._strategies import aware_datetimes, printable_ascii_text +from tests.unit.trust.ratification._fixtures import NOW, RATIFICATION_ID, make_requested + +_REASON = printable_ascii_text(min_size=1, max_size=REASON_MAX_LENGTH) + +_DENIABLE_SOURCES = (RatificationStatus.REQUESTED,) +_TERMINAL_SOURCES = tuple(s for s in RatificationStatus if s not in frozenset(_DENIABLE_SOURCES)) + + +def _state_with(*, status: RatificationStatus, requested_by: UUID) -> Ratification: + """Build a Ratification in the requested status, seeded by requested_by.""" + requested = make_requested(requested_by=requested_by) + match status: + case RatificationStatus.REQUESTED: + return requested + case RatificationStatus.GRANTED: + return evolve( + requested, + RatificationGranted(ratification_id=RATIFICATION_ID, occurred_at=NOW), + ) + case RatificationStatus.DENIED: + return evolve( + requested, + RatificationDenied( + ratification_id=RATIFICATION_ID, reason="prior denial", occurred_at=NOW + ), + ) + + +@pytest.mark.unit +@given(ratification_id=st.uuids(), reason=_REASON, denied_by=st.uuids(), now=aware_datetimes()) +def test_deny_with_none_state_always_raises_not_found( + ratification_id: UUID, + reason: str, + denied_by: UUID, + now: datetime, +) -> None: + """Empty stream always raises RatificationNotFoundError carrying command id.""" + with pytest.raises(RatificationNotFoundError) as exc: + decide( + state=None, + command=DenyRatification(ratification_id=ratification_id, reason=reason), + denied_by=denied_by, + now=now, + ) + assert exc.value.ratification_id == ratification_id + + +@pytest.mark.unit +@given( + source=st.sampled_from(_TERMINAL_SOURCES), + requester=st.uuids(), + reason=_REASON, + denied_by=st.uuids(), + now=aware_datetimes(), +) +def test_deny_from_terminal_source_always_raises_cannot_deny( + source: RatificationStatus, + requester: UUID, + reason: str, + denied_by: UUID, + now: datetime, +) -> None: + """Any source other than Requested raises, carrying the current status.""" + state = _state_with(status=source, requested_by=requester) + with pytest.raises(RatificationCannotDenyError) as exc: + decide( + state=state, + command=DenyRatification(ratification_id=state.id, reason=reason), + denied_by=denied_by, + now=now, + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given(requester=st.uuids(), reason=_REASON, now=aware_datetimes()) +def test_deny_by_requester_always_raises_self_sign( + requester: UUID, + reason: str, + now: datetime, +) -> None: + """Four-eyes: on a Requested state, the requester may not deny its own request.""" + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + with pytest.raises(RatificationRequesterCannotSelfRatifyError) as exc: + decide( + state=state, + command=DenyRatification(ratification_id=state.id, reason=reason), + denied_by=requester, + now=now, + ) + assert exc.value.principal_id == requester + + +@pytest.mark.unit +@given( + requester=st.uuids(), + denied_by=st.uuids(), + blank=st.text(alphabet=" \t\n", max_size=8), + now=aware_datetimes(), +) +def test_deny_blank_reason_always_raises_invalid_reason( + requester: UUID, + denied_by: UUID, + blank: str, + now: datetime, +) -> None: + """An independent principal with a whitespace-only reason raises.""" + assume(requester != denied_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + with pytest.raises(InvalidRatificationReasonError): + decide( + state=state, + command=DenyRatification(ratification_id=state.id, reason=blank), + denied_by=denied_by, + now=now, + ) + + +@pytest.mark.unit +@given( + requester=st.uuids(), + denied_by=st.uuids(), + overlong=st.integers(min_value=REASON_MAX_LENGTH + 1, max_value=REASON_MAX_LENGTH + 200), + now=aware_datetimes(), +) +def test_deny_overlong_reason_always_raises_invalid_reason( + requester: UUID, + denied_by: UUID, + overlong: int, + now: datetime, +) -> None: + """A reason longer than the max after trim raises.""" + assume(requester != denied_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + with pytest.raises(InvalidRatificationReasonError): + decide( + state=state, + command=DenyRatification(ratification_id=state.id, reason="x" * overlong), + denied_by=denied_by, + now=now, + ) + + +@pytest.mark.unit +@given(requester=st.uuids(), denied_by=st.uuids(), reason=_REASON, now=aware_datetimes()) +def test_deny_by_independent_principal_emits_single_event_with_trimmed_reason( + requester: UUID, + denied_by: UUID, + reason: str, + now: datetime, +) -> None: + """An independent principal with a valid reason emits one RatificationDenied (trimmed).""" + assume(requester != denied_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + events = decide( + state=state, + command=DenyRatification(ratification_id=state.id, reason=f" {reason} "), + denied_by=denied_by, + now=now, + ) + assert events == [RatificationDenied(ratification_id=state.id, reason=reason, occurred_at=now)] + + +@pytest.mark.unit +@given(requester=st.uuids(), denied_by=st.uuids(), reason=_REASON, now=aware_datetimes()) +def test_deny_is_pure_same_input_same_output( + requester: UUID, + denied_by: UUID, + reason: str, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + assume(requester != denied_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + command = DenyRatification(ratification_id=state.id, reason=reason) + first = decide(state=state, command=command, denied_by=denied_by, now=now) + second = decide(state=state, command=command, denied_by=denied_by, now=now) + assert first == second diff --git a/apps/api/tests/unit/trust/test_deny_ratification_handler.py b/apps/api/tests/unit/trust/test_deny_ratification_handler.py new file mode 100644 index 00000000000..afc698d539f --- /dev/null +++ b/apps/api/tests/unit/trust/test_deny_ratification_handler.py @@ -0,0 +1,139 @@ +"""Application-handler unit tests for the `deny_ratification` slice (transition). + +Pure-decider behavior is exercised in `ratification/test_deny_ratification_decider.py` +and the sibling PBT; here we pin the handler-level concerns: state load + event +append (with reason), the authz-deny path, and the independence (four-eyes) +invariant threaded end-to-end (the envelope principal becomes `denied_by`, so a +requester denying its own request raises). +""" + +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.ratification import ( + RatificationNotFoundError, + RatificationRequested, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.features import deny_ratification +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC) +_RATIFICATION_ID = UUID("01910000-0000-7000-8000-00000000f001") +_TARGET_REF = UUID("01910000-0000-7000-8000-00000000f002") +_REQUESTER_ID = UUID("01910000-0000-7000-8000-00000000f003") +_OTHER_PRINCIPAL_ID = UUID("01910000-0000-7000-8000-00000000f004") +_CORRELATION_ID = UUID("01910000-0000-7000-8000-0000000000aa") +_GENESIS_EVENT_ID = UUID("01910000-0000-7000-8000-00000000f101") +_TRANSITION_EVENT_ID = UUID("01910000-0000-7000-8000-00000000f102") +_COMMAND_NAME = "AbortRun" +_CONSEQUENCE_CLASS = "first_of_kind" +_REASON = "unsafe first-of-kind action" + + +async def _seed_requested(store: InMemoryEventStore, *, requested_by: UUID = _REQUESTER_ID) -> None: + """Append a RatificationRequested so the Ratification is in Requested status.""" + event = RatificationRequested( + ratification_id=_RATIFICATION_ID, + target_action_id=_TARGET_REF, + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=requested_by, + occurred_at=_NOW, + ) + await store.append( + stream_type="Ratification", + stream_id=_RATIFICATION_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=_GENESIS_EVENT_ID, + command_name="RequestRatification", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=requested_by, + ) + ], + ) + + +@pytest.mark.unit +async def test_deny_ratification_handler_appends_denied_by_independent_principal() -> None: + store = InMemoryEventStore() + await _seed_requested(store) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = deny_ratification.bind(deps) + + returned = await handler( + deny_ratification.DenyRatification(ratification_id=_RATIFICATION_ID, reason=_REASON), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned is None + + events, _ = await store.load("Ratification", _RATIFICATION_ID) + assert events[-1].event_type == "RatificationDenied" + assert events[-1].payload["reason"] == _REASON + folded = fold([from_stored(s) for s in events]) + assert folded is not None + assert folded.status == RatificationStatus.DENIED + assert folded.last_reason == _REASON + + +@pytest.mark.unit +async def test_deny_ratification_handler_raises_self_sign_when_requester_denies() -> None: + """Independence end-to-end: the envelope principal becomes denied_by, so a + requester denying its own request raises RatificationRequesterCannotSelfRatifyError.""" + store = InMemoryEventStore() + await _seed_requested(store, requested_by=_REQUESTER_ID) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = deny_ratification.bind(deps) + + with pytest.raises(RatificationRequesterCannotSelfRatifyError): + await handler( + deny_ratification.DenyRatification(ratification_id=_RATIFICATION_ID, reason=_REASON), + principal_id=_REQUESTER_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_deny_ratification_handler_raises_not_found_when_absent() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = deny_ratification.bind(deps) + + with pytest.raises(RatificationNotFoundError): + await handler( + deny_ratification.DenyRatification(ratification_id=_RATIFICATION_ID, reason=_REASON), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_deny_ratification_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + await _seed_requested(store) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store, deny=True) + handler = deny_ratification.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + deny_ratification.DenyRatification(ratification_id=_RATIFICATION_ID, reason=_REASON), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/trust/test_grant_ratification_decider_properties.py b/apps/api/tests/unit/trust/test_grant_ratification_decider_properties.py new file mode 100644 index 00000000000..cb49d823c3f --- /dev/null +++ b/apps/api/tests/unit/trust/test_grant_ratification_decider_properties.py @@ -0,0 +1,169 @@ +"""Property-based tests for `grant_ratification.decide` (Trust BC, Ratification). + +Complements the example-based `ratification/test_grant_ratification_decider.py` +with universal claims across generated inputs. The decider is a pure +single-source FSM transition + + (state, command, granted_by, now) -> list[RatificationGranted] + +Load-bearing properties: + + - state=None always raises `RatificationNotFoundError` carrying + command.ratification_id. + - The source-state partition is total over `RatificationStatus`: only + `Requested` is grantable; every terminal status raises + `RatificationCannotGrantError` carrying the current status (the status + guard runs before the independence check), so a future status value + cannot silently fall through. + - Independence (four-eyes): on a Requested state, granted_by==requested_by + always raises `RatificationRequesterCannotSelfRatifyError`; an independent + principal emits exactly one `RatificationGranted` (ratification_id=state.id, + occurred_at=now). This is the load-bearing invariant the gate provides. + - Pure: same (state, command, granted_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 + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +from cora.trust.aggregates.ratification import ( + Ratification, + RatificationCannotGrantError, + RatificationDenied, + RatificationGranted, + RatificationNotFoundError, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, + evolve, +) +from cora.trust.features.grant_ratification import GrantRatification +from cora.trust.features.grant_ratification.decider import decide +from tests._strategies import aware_datetimes +from tests.unit.trust.ratification._fixtures import NOW, RATIFICATION_ID, make_requested + +_GRANTABLE_SOURCES = (RatificationStatus.REQUESTED,) +_TERMINAL_SOURCES = tuple(s for s in RatificationStatus if s not in frozenset(_GRANTABLE_SOURCES)) + + +def _state_with(*, status: RatificationStatus, requested_by: UUID) -> Ratification: + """Build a Ratification in the requested status, seeded by requested_by.""" + requested = make_requested(requested_by=requested_by) + match status: + case RatificationStatus.REQUESTED: + return requested + case RatificationStatus.GRANTED: + return evolve( + requested, + RatificationGranted(ratification_id=RATIFICATION_ID, occurred_at=NOW), + ) + case RatificationStatus.DENIED: + return evolve( + requested, + RatificationDenied( + ratification_id=RATIFICATION_ID, reason="prior denial", occurred_at=NOW + ), + ) + + +@pytest.mark.unit +@given(ratification_id=st.uuids(), granted_by=st.uuids(), now=aware_datetimes()) +def test_grant_with_none_state_always_raises_not_found( + ratification_id: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Empty stream always raises RatificationNotFoundError carrying command id.""" + with pytest.raises(RatificationNotFoundError) as exc: + decide( + state=None, + command=GrantRatification(ratification_id=ratification_id), + granted_by=granted_by, + now=now, + ) + assert exc.value.ratification_id == ratification_id + + +@pytest.mark.unit +@given( + source=st.sampled_from(_TERMINAL_SOURCES), + requester=st.uuids(), + granted_by=st.uuids(), + now=aware_datetimes(), +) +def test_grant_from_terminal_source_always_raises_cannot_grant( + source: RatificationStatus, + requester: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Any source other than Requested raises, carrying the current status.""" + state = _state_with(status=source, requested_by=requester) + with pytest.raises(RatificationCannotGrantError) as exc: + decide( + state=state, + command=GrantRatification(ratification_id=state.id), + granted_by=granted_by, + now=now, + ) + assert exc.value.current_status is source + + +@pytest.mark.unit +@given(requester=st.uuids(), now=aware_datetimes()) +def test_grant_by_requester_always_raises_self_sign( + requester: UUID, + now: datetime, +) -> None: + """Four-eyes: on a Requested state, the requester may not grant its own request.""" + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + with pytest.raises(RatificationRequesterCannotSelfRatifyError) as exc: + decide( + state=state, + command=GrantRatification(ratification_id=state.id), + granted_by=requester, + now=now, + ) + assert exc.value.principal_id == requester + + +@pytest.mark.unit +@given(requester=st.uuids(), granted_by=st.uuids(), now=aware_datetimes()) +def test_grant_by_independent_principal_emits_single_event( + requester: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """An independent principal on a Requested state emits one RatificationGranted.""" + assume(requester != granted_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + events = decide( + state=state, + command=GrantRatification(ratification_id=state.id), + granted_by=granted_by, + now=now, + ) + assert events == [RatificationGranted(ratification_id=state.id, occurred_at=now)] + + +@pytest.mark.unit +@given(requester=st.uuids(), granted_by=st.uuids(), now=aware_datetimes()) +def test_grant_is_pure_same_input_same_output( + requester: UUID, + granted_by: UUID, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + assume(requester != granted_by) + state = _state_with(status=RatificationStatus.REQUESTED, requested_by=requester) + command = GrantRatification(ratification_id=state.id) + first = decide(state=state, command=command, granted_by=granted_by, now=now) + second = decide(state=state, command=command, granted_by=granted_by, now=now) + assert first == second diff --git a/apps/api/tests/unit/trust/test_grant_ratification_handler.py b/apps/api/tests/unit/trust/test_grant_ratification_handler.py new file mode 100644 index 00000000000..e3753b216d7 --- /dev/null +++ b/apps/api/tests/unit/trust/test_grant_ratification_handler.py @@ -0,0 +1,136 @@ +"""Application-handler unit tests for the `grant_ratification` slice (transition). + +Pure-decider behavior is exercised in `ratification/test_grant_ratification_decider.py` +and the sibling PBT; here we pin the handler-level concerns: state load + event +append, the authz-deny path, and the independence (four-eyes) invariant threaded +end-to-end (the envelope principal becomes `granted_by`, so a requester granting +its own request raises). +""" + +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.ratification import ( + RatificationNotFoundError, + RatificationRequested, + RatificationRequesterCannotSelfRatifyError, + RatificationStatus, + event_type_name, + fold, + from_stored, + to_payload, +) +from cora.trust.features import grant_ratification +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC) +_RATIFICATION_ID = UUID("01910000-0000-7000-8000-00000000f001") +_TARGET_REF = UUID("01910000-0000-7000-8000-00000000f002") +_REQUESTER_ID = UUID("01910000-0000-7000-8000-00000000f003") +_OTHER_PRINCIPAL_ID = UUID("01910000-0000-7000-8000-00000000f004") +_CORRELATION_ID = UUID("01910000-0000-7000-8000-0000000000aa") +_GENESIS_EVENT_ID = UUID("01910000-0000-7000-8000-00000000f101") +_TRANSITION_EVENT_ID = UUID("01910000-0000-7000-8000-00000000f102") +_COMMAND_NAME = "AbortRun" +_CONSEQUENCE_CLASS = "first_of_kind" + + +async def _seed_requested(store: InMemoryEventStore, *, requested_by: UUID = _REQUESTER_ID) -> None: + """Append a RatificationRequested so the Ratification is in Requested status.""" + event = RatificationRequested( + ratification_id=_RATIFICATION_ID, + target_action_id=_TARGET_REF, + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=requested_by, + occurred_at=_NOW, + ) + await store.append( + stream_type="Ratification", + stream_id=_RATIFICATION_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=_GENESIS_EVENT_ID, + command_name="RequestRatification", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=requested_by, + ) + ], + ) + + +@pytest.mark.unit +async def test_grant_ratification_handler_appends_granted_by_independent_principal() -> None: + store = InMemoryEventStore() + await _seed_requested(store) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = grant_ratification.bind(deps) + + returned = await handler( + grant_ratification.GrantRatification(ratification_id=_RATIFICATION_ID), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned is None + + events, _ = await store.load("Ratification", _RATIFICATION_ID) + assert events[-1].event_type == "RatificationGranted" + folded = fold([from_stored(s) for s in events]) + assert folded is not None + assert folded.status == RatificationStatus.GRANTED + + +@pytest.mark.unit +async def test_grant_ratification_handler_raises_self_sign_when_requester_grants() -> None: + """Independence end-to-end: the envelope principal becomes granted_by, so a + requester granting its own request raises RatificationRequesterCannotSelfRatifyError.""" + store = InMemoryEventStore() + await _seed_requested(store, requested_by=_REQUESTER_ID) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = grant_ratification.bind(deps) + + with pytest.raises(RatificationRequesterCannotSelfRatifyError): + await handler( + grant_ratification.GrantRatification(ratification_id=_RATIFICATION_ID), + principal_id=_REQUESTER_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_grant_ratification_handler_raises_not_found_when_absent() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store) + handler = grant_ratification.bind(deps) + + with pytest.raises(RatificationNotFoundError): + await handler( + grant_ratification.GrantRatification(ratification_id=_RATIFICATION_ID), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.unit +async def test_grant_ratification_handler_raises_unauthorized_on_deny() -> None: + store = InMemoryEventStore() + await _seed_requested(store) + deps = build_deps(ids=[_TRANSITION_EVENT_ID], now=_NOW, event_store=store, deny=True) + handler = grant_ratification.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + grant_ratification.GrantRatification(ratification_id=_RATIFICATION_ID), + principal_id=_OTHER_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/apps/api/tests/unit/trust/test_request_ratification_decider_properties.py b/apps/api/tests/unit/trust/test_request_ratification_decider_properties.py new file mode 100644 index 00000000000..f0951f37be1 --- /dev/null +++ b/apps/api/tests/unit/trust/test_request_ratification_decider_properties.py @@ -0,0 +1,263 @@ +"""Property-based tests for `request_ratification.decide` (Trust BC, Ratification). + +Complements the example-based `ratification/test_request_ratification_decider.py` +with universal claims across generated inputs. The decider is a pure gated +genesis + + (state, command, requested_by, now) -> list[RatificationRequested] + +with a caller-supplied `command.ratification_id` (no injected `new_id`): a +subscriber mints deterministic UUIDs, so the genesis id rides on the command. +`requested_by` is threaded in by the handler from the envelope principal. + +Load-bearing properties: + + - state=None + valid input emits exactly one RatificationRequested threading + the command fields through, with requested_by=the threaded principal and + occurred_at=now. + - Any non-None state always raises `RatificationAlreadyExistsError` carrying + command.ratification_id (idempotency-as-error), regardless of command. + - A blank or over-100 consequence_class raises `InvalidConsequenceClassError`. + - consequence_class is trimmed on the emitted event. + - Pure: same (state, command, requested_by, now) returns equal events. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +if TYPE_CHECKING: + from datetime import datetime + from uuid import UUID + +from cora.trust.aggregates.ratification import ( + CONSEQUENCE_CLASS_MAX_LENGTH, + InvalidConsequenceClassError, + RatificationAlreadyExistsError, + RatificationRequested, +) +from cora.trust.features.request_ratification import RequestRatification +from cora.trust.features.request_ratification.decider import decide +from tests._strategies import aware_datetimes, printable_ascii_text +from tests.unit.trust.ratification._fixtures import make_requested + +_COMMAND_NAME = printable_ascii_text(min_size=1, max_size=64) +_CONSEQUENCE_CLASS = printable_ascii_text(min_size=1, max_size=CONSEQUENCE_CLASS_MAX_LENGTH) + + +def _command( + *, + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + consequence_class: str, +) -> RequestRatification: + return RequestRatification( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + ) + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_happy_path_emits_single_requested_with_threaded_fields( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + consequence_class: str, + requested_by: UUID, + now: datetime, +) -> None: + """state=None emits one RatificationRequested threading command fields + requested_by.""" + events = decide( + state=None, + command=_command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + ), + requested_by=requested_by, + now=now, + ) + assert events == [ + RatificationRequested( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + requested_by=requested_by, + occurred_at=now, + ) + ] + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_on_existing_state_always_raises_already_exists( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + consequence_class: str, + requested_by: UUID, + now: datetime, +) -> None: + """Any non-None state raises RatificationAlreadyExistsError carrying command id.""" + with pytest.raises(RatificationAlreadyExistsError) as exc: + decide( + state=make_requested(), + command=_command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + ), + requested_by=requested_by, + now=now, + ) + assert exc.value.ratification_id == ratification_id + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + blank=st.text(alphabet=" \t\n", max_size=8), + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_blank_consequence_class_always_raises( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + blank: str, + requested_by: UUID, + now: datetime, +) -> None: + """A whitespace-only consequence_class raises InvalidConsequenceClassError.""" + with pytest.raises(InvalidConsequenceClassError): + decide( + state=None, + command=_command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=blank, + ), + requested_by=requested_by, + now=now, + ) + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + overlong=st.integers(min_value=CONSEQUENCE_CLASS_MAX_LENGTH + 1, max_value=500), + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_overlong_consequence_class_always_raises( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + overlong: int, + requested_by: UUID, + now: datetime, +) -> None: + """A consequence_class longer than the max after trim raises.""" + with pytest.raises(InvalidConsequenceClassError): + decide( + state=None, + command=_command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class="x" * overlong, + ), + requested_by=requested_by, + now=now, + ) + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_trims_consequence_class_on_event( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + consequence_class: str, + requested_by: UUID, + now: datetime, +) -> None: + """Surrounding whitespace on consequence_class is stripped on the event.""" + events = decide( + state=None, + command=_command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=f" {consequence_class} ", + ), + requested_by=requested_by, + now=now, + ) + assert events[0].consequence_class == consequence_class + + +@pytest.mark.unit +@given( + ratification_id=st.uuids(), + target_action_id=st.uuids(), + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + requested_by=st.uuids(), + now=aware_datetimes(), +) +def test_request_is_pure_same_input_same_output( + ratification_id: UUID, + target_action_id: UUID, + command_name: str, + consequence_class: str, + requested_by: UUID, + now: datetime, +) -> None: + """Two calls with identical args return equal events (no clock leakage).""" + command = _command( + ratification_id=ratification_id, + target_action_id=target_action_id, + command_name=command_name, + consequence_class=consequence_class, + ) + first = decide(state=None, command=command, requested_by=requested_by, now=now) + second = decide(state=None, command=command, requested_by=requested_by, now=now) + assert first == second diff --git a/apps/api/tests/unit/trust/test_request_ratification_handler.py b/apps/api/tests/unit/trust/test_request_ratification_handler.py new file mode 100644 index 00000000000..8a14970a12f --- /dev/null +++ b/apps/api/tests/unit/trust/test_request_ratification_handler.py @@ -0,0 +1,92 @@ +"""Application-handler unit tests for the `request_ratification` slice (genesis). + +Pure-decider behavior is exercised in `ratification/test_request_ratification_decider.py` +and the sibling PBT; here we pin the handler-level concerns: caller-supplied-id +return, event-store append + envelope shape, and the authz-deny path. +""" + +from datetime import UTC, datetime +from uuid import UUID + +import pytest + +from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore +from cora.trust import UnauthorizedError +from cora.trust.aggregates.ratification import ( + RatificationStatus, + fold, + from_stored, +) +from cora.trust.features import request_ratification +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 7, 5, 12, 0, 0, tzinfo=UTC) +_RATIFICATION_ID = UUID("01910000-0000-7000-8000-00000000f001") +_TARGET_REF = UUID("01910000-0000-7000-8000-00000000f002") +_PRINCIPAL_ID = UUID("01910000-0000-7000-8000-000000000099") +_CORRELATION_ID = UUID("01910000-0000-7000-8000-0000000000aa") +_GENESIS_EVENT_ID = UUID("01910000-0000-7000-8000-00000000f101") +_COMMAND_NAME = "AbortRun" +_CONSEQUENCE_CLASS = "first_of_kind" + + +def _request_command() -> request_ratification.RequestRatification: + return request_ratification.RequestRatification( + ratification_id=_RATIFICATION_ID, + target_action_id=_TARGET_REF, + command_name=_COMMAND_NAME, + consequence_class=_CONSEQUENCE_CLASS, + ) + + +@pytest.mark.unit +async def test_request_ratification_handler_returns_caller_supplied_id() -> None: + """Genesis: caller supplies ratification_id; handler returns it unchanged.""" + deps = build_deps(ids=[_GENESIS_EVENT_ID], now=_NOW) + handler = request_ratification.bind(deps) + + returned = await handler( + _request_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + assert returned == _RATIFICATION_ID + + +@pytest.mark.unit +async def test_request_ratification_handler_appends_ratification_requested_to_store() -> None: + store = InMemoryEventStore() + deps = build_deps(ids=[_GENESIS_EVENT_ID], now=_NOW, event_store=store) + handler = request_ratification.bind(deps) + + await handler( + _request_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) + + events, version = await store.load("Ratification", _RATIFICATION_ID) + assert version == 1 + assert len(events) == 1 + stored = events[0] + assert stored.event_type == "RatificationRequested" + assert stored.correlation_id == _CORRELATION_ID + assert stored.principal_id == _PRINCIPAL_ID + assert stored.metadata == {"command": "RequestRatification"} + folded = fold([from_stored(s) for s in events]) + assert folded is not None + assert folded.status == RatificationStatus.REQUESTED + assert folded.requested_by == _PRINCIPAL_ID + + +@pytest.mark.unit +async def test_request_ratification_handler_raises_unauthorized_on_deny() -> None: + deps = build_deps(ids=[_GENESIS_EVENT_ID], now=_NOW, deny=True) + handler = request_ratification.bind(deps) + + with pytest.raises(UnauthorizedError): + await handler( + _request_command(), + principal_id=_PRINCIPAL_ID, + correlation_id=_CORRELATION_ID, + ) diff --git a/docs/architecture/model.md b/docs/architecture/model.md index e83836b7430..07cb0e182af 100644 --- a/docs/architecture/model.md +++ b/docs/architecture/model.md @@ -14,7 +14,7 @@ Status legend: **Active** = aggregate is shipping and listed under [Modules](mod _Generated from the code at build time._ -Seventeen BCs and forty aggregates ship today; two more BCs are reserved with single planned aggregates. Each group is named for the operational role its BCs play in running an experiment: **Foundation** owns the shared facts every other group refers to (identity, equipment); **Procedure** is the planned-work recipe ladder and its execution instances; **Resource** is the continuous and consumable substrate work runs on, plus its upkeep; **Authority** is where CORA itself decides or grants permission to act, intra- and cross-facility; **Assurance** is observed state and recorded evidence that conditions work but that CORA does not decide; **Governance** is the audit and configuration of consequential choices; and **Outcome** is what the experiment studies and produces. The ISA and peer standards that shaped several BCs (ISA-88, ISA-106, ISA-99) are documentation provenance recorded in the [glossary](../reference/glossary.md), not the partition. To place a new BC, take the first role above that fits; if none fits, the BC charter is mis-scoped rather than the scheme needing an eighth group, and a new group is added only when three homeless BCs share a genuinely new role. +Seventeen BCs and forty-one aggregates ship today; two more BCs are reserved with single planned aggregates. Each group is named for the operational role its BCs play in running an experiment: **Foundation** owns the shared facts every other group refers to (identity, equipment); **Procedure** is the planned-work recipe ladder and its execution instances; **Resource** is the continuous and consumable substrate work runs on, plus its upkeep; **Authority** is where CORA itself decides or grants permission to act, intra- and cross-facility; **Assurance** is observed state and recorded evidence that conditions work but that CORA does not decide; **Governance** is the audit and configuration of consequential choices; and **Outcome** is what the experiment studies and produces. The ISA and peer standards that shaped several BCs (ISA-88, ISA-106, ISA-99) are documentation provenance recorded in the [glossary](../reference/glossary.md), not the partition. To place a new BC, take the first role above that fits; if none fits, the BC charter is mis-scoped rather than the scheme needing an eighth group, and a new group is added only when three homeless BCs share a genuinely new role. ## Aggregates From aec233bb4e22558433bb30c26c4513ca24b5e4f1 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Tue, 7 Jul 2026 12:38:50 +0300 Subject: [PATCH 2/2] Wire the consequence gate: co-signature-gated stop_run (Gate IV) Make the dormant Ratification aggregate a live gate. A consequence-classed command (StopRun in v1) is admitted only when a GRANTED Ratification covers (run_id, command_name); absent that, the gate refuses and the run is parked in the shared reversible hold pending a second, independent principal's co-sign. This is the paper's cleanest-survivor gate (Four Gates, One Log): a new gate that costs a fold + a decider arm + reuse of the existing shared hold, not a subsystem. Flow (Option B, subscriber-driven discharge, locked at build): stop_run's decider raises RunRequiresRatificationError (HTTP 409, the sibling Run pre-flight family) when the command is in the COMMANDS_REQUIRING_RATIFICATION allowlist and the ConsequenceLookup reports no Granted coverage; the handler pre-loads coverage (I/O in the handler, pure decider), mirroring start_run's clearance pre-load. Operator then request_ratification; the RatificationHoldSubscriber parks the run (RunHeld); a second principal grant_ratification (independence enforced: granter != requester); the RatificationReleaseSubscriber resumes it; the re-issued stop_run is now covered and admitted. Why subscribers not a handler co-write: cora.agent may depend on cora.run.aggregates + cora.trust.aggregates but NOT cora.run.features (tach boundary). The enforcer subscribers do the hold/resume via Pattern C (load, guard, authorize, append), reusing the shape the kill-switch's authority_revocation_holder shipped, keeping marginal cost constant. The hold discharges into the SAME RunHeld the kill-switch uses (the "one shared hold" thesis, instanced directly). One pinned RatificationEnforcer agent (fac0 block) owns both subscribers, split into ratification_hold.py + ratification_release.py (one factory per module, per the subscriber-completeness convention) over a shared _ratification_shared.py helper. No Decision records (the Ratification events are the provenance). New tach edge cora.agent -> cora.trust.aggregates (release-only load_ratification to resolve ratification_id -> target run). Read side: proj_trust_ratification_coverage projection (folds Requested/Granted/ Denied by ratification_id) + ConsequenceLookup port (Trust ships PostgresConsequenceLookup; test default AlwaysRatifiedConsequenceLookup keeps existing stop_run tests green, NeverRatified exercises the refuse path). Gate review (4 agents: architecture / test-coverage / cross-BC / saga-correctness specialist): 0 P0, 2 correctness P1 + several test P1s, all addressed. - P1 (R4+R1, flagged twice): the release resumed ANY Held run, so a stray Granted/Denied could defeat the kill-switch or an operator hold. Fixed with a correlation guard (last_hold_placed_by_enforcer): the release folds the run's most-recent RunHeld and resumes ONLY when that hold's envelope principal_id is this enforcer; otherwise it stands down. No RunHeld schema change (envelope principal already records who held); a park-marker field is the rule-of-three refactor. - P1 (R4+R2): a Denied ratification left the run Held forever. Fixed: the release subscribes to RatificationDenied too and auto-resumes (same correlation guard), so the reversible hold stays reversible; coverage is never Granted so a re-issued stop still refuses. - Test P1s: foreign-hold-survives, denied-resumes, coverage-projection replay-idempotency, adapter Granted-only negatives (Requested/Denied do not cover), a total-gate property (uncovered stop always refuses for any state/reason), enforcer registration pinned on-by-default. - P2s: explicit bool() on the adapter fetchval; 422->409 doc correction; permanent-coverage noted. Watch (documented, deferred): coverage never expires; gate precedes the None-state check (409 not 404) per the admission-outer lock. Verified: pyright + ruff + tach clean, 43 consequence-gate tests, 30160 unit + architecture tests green. Co-Authored-By: Claude --- apps/api/src/cora/agent/__init__.py | 6 + apps/api/src/cora/agent/_subscribers.py | 22 ++ .../cora/agent/seed_ratification_enforcer.py | 114 ++++++ .../agent/subscribers/_ratification_shared.py | 67 ++++ .../agent/subscribers/ratification_hold.py | 136 +++++++ .../agent/subscribers/ratification_release.py | 159 ++++++++ apps/api/src/cora/api/main.py | 7 + apps/api/src/cora/infrastructure/deps.py | 41 +++ apps/api/src/cora/infrastructure/kernel.py | 13 + .../src/cora/infrastructure/ports/__init__.py | 8 + .../ports/consequence_lookup.py | 101 ++++++ .../src/cora/run/aggregates/run/__init__.py | 2 + apps/api/src/cora/run/aggregates/run/state.py | 26 ++ .../src/cora/run/features/stop_run/decider.py | 30 +- .../src/cora/run/features/stop_run/handler.py | 124 ++++++- apps/api/src/cora/run/routes.py | 3 + apps/api/src/cora/shared/consequence.py | 39 ++ apps/api/src/cora/trust/_projections.py | 2 + apps/api/src/cora/trust/adapters/__init__.py | 10 + .../adapters/postgres_consequence_lookup.py | 53 +++ .../src/cora/trust/projections/__init__.py | 2 + .../projections/ratification_coverage.py | 81 +++++ apps/api/tach.toml | 6 + apps/api/tests/integration/_helpers.py | 3 + .../test_consequence_gate_postgres.py | 341 ++++++++++++++++++ .../test_agent_subscribers_registration.py | 15 + .../test_ratification_enforcer_subscriber.py | 326 +++++++++++++++++ .../run/test_stop_run_consequence_gate.py | 99 +++++ .../tests/unit/run/test_stop_run_decider.py | 14 +- .../run/test_stop_run_decider_properties.py | 14 +- .../test_ratification_coverage_projection.py | 104 ++++++ ..._init_proj_trust_ratification_coverage.sql | 42 +++ infra/atlas/migrations/atlas.sum | 3 +- 33 files changed, 1991 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/cora/agent/seed_ratification_enforcer.py create mode 100644 apps/api/src/cora/agent/subscribers/_ratification_shared.py create mode 100644 apps/api/src/cora/agent/subscribers/ratification_hold.py create mode 100644 apps/api/src/cora/agent/subscribers/ratification_release.py create mode 100644 apps/api/src/cora/infrastructure/ports/consequence_lookup.py create mode 100644 apps/api/src/cora/shared/consequence.py create mode 100644 apps/api/src/cora/trust/adapters/__init__.py create mode 100644 apps/api/src/cora/trust/adapters/postgres_consequence_lookup.py create mode 100644 apps/api/src/cora/trust/projections/ratification_coverage.py create mode 100644 apps/api/tests/integration/test_consequence_gate_postgres.py create mode 100644 apps/api/tests/unit/agent/test_ratification_enforcer_subscriber.py create mode 100644 apps/api/tests/unit/run/test_stop_run_consequence_gate.py create mode 100644 apps/api/tests/unit/trust/test_ratification_coverage_projection.py create mode 100644 infra/atlas/migrations/20260706000000_init_proj_trust_ratification_coverage.sql diff --git a/apps/api/src/cora/agent/__init__.py b/apps/api/src/cora/agent/__init__.py index cad11820c5e..d6cb869fea7 100644 --- a/apps/api/src/cora/agent/__init__.py +++ b/apps/api/src/cora/agent/__init__.py @@ -71,6 +71,10 @@ PROCEDURE_WATCHER_AGENT_ID, seed_procedure_watcher_agent, ) +from cora.agent.seed_ratification_enforcer import ( + RATIFICATION_ENFORCER_AGENT_ID, + seed_ratification_enforcer_agent, +) from cora.agent.seed_run_initiator import ( RUN_INITIATOR_AGENT_ID, seed_run_initiator_agent, @@ -91,6 +95,7 @@ "CLEARANCE_WATCHER_AGENT_ID", "EXPERIMENT_STEERER_AGENT_ID", "PROCEDURE_WATCHER_AGENT_ID", + "RATIFICATION_ENFORCER_AGENT_ID", "RUN_INITIATOR_AGENT_ID", "RUN_SUPERVISOR_AGENT_ID", "AgentHandlers", @@ -114,6 +119,7 @@ "seed_clearance_watcher_agent", "seed_experiment_steerer_agent", "seed_procedure_watcher_agent", + "seed_ratification_enforcer_agent", "seed_run_debriefer_agent", "seed_run_initiator_agent", "seed_run_supervisor_agent", diff --git a/apps/api/src/cora/agent/_subscribers.py b/apps/api/src/cora/agent/_subscribers.py index 16235f2d1d8..61ce933b687 100644 --- a/apps/api/src/cora/agent/_subscribers.py +++ b/apps/api/src/cora/agent/_subscribers.py @@ -70,6 +70,8 @@ ) 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.ratification_hold import make_ratification_hold_subscriber +from cora.agent.subscribers.ratification_release import make_ratification_release_subscriber from cora.agent.subscribers.run_debriefer import make_run_debriefer_subscriber from cora.infrastructure.logging import get_logger @@ -96,6 +98,26 @@ def register_agent_subscribers(registry: ProjectionRegistry, deps: Kernel) -> No subscribed_event_types=sorted(holder.subscribed_event_types), ) + # RatificationEnforcer (consequence gate, Gate IV): a DETERMINISTIC subscriber + # pair, registered UNCONDITIONALLY. The hold reacts to RatificationRequested + # (park the run pending co-sign); the release reacts to RatificationGranted + # (resume it). Both discharge into the same shared RunHeld/RunResumed hold the + # kill-switch uses; no LLM, so no ReactionWorker pool split needed. + ratification_hold = make_ratification_hold_subscriber(deps) + registry.register(ratification_hold) + _log.info( + "agent_subscriber.registered", + subscriber=ratification_hold.name, + subscribed_event_types=sorted(ratification_hold.subscribed_event_types), + ) + ratification_release = make_ratification_release_subscriber(deps) + registry.register(ratification_release) + _log.info( + "agent_subscriber.registered", + subscriber=ratification_release.name, + subscribed_event_types=sorted(ratification_release.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 a # projection-worker Reaction; its gate is a fast deterministic check (no diff --git a/apps/api/src/cora/agent/seed_ratification_enforcer.py b/apps/api/src/cora/agent/seed_ratification_enforcer.py new file mode 100644 index 00000000000..4c48eee0f0e --- /dev/null +++ b/apps/api/src/cora/agent/seed_ratification_enforcer.py @@ -0,0 +1,114 @@ +"""Bootstrap-time seed for the RatificationEnforcer Agent (consequence gate, Gate IV). + +The RatificationEnforcer is a DETERMINISTIC (non-LLM) subscriber pair that +discharges the consequence gate into the shared hold: + - on `RatificationRequested`, it HOLDS the target run (appends RunHeld via + Pattern C, guarding Running in-process, NOT calling the hold_run slice, which + is off-limits across the tach BC boundary); + - on `RatificationGranted`, it RESUMES the held run (appends RunResumed). + +It needs an Agent record (and its co-registered Actor) at the pinned +`RATIFICATION_ENFORCER_AGENT_ID` so it can hold/resume Runs as an agent-kind +principal and be authz-gated + operator-deactivated like any other agent. + +Unlike the kill-switch holder, the enforcer records NO Decision: the Ratification +events themselves (Requested / Granted / Denied) are the provenance. + +Mirrors `cora.agent.seed_authority_revocation_holder` except for the per-agent +constants below; the shared scaffolding lives in `cora.agent._agent_seed`. + +Per the consequence-gate design (Gate IV, the T-ASE resource-accountability +paper's four-gates plan): + - Pinned UUID in the deployment-controlled `fac0` range (a hex-valid nod to the + rati-FIC-ation mnemonic), distinct from every other agent block; deployment- + stable forever. + - DETERMINISTIC agent (rule-based, NOT LLM): no prompt template + (`prompt_template_id=None`) and a sentinel `ModelRef` + (`provider="deterministic"`), never used to build an LLM. Same Agent-shape + watch the other rule-agents carry. + - Authorization: the hold subscriber authorizes HoldRun and the release + subscriber authorizes ResumeRun through the Authorize port as this principal + before writing. Under default AllowAllAuthorize both are permitted (bootstrap + window); under TrustAuthorize the operator's Policy must include this + principal + {HoldRun, ResumeRun}. Without a grant the action is a logged + no-op, so the gate degrades safe rather than crashing. + - Not a safety interlock: the hold is a REVERSIBLE park pending a co-signature + (resume_run exists), edge-triggered and fail-safe, NOT the floor PSS. +""" + +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 + + +# --------------------------------------------------------------------------- +# RatificationEnforcer agent identity (deployment-stable constants) +# --------------------------------------------------------------------------- + +# Treat as FOREVER-STABLE. UUID is in the deployment-controlled `fac0` range (a +# hex-valid nod to the rati-FIC-ation mnemonic), distinct from every other agent +# block, keeping the bootstrap constants visually grouped per agent. +RATIFICATION_ENFORCER_AGENT_ID = UUID("01900000-0000-7000-8000-0000fac00010") +RATIFICATION_ENFORCER_AGENT_NAME = "RatificationEnforcer" +RATIFICATION_ENFORCER_AGENT_KIND = "RatificationEnforcer" +RATIFICATION_ENFORCER_AGENT_VERSION = "1.0.0" +RATIFICATION_ENFORCER_AGENT_DESCRIPTION = ( + "Deterministic consequence-gate subscriber pair: on a RatificationRequested " + "holds the target run (parks it pending co-signature); on a " + "RatificationGranted resumes it. A reversible, fail-safe park of a run " + "pending a second independent principal's co-sign, not a safety interlock " + "(the floor PSS owns hard safety)." +) + + +# Sentinel model ref: the enforcer is rule-based, not an LLM agent. The Agent +# aggregate requires a ModelRef; this value is never used to build an LLM. +_DETERMINISTIC_MODEL_REF = ModelRef( + provider="deterministic", + model="agent:RatificationEnforcer:v1", + snapshot_pin=None, +) + + +# --------------------------------------------------------------------------- +# Deterministic IDs for the bootstrap write envelope +# --------------------------------------------------------------------------- + +_AGENT_EVENT_ID = UUID("01900000-0000-7000-8000-0000fac00012") +_ACTOR_EVENT_ID = UUID("01900000-0000-7000-8000-0000fac00013") +_BOOTSTRAP_CORRELATION_ID = UUID("01900000-0000-7000-8000-0000fac00014") + + +async def seed_ratification_enforcer_agent(kernel: Kernel) -> None: + """Seed the RatificationEnforcer Agent + co-registered Actor (idempotent).""" + identity = AgentSeedIdentity( + agent_id=RATIFICATION_ENFORCER_AGENT_ID, + name=RATIFICATION_ENFORCER_AGENT_NAME, + kind=RATIFICATION_ENFORCER_AGENT_KIND, + version=RATIFICATION_ENFORCER_AGENT_VERSION, + description=RATIFICATION_ENFORCER_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="SeedRatificationEnforcerAgent", + ) + await seed_agent(kernel, identity) + + +__all__ = [ + "RATIFICATION_ENFORCER_AGENT_DESCRIPTION", + "RATIFICATION_ENFORCER_AGENT_ID", + "RATIFICATION_ENFORCER_AGENT_KIND", + "RATIFICATION_ENFORCER_AGENT_NAME", + "RATIFICATION_ENFORCER_AGENT_VERSION", + "seed_ratification_enforcer_agent", +] diff --git a/apps/api/src/cora/agent/subscribers/_ratification_shared.py b/apps/api/src/cora/agent/subscribers/_ratification_shared.py new file mode 100644 index 00000000000..d7e454dc937 --- /dev/null +++ b/apps/api/src/cora/agent/subscribers/_ratification_shared.py @@ -0,0 +1,67 @@ +"""Shared helpers for the consequence-gate (Gate IV) enforcer subscribers. + +Leading-underscore module: intra-package machinery, not a subscriber itself (the +`test_agent_subscribers_completeness` discovery skips `_`-prefixed files). Home for +what `ratification_hold.py` and `ratification_release.py` both need: the pinned +enforcer identity guard and the hold-correlation guard. + +The two subscribers, acting as the pinned RatificationEnforcer agent, turn the +consequence gate's refuse-and-park flow into real RunHeld / RunResumed transitions +on the SAME shared hold the kill-switch uses. Both write via Pattern C (load, +guard status in-process, authorize as their own principal, append from +cora.run.aggregates), because cora.agent may depend on cora.run.aggregates + +cora.trust.aggregates but NOT cora.run.features (the tach BC boundary). Neither +writes a Decision: the Ratification events (Requested/Granted/Denied) are the +provenance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from cora.access.aggregates.actor import load_actor +from cora.agent.seed_ratification_enforcer import RATIFICATION_ENFORCER_AGENT_ID + +if TYPE_CHECKING: + from cora.infrastructure.ports import EventStore + from cora.infrastructure.ports.event_store import StoredEvent + +RUN_STREAM_TYPE = "Run" +HOLD_COMMAND_NAME = "HoldRun" +RESUME_COMMAND_NAME = "ResumeRun" + + +async def enforcer_standing_down(event_store: EventStore) -> bool: + """True if the enforcer is not seeded yet or was operator-deactivated. + + Same operator-deactivation revocation surface every sibling agent uses + (load_actor().active): an operator disables the gate the same way they disable + any agent. + """ + actor = await load_actor(event_store, RATIFICATION_ENFORCER_AGENT_ID) + return actor is None or not actor.active + + +def last_hold_placed_by_enforcer(stored: list[StoredEvent]) -> bool: + """True iff the run's most-recent RunHeld was placed by this enforcer. + + The load-bearing correlation guard for the release: `RunStatus.HELD` is a + single bit with no hold-source, so a run may be Held by the kill-switch, an + operator, or this gate. Scanning backward for the latest RunHeld and checking + its envelope principal_id lets the release resume ONLY a hold it placed, so a + stray Granted/Denied cannot defeat a foreign hold (e.g. the kill-switch's). + Returns False when no RunHeld exists (defensive; callers confirm status==HELD). + """ + for event in reversed(stored): + if event.event_type == "RunHeld": + return event.principal_id == RATIFICATION_ENFORCER_AGENT_ID + return False + + +__all__ = [ + "HOLD_COMMAND_NAME", + "RESUME_COMMAND_NAME", + "RUN_STREAM_TYPE", + "enforcer_standing_down", + "last_hold_placed_by_enforcer", +] diff --git a/apps/api/src/cora/agent/subscribers/ratification_hold.py b/apps/api/src/cora/agent/subscribers/ratification_hold.py new file mode 100644 index 00000000000..8c6f6b9ea27 --- /dev/null +++ b/apps/api/src/cora/agent/subscribers/ratification_hold.py @@ -0,0 +1,136 @@ +"""Reaction (consequence gate, Gate IV): RatificationRequested -> hold the run. + +When a consequence-classed command (StopRun) is refused for lack of a co-signature, +the operator requests a ratification; this subscriber, acting as the pinned +RatificationEnforcer agent, parks the target run in the SAME shared hold the +kill-switch uses (RunHeld), pending the co-sign. + +Pattern C write (not the hold_run slice): cora.agent may depend on +cora.run.aggregates but NOT cora.run.features (tach boundary). So it loads the run, +guards status == Running in-process, authorizes HoldRun as its own principal, and +appends RunHeld with optimistic concurrency, mirroring authority_revocation_holder. +Kind-blind (the target run is a UUID from the event payload; actor kind is never +read). Idempotent + fail-safe: an already-Held / terminal / missing run is a +logged no-op, and a lost concurrency race or Authorize Deny is swallowed, so a +redelivered event cannot wedge the shared bookmark. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from cora.agent.seed_ratification_enforcer import RATIFICATION_ENFORCER_AGENT_ID +from cora.agent.subscribers._ratification_shared import ( + HOLD_COMMAND_NAME, + RUN_STREAM_TYPE, + enforcer_standing_down, +) +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 + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + from cora.infrastructure.ports import Authorize, Clock, EventStore, IdGenerator + from cora.infrastructure.ports.event_store import StoredEvent + from cora.infrastructure.projection.handler import ConnectionLike + +_log = get_logger(__name__) + + +class RatificationHoldSubscriber: + """Reaction: RatificationRequested -> hold the target run (park pending co-sign).""" + + name = "ratification_hold" + subscribed_event_types = frozenset({"RatificationRequested"}) + batch_size = 1 + + def __init__( + self, + *, + event_store: EventStore, + authz: Authorize, + clock: Clock, + id_generator: IdGenerator, + ) -> None: + self.event_store = event_store + self.authz = authz + self.clock = clock + self.id_generator = id_generator + + async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: + _ = conn # cross-BC writes go through the event store, like the other subscribers + if event.event_type != "RatificationRequested": + return + try: + if await enforcer_standing_down(self.event_store): + return + run_id = UUID(event.payload["target_action_id"]) + await self._hold(run_id) + except Exception: + _log.exception( + "ratification_hold.apply_failed", + ratification_event_id=str(event.event_id), + ) + + async def _hold(self, run_id: UUID) -> None: + stored, version = await self.event_store.load(RUN_STREAM_TYPE, run_id) + if not stored: + _log.info("ratification_hold.run_not_found", run_id=str(run_id)) + return + state = fold_run([run_from_stored(s) for s in stored]) + if state is None or state.status is not RunStatus.RUNNING: + _log.info("ratification_hold.not_running", run_id=str(run_id)) + return + authz = await self.authz.authorize( + principal_id=RATIFICATION_ENFORCER_AGENT_ID, + command_name=HOLD_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=NIL_SENTINEL_ID, + ) + if isinstance(authz, Deny): + _log.info("ratification_hold.unauthorized", run_id=str(run_id)) + return + now = self.clock.now() + 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=self.id_generator.new_id(), + command_name=HOLD_COMMAND_NAME, + correlation_id=self.id_generator.new_id(), + causation_id=None, + principal_id=RATIFICATION_ENFORCER_AGENT_ID, + ) + try: + await self.event_store.append( + stream_type=RUN_STREAM_TYPE, + stream_id=run_id, + expected_version=version, + events=[envelope], + ) + except ConcurrencyError: + _log.info("ratification_hold.lost_race", run_id=str(run_id)) + return + _log.info("ratification_hold.held", run_id=str(run_id)) + + +def make_ratification_hold_subscriber(deps: Kernel) -> RatificationHoldSubscriber: + """Build the RatificationHold subscriber closed over the shared deps.""" + return RatificationHoldSubscriber( + event_store=deps.event_store, + authz=deps.authz, + clock=deps.clock, + id_generator=deps.id_generator, + ) + + +__all__ = ["RatificationHoldSubscriber", "make_ratification_hold_subscriber"] diff --git a/apps/api/src/cora/agent/subscribers/ratification_release.py b/apps/api/src/cora/agent/subscribers/ratification_release.py new file mode 100644 index 00000000000..ed9cead3a98 --- /dev/null +++ b/apps/api/src/cora/agent/subscribers/ratification_release.py @@ -0,0 +1,159 @@ +"""Reaction (consequence gate, Gate IV): a settled Ratification -> resume the run. + +Reacts to BOTH RatificationGranted and RatificationDenied and un-parks the run +this enforcer held: Granted means the co-sign arrived (the operator can re-issue +the now-covered stop, which the gate admits); Denied means the co-sign was refused +(the run returns to Running and the stop stays un-admitted, since coverage is never +Granted). Leaving a denied run Held forever would make the "reversible hold" +un-reversible, so denial auto-resumes symmetrically. + +CORRELATION (the load-bearing guard, `last_hold_placed_by_enforcer`): the release +resumes ONLY a hold this enforcer placed. RunStatus.HELD carries no hold-source, so +a run may be Held by the kill-switch, an operator, or this gate; resuming blindly +on status == HELD would let a stray Granted/Denied defeat a foreign hold. The +guard folds the run's most-recent RunHeld and resumes only when its envelope +principal_id is this enforcer; otherwise it stands down. + +Pattern C write (not the resume_run slice): loads the run, guards, authorizes +ResumeRun as its own principal, appends RunResumed with optimistic concurrency. +Granted/Denied carry only ratification_id, so it loads the Ratification aggregate +(the sole reason cora.agent depends on cora.trust.aggregates) to resolve the +target run. Kind-blind; idempotent + fail-safe (already-Running / foreign-hold / +lost-race / Deny are logged no-ops). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from cora.agent.seed_ratification_enforcer import RATIFICATION_ENFORCER_AGENT_ID +from cora.agent.subscribers._ratification_shared import ( + RESUME_COMMAND_NAME, + RUN_STREAM_TYPE, + enforcer_standing_down, + last_hold_placed_by_enforcer, +) +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 RunResumed, 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.trust.aggregates.ratification import load_ratification + +if TYPE_CHECKING: + from cora.infrastructure.kernel import Kernel + from cora.infrastructure.ports import Authorize, Clock, EventStore, IdGenerator + from cora.infrastructure.ports.event_store import StoredEvent + from cora.infrastructure.projection.handler import ConnectionLike + +_log = get_logger(__name__) + +_SETTLED_EVENT_TYPES = ("RatificationGranted", "RatificationDenied") + + +class RatificationReleaseSubscriber: + """Reaction: a settled Ratification (Granted OR Denied) -> resume the parked run.""" + + name = "ratification_release" + subscribed_event_types = frozenset({"RatificationGranted", "RatificationDenied"}) + batch_size = 1 + + def __init__( + self, + *, + event_store: EventStore, + authz: Authorize, + clock: Clock, + id_generator: IdGenerator, + ) -> None: + self.event_store = event_store + self.authz = authz + self.clock = clock + self.id_generator = id_generator + + async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: + _ = conn + if event.event_type not in _SETTLED_EVENT_TYPES: + return + try: + if await enforcer_standing_down(self.event_store): + return + # Granted/Denied carry only ratification_id; resolve the target run by + # loading the Ratification aggregate (carries target_action_id). + ratification = await load_ratification( + self.event_store, UUID(event.payload["ratification_id"]) + ) + if ratification is None: + return + await self._resume(ratification.target_action_id) + except Exception: + _log.exception( + "ratification_release.apply_failed", + ratification_event_id=str(event.event_id), + ) + + async def _resume(self, run_id: UUID) -> None: + stored, version = await self.event_store.load(RUN_STREAM_TYPE, run_id) + if not stored: + _log.info("ratification_release.run_not_found", run_id=str(run_id)) + return + state = fold_run([run_from_stored(s) for s in stored]) + if state is None or state.status is not RunStatus.HELD: + _log.info("ratification_release.not_held", run_id=str(run_id)) + return + if not last_hold_placed_by_enforcer(stored): + # The run is Held for a DIFFERENT reason (kill-switch / operator). Do + # not resume it: clearing someone else's hold would invert their intent + # (e.g. defeat the kill-switch). Stand down; the other holder owns it. + _log.info("ratification_release.foreign_hold", run_id=str(run_id)) + return + authz = await self.authz.authorize( + principal_id=RATIFICATION_ENFORCER_AGENT_ID, + command_name=RESUME_COMMAND_NAME, + conduit_id=NIL_SENTINEL_ID, + surface_id=NIL_SENTINEL_ID, + ) + if isinstance(authz, Deny): + _log.info("ratification_release.unauthorized", run_id=str(run_id)) + return + now = self.clock.now() + resumed = RunResumed(run_id=run_id, occurred_at=now, decided_by_decision_id=None) + envelope = to_new_event( + event_type=run_event_type_name(resumed), + payload=run_to_payload(resumed), + occurred_at=now, + event_id=self.id_generator.new_id(), + command_name=RESUME_COMMAND_NAME, + correlation_id=self.id_generator.new_id(), + causation_id=None, + principal_id=RATIFICATION_ENFORCER_AGENT_ID, + ) + try: + await self.event_store.append( + stream_type=RUN_STREAM_TYPE, + stream_id=run_id, + expected_version=version, + events=[envelope], + ) + except ConcurrencyError: + _log.info("ratification_release.lost_race", run_id=str(run_id)) + return + _log.info("ratification_release.resumed", run_id=str(run_id)) + + +def make_ratification_release_subscriber(deps: Kernel) -> RatificationReleaseSubscriber: + """Build the RatificationRelease subscriber closed over the shared deps.""" + return RatificationReleaseSubscriber( + event_store=deps.event_store, + authz=deps.authz, + clock=deps.clock, + id_generator=deps.id_generator, + ) + + +__all__ = ["RatificationReleaseSubscriber", "make_ratification_release_subscriber"] diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 43420d5fcfe..c1c4f743a31 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -63,6 +63,7 @@ seed_clearance_watcher_agent, seed_experiment_steerer_agent, seed_procedure_watcher_agent, + seed_ratification_enforcer_agent, seed_run_debriefer_agent, seed_run_initiator_agent, seed_run_supervisor_agent, @@ -235,6 +236,7 @@ warn_if_verdict_log_dormant, wire_trust, ) +from cora.trust.adapters import PostgresConsequenceLookup def _settings_for_app() -> Settings: @@ -619,6 +621,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: capability_lookup_factory=PostgresCapabilityLookup, supply_lookup_factory=PostgresSupplyLookup, run_actor_involvement_lookup_factory=PostgresRunActorInvolvementLookup, + consequence_lookup_factory=PostgresConsequenceLookup, dataset_distribution_lookup_factory=PostgresDatasetDistributionLookup, credential_lookup_factory=PostgresCredentialLookup, facility_lookup_factory=PostgresFacilityLookup, @@ -818,6 +821,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: # 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 RatificationEnforcer Agent record: the consequence-gate + # hold/release subscribers register unconditionally and must resolve + # their `actor_id` at apply()-time. Idempotent across restarts. + await seed_ratification_enforcer_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/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index b5641c71f56..e8823465e73 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -86,6 +86,7 @@ AlwaysEmptyCapabilityLookup, AlwaysPermittedEnclosureLookup, AlwaysQuietCautionLookup, + AlwaysRatifiedConsequenceLookup, AssemblyLookup, AssetLookup, Authorize, @@ -95,6 +96,7 @@ ClearanceTemplateLookup, Clock, ComputeReachabilityLookup, + ConsequenceLookup, CredentialLookup, DatasetDistributionLookup, EnclosureLookup, @@ -167,6 +169,7 @@ def make_postgres_kernel( capability_lookup: CapabilityLookup | None = None, supply_lookup: SupplyLookup | None = None, run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, + consequence_lookup: ConsequenceLookup | None = None, dataset_distribution_lookup: DatasetDistributionLookup | None = None, compute_reachability_lookup: ComputeReachabilityLookup | None = None, credential_lookup: CredentialLookup | None = None, @@ -337,6 +340,11 @@ def make_postgres_kernel( if run_actor_involvement_lookup is not None else NoInvolvementLookup() ), + consequence_lookup=( + consequence_lookup + if consequence_lookup is not None + else AlwaysRatifiedConsequenceLookup() + ), dataset_distribution_lookup=( dataset_distribution_lookup if dataset_distribution_lookup is not None @@ -390,6 +398,7 @@ def make_inmemory_kernel( capability_lookup: CapabilityLookup | None = None, supply_lookup: SupplyLookup | None = None, run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, + consequence_lookup: ConsequenceLookup | None = None, dataset_distribution_lookup: DatasetDistributionLookup | None = None, compute_reachability_lookup: ComputeReachabilityLookup | None = None, credential_lookup: CredentialLookup | None = None, @@ -543,6 +552,11 @@ def make_inmemory_kernel( if run_actor_involvement_lookup is not None else NoInvolvementLookup() ), + consequence_lookup=( + consequence_lookup + if consequence_lookup is not None + else AlwaysRatifiedConsequenceLookup() + ), dataset_distribution_lookup=( dataset_distribution_lookup if dataset_distribution_lookup is not None @@ -686,6 +700,25 @@ def __call__( ) -> RunActorInvolvementLookup: ... +class ConsequenceLookupFactory(Protocol): + """Builds the production ConsequenceLookup port for the Kernel. + + Trust BC's `cora.trust.adapters.PostgresConsequenceLookup` 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 + `AlwaysRatifiedConsequenceLookup` automatically. + """ + + def __call__( + self, + pool: asyncpg.Pool, + ) -> ConsequenceLookup: ... + + class DatasetDistributionLookupFactory(Protocol): """Builds the production DatasetDistributionLookup port for the Kernel. @@ -894,6 +927,7 @@ async def build_kernel( capability_lookup_factory: CapabilityLookupFactory | None = None, supply_lookup_factory: SupplyLookupFactory | None = None, run_actor_involvement_lookup_factory: RunActorInvolvementLookupFactory | None = None, + consequence_lookup_factory: ConsequenceLookupFactory | None = None, dataset_distribution_lookup_factory: DatasetDistributionLookupFactory | None = None, credential_lookup_factory: CredentialLookupFactory | None = None, facility_lookup_factory: FacilityLookupFactory | None = None, @@ -1007,6 +1041,11 @@ async def build_kernel( if run_actor_involvement_lookup_factory is not None else NoInvolvementLookup() ) + consequence_lookup: ConsequenceLookup = ( + consequence_lookup_factory(pool) + if consequence_lookup_factory is not None + else AlwaysRatifiedConsequenceLookup() + ) dataset_distribution_lookup: DatasetDistributionLookup = ( dataset_distribution_lookup_factory(pool) if dataset_distribution_lookup_factory is not None @@ -1061,6 +1100,7 @@ async def build_kernel( capability_lookup=capability_lookup, supply_lookup=supply_lookup, run_actor_involvement_lookup=run_actor_involvement_lookup, + consequence_lookup=consequence_lookup, dataset_distribution_lookup=dataset_distribution_lookup, credential_lookup=credential_lookup, facility_lookup=facility_lookup, @@ -1148,6 +1188,7 @@ async def composed() -> None: "CautionLookupFactory", "ClearanceLookupFactory", "ClearanceTemplateLookupFactory", + "ConsequenceLookupFactory", "CredentialLookupFactory", "DatasetDistributionLookupFactory", "EnclosureLookupFactory", diff --git a/apps/api/src/cora/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index 9037cfc9e29..346fb2b246a 100644 --- a/apps/api/src/cora/infrastructure/kernel.py +++ b/apps/api/src/cora/infrastructure/kernel.py @@ -55,6 +55,7 @@ ClearanceTemplateLookup, Clock, ComputeReachabilityLookup, + ConsequenceLookup, CredentialLookup, DatasetDistributionLookup, EnclosureLookup, @@ -163,6 +164,17 @@ class Kernel: to seed runs; kill-switch tests override with the real adapter. Mirrors the `ClearanceLookup` / `CautionLookup` test-default pattern. + `consequence_lookup`: cross-BC port consumed by Run BC's `stop_run` + handler for the consequence gate (Gate IV): is this action covered + by a GRANTED Ratification (a second, independent principal's + co-signature)? Trust BC ships `PostgresConsequenceLookup` as the + production adapter (reads `proj_trust_ratification_coverage`). Test + environments default to `AlwaysRatifiedConsequenceLookup` (coverage + always present) so existing stop_run tests stay green with stop_run + in the ratification allowlist; consequence-gate tests override with + `NeverRatifiedConsequenceLookup` (refuse-and-hold path) or the real + adapter (end-to-end). + `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 @@ -327,6 +339,7 @@ class Kernel: capability_lookup: CapabilityLookup supply_lookup: SupplyLookup run_actor_involvement_lookup: RunActorInvolvementLookup + consequence_lookup: ConsequenceLookup 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 e1acab7773b..451352543a2 100644 --- a/apps/api/src/cora/infrastructure/ports/__init__.py +++ b/apps/api/src/cora/infrastructure/ports/__init__.py @@ -51,6 +51,11 @@ NoComputeReachabilityLookup, SeededComputeReachabilityLookup, ) +from cora.infrastructure.ports.consequence_lookup import ( + AlwaysRatifiedConsequenceLookup, + ConsequenceLookup, + NeverRatifiedConsequenceLookup, +) from cora.infrastructure.ports.credential_lookup import ( CredentialLookup, CredentialLookupResult, @@ -166,6 +171,7 @@ "AlwaysEmptyCapabilityLookup", "AlwaysPermittedEnclosureLookup", "AlwaysQuietCautionLookup", + "AlwaysRatifiedConsequenceLookup", "AssemblyLookup", "AssemblyLookupResult", "AssetLookup", @@ -192,6 +198,7 @@ "Clock", "ComputeReachabilityLookup", "ConcurrencyError", + "ConsequenceLookup", "CredentialLookup", "CredentialLookupResult", "DatasetDistributionLookup", @@ -234,6 +241,7 @@ "LogbookMirror", "MinSeverity", "ModelRef", + "NeverRatifiedConsequenceLookup", "NewEvent", "NoComputeReachabilityLookup", "NoDatasetDistributionsLookup", diff --git a/apps/api/src/cora/infrastructure/ports/consequence_lookup.py b/apps/api/src/cora/infrastructure/ports/consequence_lookup.py new file mode 100644 index 00000000000..73cae7dcf67 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/consequence_lookup.py @@ -0,0 +1,101 @@ +"""ConsequenceLookup port: cross-BC query for Trust BC's ratification-coverage +projection (consequence gate, Gate IV). + +Used by Run BC's `stop_run` handler to gate a consequence-classed command on the +presence of a GRANTED Ratification covering this action's scope +`(run_id, command_name)`. A second, independent principal must have granted the +co-signature before the action is admitted; absent that, the gate refuses and the +run is placed in the shared hold pending the co-sign. + +## Convention + +Mirrors `ClearanceLookup` exactly: one implementor (Trust BC ships +`PostgresConsequenceLookup` reading `proj_trust_ratification_coverage`), one +consumer today (Run's `stop_run`; more in-scope commands later). Lives in +`cora.infrastructure.ports` for neutral cross-BC access. The port is shaped around +the CONSUMER's need ("is this action co-signed?"), not Trust's domain language; +the adapter translates the projection's columns to this shape. + +## Coverage semantics + +"Covered" means: a Ratification exists with `status = 'Granted'`, `run_id = `, and `command_name = `. The consequence-class trigger +(which commands require co-sign) is a static allowlist on the Run side +(`COMMANDS_REQUIRING_RATIFICATION`); this port answers ONLY the coverage question, +keeping the trigger and the lookup orthogonal. A log-folded first-of-kind +refinement of the trigger is a documented follow-up and does not change this +port's contract. +""" + +from typing import Protocol +from uuid import UUID + + +class ConsequenceLookup(Protocol): + """Cross-BC port: query Trust's ratification-coverage projection from Run BC.""" + + async def granted_coverage_exists( + self, + *, + run_id: UUID, + command_name: str, + ) -> bool: + """Return True iff a Granted Ratification covers `(run_id, command_name)`. + + A Requested (pending) or Denied Ratification does NOT count as coverage: + only a second principal's Grant admits the action. False means the gated + command must be refused (and the run placed in the shared hold pending a + co-sign). + """ + ... + + +class NeverRatifiedConsequenceLookup: + """Test-default stub: nothing is ever co-signed (no coverage). + + The conservative default: a consequence-gated command is never pre-covered, so + a test that does not exercise ratification sees the gate's refuse-and-hold + path only when it opts a command into the allowlist. Tests that exercise the + grant path override with the real `PostgresConsequenceLookup` and seed a + Granted Ratification via `request_ratification` + `grant_ratification`. + + Named in the `AllowAll` / `AlwaysCovered` / `NeverRatified` test-default + family (the disposition is in the name). + """ + + async def granted_coverage_exists( + self, + *, + run_id: UUID, + command_name: str, + ) -> bool: + _ = (run_id, command_name) # no coverage recorded + return False + + +class AlwaysRatifiedConsequenceLookup: + """Test-default stub: everything is co-signed (coverage always present). + + The permissive default for the many existing Run tests that call stop_run + WITHOUT caring about the consequence gate: with stop_run in the allowlist, + those tests would otherwise all trip the gate. Wiring this as the + build_postgres_deps / make_*_kernel default keeps them green; gate tests + override with NeverRatifiedConsequenceLookup (refuse path) or the real adapter + (end-to-end). + """ + + async def granted_coverage_exists( + self, + *, + run_id: UUID, + command_name: str, + ) -> bool: + _ = (run_id, command_name) # synthetic coverage for everything + return True + + +__all__ = [ + "AlwaysRatifiedConsequenceLookup", + "ConsequenceLookup", + "NeverRatifiedConsequenceLookup", +] diff --git a/apps/api/src/cora/run/aggregates/run/__init__.py b/apps/api/src/cora/run/aggregates/run/__init__.py index 0a9477a2894..937bef2b2db 100644 --- a/apps/api/src/cora/run/aggregates/run/__init__.py +++ b/apps/api/src/cora/run/aggregates/run/__init__.py @@ -100,6 +100,7 @@ RunRequiresAvailableSupplyError, RunRequiresOpenBeamShuttersError, RunRequiresPermittedEnclosureError, + RunRequiresRatificationError, RunStatus, RunStopReason, RunSubjectNotMountableError, @@ -183,6 +184,7 @@ "RunRequiresAvailableSupplyError", "RunRequiresOpenBeamShuttersError", "RunRequiresPermittedEnclosureError", + "RunRequiresRatificationError", "RunResumed", "RunStarted", "RunStatus", diff --git a/apps/api/src/cora/run/aggregates/run/state.py b/apps/api/src/cora/run/aggregates/run/state.py index 76c7614d97c..e2b4cc1a177 100644 --- a/apps/api/src/cora/run/aggregates/run/state.py +++ b/apps/api/src/cora/run/aggregates/run/state.py @@ -871,6 +871,32 @@ def __init__(self, run_id: UUID, current_status: "RunStatus") -> None: self.current_status = current_status +class RunRequiresRatificationError(Exception): + """Consequence gate (Gate IV): this command needs a second principal's co-sign. + + Cross-BC gate: a command in the declared consequence class + (`cora.shared.consequence.COMMANDS_REQUIRING_RATIFICATION`, `StopRun` in v1) + is admitted only when a GRANTED Ratification covers `(run_id, command_name)`. + This fires when the command is in-scope AND no Granted coverage exists yet: + the operator must `request_ratification` for this action and a second, + independent principal must `grant_ratification` before the stop is admitted. + While the co-sign is pending, the run is parked in the shared hold (via the + RatificationHoldSubscriber). Kind-blind: a human and an agent are gated + identically. + + Mapped to HTTP 409 (same family as the other Run cross-BC pre-flight gates). + """ + + def __init__(self, run_id: UUID, command_name: str) -> None: + super().__init__( + f"Run {run_id} cannot {command_name}: this action requires a second " + f"independent principal's co-signature. Request a ratification for " + f"this action and have it granted before retrying." + ) + self.run_id = run_id + self.command_name = command_name + + class RunCannotTruncateError(Exception): """Attempted to truncate a Run not in `Running` or `Held`. diff --git a/apps/api/src/cora/run/features/stop_run/decider.py b/apps/api/src/cora/run/features/stop_run/decider.py index 92abd608c1e..595264534c9 100644 --- a/apps/api/src/cora/run/features/stop_run/decider.py +++ b/apps/api/src/cora/run/features/stop_run/decider.py @@ -6,11 +6,26 @@ Stopping any terminal Run (Completed | Aborted | Stopped) raises; re-stopping a `Stopped` Run raises (strict-not-idempotent). +## Consequence gate (Gate IV) + +`StopRun` is in the declared consequence class +(`cora.shared.consequence.COMMANDS_REQUIRING_RATIFICATION`): a deliberate, +irreversible early termination requires a second, independent principal's +co-signature. The handler pre-loads coverage via the `ConsequenceLookup` port and +passes `ratification_covered` here (the pure decider stays I/O-free). When the +command is in-scope AND coverage is absent, the gate refuses with +`RunRequiresRatificationError` BEFORE the status/transition checks: admission is +the outer precondition. Kind-blind (the decider never reads actor kind). On that +refusal the operator requests a ratification, which parks the run in the shared +hold pending the co-sign (see the RatificationHoldSubscriber). + `reason` validation goes through the `RunStopReason` VO (which calls the shared `validate_bounded_text` helper). The on-the-wire payload in `RunStopped.reason` carries the trimmed string. Invariants: + - Consequence-classed command without Granted coverage + -> RunRequiresRatificationError - State must not be None -> RunNotFoundError - command.reason must be 1-500 chars after trimming -> InvalidRunStopReasonError @@ -24,22 +39,35 @@ Run, RunCannotStopError, RunNotFoundError, + RunRequiresRatificationError, RunStatus, RunStopped, RunStopReason, ) from cora.run.features.stop_run.command import StopRun +from cora.shared.consequence import requires_ratification _STOPPABLE_STATUSES: tuple[RunStatus, ...] = (RunStatus.RUNNING, RunStatus.HELD) +_COMMAND_NAME = "StopRun" + def decide( state: Run | None, command: StopRun, *, now: datetime, + ratification_covered: bool, ) -> list[RunStopped]: - """Decide the events produced by stopping a Run.""" + """Decide the events produced by stopping a Run. + + `ratification_covered` is the consequence-gate coverage fact pre-loaded by the + handler from the `ConsequenceLookup` port: True iff a Granted Ratification + covers `(run_id, StopRun)`. The gate is checked first (admission precondition) + so an un-co-signed stop is refused before any transition logic runs. + """ + if requires_ratification(_COMMAND_NAME) and not ratification_covered: + raise RunRequiresRatificationError(command.run_id, _COMMAND_NAME) if state is None: raise RunNotFoundError(command.run_id) reason = RunStopReason(command.reason) diff --git a/apps/api/src/cora/run/features/stop_run/handler.py b/apps/api/src/cora/run/features/stop_run/handler.py index 59a52c41646..96cf960c90c 100644 --- a/apps/api/src/cora/run/features/stop_run/handler.py +++ b/apps/api/src/cora/run/features/stop_run/handler.py @@ -1,24 +1,36 @@ """Application handler for the `stop_run` slice. -Update-style handler. Canonical body lives in -`cora.run._run_update_handler.make_run_update_handler`; this module -is a thin slice-specific bind. - -The command's `reason` field IS captured on the emitted -`RunStopped` event payload but is intentionally NOT logged at -the handler boundary (matches Subject discard / Asset condition -precedent), so this slice does not pass `extra_log_fields`. +Bespoke handler (not the `make_run_update_handler` factory) because the +consequence gate (Gate IV) needs a cross-BC PRE-LOAD before the pure decider: +the handler asks the `ConsequenceLookup` port whether a Granted Ratification +covers `(run_id, StopRun)` and threads that fact into `decide(..., +ratification_covered=...)`. Mirrors how `start_run` pre-loads clearance / supply +coverage into its decider context: the port call is I/O in the handler, the +decision stays pure. + +The command's `reason` field IS captured on the emitted `RunStopped` event +payload but is intentionally NOT logged at the handler boundary (matches the +Subject discard / Asset condition precedent). """ 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.run._run_update_handler import make_run_update_handler +from cora.run.aggregates.run import event_type_name, fold, from_stored, to_payload +from cora.run.errors import UnauthorizedError from cora.run.features.stop_run.command import StopRun from cora.run.features.stop_run.decider import decide +_STREAM_TYPE = "Run" +_COMMAND_NAME = "StopRun" + +_log = get_logger("stop_run") + class Handler(Protocol): """Callable interface every stop_run handler implements.""" @@ -36,9 +48,91 @@ async def __call__( def bind(deps: Kernel) -> Handler: """Build a stop_run handler closed over the shared deps.""" - return make_run_update_handler( - deps, - command_name="StopRun", - log_prefix="stop_run", - decide_fn=decide, - ) + + async def handler( + command: StopRun, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> None: + _log.info( + "stop_run.start", + command_name=_COMMAND_NAME, + run_id=str(command.run_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + causation_id=str(causation_id) if causation_id is not None else None, + ) + + 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( + "stop_run.denied", + command_name=_COMMAND_NAME, + run_id=str(command.run_id), + principal_id=str(principal_id), + correlation_id=str(correlation_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + # Consequence-gate pre-load: is (run_id, StopRun) covered by a Granted + # Ratification? The port call is the handler's I/O; the decider stays pure. + ratification_covered = await deps.consequence_lookup.granted_coverage_exists( + run_id=command.run_id, + command_name=_COMMAND_NAME, + ) + + now = deps.clock.now() + stored, current_version = await deps.event_store.load( + stream_type=_STREAM_TYPE, + stream_id=command.run_id, + ) + state = fold([from_stored(s) for s in stored]) + + domain_events = decide( + state=state, + command=command, + now=now, + ratification_covered=ratification_covered, + ) + + 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.run_id, + expected_version=current_version, + events=new_events, + ) + + _log.info( + "stop_run.success", + command_name=_COMMAND_NAME, + run_id=str(command.run_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), + new_version=current_version + len(new_events), + ) + + return handler diff --git a/apps/api/src/cora/run/routes.py b/apps/api/src/cora/run/routes.py index 54cb24a6fd0..4a232e66b1e 100644 --- a/apps/api/src/cora/run/routes.py +++ b/apps/api/src/cora/run/routes.py @@ -96,6 +96,7 @@ RunRequiresAvailableSupplyError, RunRequiresOpenBeamShuttersError, RunRequiresPermittedEnclosureError, + RunRequiresRatificationError, RunSubjectNotMountableError, RunSupplyCoverageMismatchError, ) @@ -252,6 +253,8 @@ def register_run_routes(app: FastAPI) -> None: RunCannotHoldError, RunCannotResumeError, RunCannotStopError, + # Consequence gate (Gate IV): stop needs a second principal's co-sign. + RunRequiresRatificationError, # Run transition guards (6f-4). RunCannotTruncateError, # Reading logbook closed (6f-5b): Run is in a terminal status. diff --git a/apps/api/src/cora/shared/consequence.py b/apps/api/src/cora/shared/consequence.py new file mode 100644 index 00000000000..24cf237d047 --- /dev/null +++ b/apps/api/src/cora/shared/consequence.py @@ -0,0 +1,39 @@ +"""Consequence gate (Gate IV): the declared class of commands that require a +second, independent principal's co-signature (a Granted Ratification) before they +are admitted. + +Kept in `cora.shared` (neutral) so the Run-side decider can consult it without a +Trust import, mirroring how bounded-text limits live in `cora.shared.text_bounds`. +The membership test is the gate's TRIGGER; the `ConsequenceLookup` port answers +the orthogonal COVERAGE question ("is there a Granted Ratification for this +action?"). Keeping the two separate is what keeps the gate's marginal cost a pure +fold + decider arm. + +## Allowlist-first (rule-of-three deferred) + +The declared class is a static frozenset of command names, mirroring the +obligation gate's allowlist and the required-role sets. A Capability-declared +`consequence_class` (per-action, data-driven) is the richer version; it is +deferred until a second in-scope command appears (rule of three). A log-folded +first-of-kind refinement of the trigger (co-sign only the FIRST time a command is +run against a scope) is a documented follow-up and does not change this module's +contract. + +## v1 membership + +`StopRun` only: a deliberate, non-emergency, irreversible early termination of a +running experiment. NOT `AbortRun` (emergency exit; holding an emergency for a +co-signature inverts the safety posture). +""" + +from typing import Final + +COMMANDS_REQUIRING_RATIFICATION: Final[frozenset[str]] = frozenset({"StopRun"}) + + +def requires_ratification(command_name: str) -> bool: + """Return True iff `command_name` is in the declared consequence class.""" + return command_name in COMMANDS_REQUIRING_RATIFICATION + + +__all__ = ["COMMANDS_REQUIRING_RATIFICATION", "requires_ratification"] diff --git a/apps/api/src/cora/trust/_projections.py b/apps/api/src/cora/trust/_projections.py index 62c75234890..35b20f28c24 100644 --- a/apps/api/src/cora/trust/_projections.py +++ b/apps/api/src/cora/trust/_projections.py @@ -13,6 +13,7 @@ from cora.trust.projections import ( ConduitSummaryProjection, PolicySummaryProjection, + RatificationCoverageProjection, SurfaceActiveVisitProjection, VisitPresenceProjection, VisitSummaryProjection, @@ -32,6 +33,7 @@ def register_trust_projections( registry.register(VisitSummaryProjection()) registry.register(VisitPresenceProjection()) registry.register(SurfaceActiveVisitProjection()) + registry.register(RatificationCoverageProjection()) __all__ = ["register_trust_projections"] diff --git a/apps/api/src/cora/trust/adapters/__init__.py b/apps/api/src/cora/trust/adapters/__init__.py new file mode 100644 index 00000000000..4d19d171a05 --- /dev/null +++ b/apps/api/src/cora/trust/adapters/__init__.py @@ -0,0 +1,10 @@ +"""Adapters Trust BC ships for cross-BC ports. + +- `PostgresConsequenceLookup` implementing `ConsequenceLookup` (consumed by Run + BC's `stop_run` handler for the consequence gate: is this action co-signed by a + Granted Ratification?). +""" + +from cora.trust.adapters.postgres_consequence_lookup import PostgresConsequenceLookup + +__all__ = ["PostgresConsequenceLookup"] diff --git a/apps/api/src/cora/trust/adapters/postgres_consequence_lookup.py b/apps/api/src/cora/trust/adapters/postgres_consequence_lookup.py new file mode 100644 index 00000000000..fa4d4e4cbf8 --- /dev/null +++ b/apps/api/src/cora/trust/adapters/postgres_consequence_lookup.py @@ -0,0 +1,53 @@ +"""Postgres adapter implementing `ConsequenceLookup` over +`proj_trust_ratification_coverage`. + +Consumed by Run BC's `stop_run` handler via the `Kernel.consequence_lookup` port. +Answers "is there a GRANTED Ratification covering (target_action_id, +command_name)?" so the consequence gate admits a co-signed action and refuses (+ +holds) an un-co-signed one. + +## Why query the projection (not the event store) + +Same modern-DDD guidance as ClearanceLookup: cross-BC integration at command time +reads a replicated read model, not the upstream aggregate. The coverage +projection is a denormalized cross-stream view maintained by the projection +worker; the adapter reads it directly through the shared asyncpg pool. +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from uuid import UUID + +import asyncpg + +_GRANTED_COVERAGE_EXISTS_SQL = """ +SELECT EXISTS ( + SELECT 1 + FROM proj_trust_ratification_coverage + WHERE target_action_id = $1 + AND command_name = $2 + AND status = 'Granted' +) +""" + + +class PostgresConsequenceLookup: + """asyncpg-backed `ConsequenceLookup` implementation.""" + + def __init__(self, pool: asyncpg.Pool) -> None: + self._pool = pool + + async def granted_coverage_exists( + self, + *, + run_id: UUID, + command_name: str, + ) -> bool: + async with self._pool.acquire() as conn: + # SELECT EXISTS(...) never yields NULL, but wrap in bool() so the + # `-> bool` contract is honest (fetchval is typed Any) rather than + # leaning on the file-level pyright suppression. + return bool(await conn.fetchval(_GRANTED_COVERAGE_EXISTS_SQL, run_id, command_name)) + + +__all__ = ["PostgresConsequenceLookup"] diff --git a/apps/api/src/cora/trust/projections/__init__.py b/apps/api/src/cora/trust/projections/__init__.py index c7fabc30b78..f81018a1c43 100644 --- a/apps/api/src/cora/trust/projections/__init__.py +++ b/apps/api/src/cora/trust/projections/__init__.py @@ -9,6 +9,7 @@ from cora.trust.projections.conduit import ConduitSummaryProjection from cora.trust.projections.policy import PolicySummaryProjection +from cora.trust.projections.ratification_coverage import RatificationCoverageProjection from cora.trust.projections.surface_active_visit import ( SurfaceActiveVisit, SurfaceActiveVisitProjection, @@ -21,6 +22,7 @@ __all__ = [ "ConduitSummaryProjection", "PolicySummaryProjection", + "RatificationCoverageProjection", "SurfaceActiveVisit", "SurfaceActiveVisitProjection", "VisitPresenceProjection", diff --git a/apps/api/src/cora/trust/projections/ratification_coverage.py b/apps/api/src/cora/trust/projections/ratification_coverage.py new file mode 100644 index 00000000000..52af1d0279f --- /dev/null +++ b/apps/api/src/cora/trust/projections/ratification_coverage.py @@ -0,0 +1,81 @@ +"""RatificationCoverageProjection: the consequence gate's coverage read model. + +Answers "is action `(target_action_id, command_name)` covered by a GRANTED +Ratification?" for the `ConsequenceLookup` port that Run BC's `stop_run` gate +consults (the target action is the run being stopped, so the consumer passes +`run_id` as the `target_action_id`). + +The scope keys (`target_action_id`, `command_name`) live only on the genesis +event (`RatificationRequested`); `RatificationGranted` / `RatificationDenied` are +keyed by `ratification_id` alone. So this projection carries the scope from +genesis and folds the status forward by `ratification_id`: + + - RatificationRequested -> INSERT (ratification_id, target_action_id, + command_name, status='Requested') + - RatificationGranted -> UPDATE status='Granted' WHERE ratification_id + - RatificationDenied -> UPDATE status='Denied' WHERE ratification_id + +The lookup filters to `status = 'Granted'`. Rows are retained across status +changes (a Denied row stays, auditable); the coverage query keys on status, not +on row presence. +""" + +# 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_trust_ratification_coverage + (ratification_id, target_action_id, command_name, status, created_at) +VALUES ($1, $2, $3, 'Requested', $4) +ON CONFLICT (ratification_id) DO NOTHING +""" + +_UPDATE_STATUS_SQL = """ +UPDATE proj_trust_ratification_coverage +SET status = $2, updated_at = now() +WHERE ratification_id = $1 +""" + +_EVENT_TO_STATUS = { + "RatificationGranted": "Granted", + "RatificationDenied": "Denied", +} + + +class RatificationCoverageProjection: + """Maintains the `proj_trust_ratification_coverage` read model.""" + + name = "proj_trust_ratification_coverage" + subscribed_event_types = frozenset( + { + "RatificationRequested", + "RatificationGranted", + "RatificationDenied", + } + ) + + async def apply(self, event: StoredEvent, conn: ConnectionLike) -> None: + if event.event_type == "RatificationRequested": + await conn.execute( + _INSERT_SQL, + UUID(event.payload["ratification_id"]), + UUID(event.payload["target_action_id"]), + event.payload["command_name"], + 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["ratification_id"]), + new_status, + ) + + +__all__ = ["RatificationCoverageProjection"] diff --git a/apps/api/tach.toml b/apps/api/tach.toml index 92eb2752186..bd4ce006f81 100644 --- a/apps/api/tach.toml +++ b/apps/api/tach.toml @@ -386,6 +386,12 @@ depends_on = [ # import: rule 2 stays inviolate. "cora.recipe.aggregates", "cora.caution.aggregates", + # RatificationEnforcer subscribers (consequence gate, Gate IV): + # - `cora.trust.aggregates` for `load_ratification` (the release + # subscriber resolves ratification_id -> target_action_id, since + # RatificationGranted carries only the ratification_id). Read-only + # cross-BC aggregate access, same shape as the other subscribers. + "cora.trust.aggregates", ] # API entrypoint composes every BC. diff --git a/apps/api/tests/integration/_helpers.py b/apps/api/tests/integration/_helpers.py index 4a3e0c1766b..9dabf82af62 100644 --- a/apps/api/tests/integration/_helpers.py +++ b/apps/api/tests/integration/_helpers.py @@ -47,6 +47,7 @@ Authorize, CautionLookup, ClearanceLookup, + ConsequenceLookup, CredentialLookup, EventStore, FacilityLookup, @@ -86,6 +87,7 @@ def build_postgres_deps( asset_lookup: AssetLookup | None = None, role_lookup: RoleLookup | None = None, run_actor_involvement_lookup: RunActorInvolvementLookup | None = None, + consequence_lookup: ConsequenceLookup | None = None, profile_store: ProfileStore | None = None, llm: LLM | None = None, ) -> Kernel: @@ -128,6 +130,7 @@ def build_postgres_deps( asset_lookup=asset_lookup, role_lookup=role_lookup, run_actor_involvement_lookup=run_actor_involvement_lookup, + consequence_lookup=consequence_lookup, profile_store=profile_store, llm=llm, ) diff --git a/apps/api/tests/integration/test_consequence_gate_postgres.py b/apps/api/tests/integration/test_consequence_gate_postgres.py new file mode 100644 index 00000000000..abaaaff5c4c --- /dev/null +++ b/apps/api/tests/integration/test_consequence_gate_postgres.py @@ -0,0 +1,341 @@ +"""End-to-end integration: the consequence gate (Gate IV) against real Postgres. + +Drives the whole refuse -> request -> hold -> grant -> release -> re-stop -> admit +flow through the real handlers, subscribers, and coverage projection: + + 1. seed a Running run started by an operator; + 2. stop_run REFUSED (RunRequiresRatificationError): StopRun is consequence-classed + and no Granted Ratification covers it (real PostgresConsequenceLookup, no stub); + 3. request_ratification(target_action_id=run_id, command_name="StopRun"); + 4. drain -> RatificationHoldSubscriber holds the run (RunHeld); + 5. grant_ratification by a SECOND, independent principal; + 6. drain -> coverage projection marks Granted + RatificationReleaseSubscriber + resumes the run (RunResumed -> Running); + 7. stop_run now ADMITTED (coverage present) -> Run is Stopped. + +Also pins the refuse path leaves the run Running (no premature stop). +""" + +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import asyncpg +import pytest + +from cora.agent.seed_ratification_enforcer import seed_ratification_enforcer_agent +from cora.agent.subscribers.ratification_hold import make_ratification_hold_subscriber +from cora.agent.subscribers.ratification_release import make_ratification_release_subscriber +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.projection import ProjectionRegistry, drain_projections +from cora.infrastructure.projection.bookmark import ensure_bookmarks +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.aggregates.run import RunRequiresRatificationError, RunStatus, load_run +from cora.run.features import start_run, stop_run +from cora.run.features.start_run import StartRun +from cora.run.features.stop_run import StopRun +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 cora.trust.adapters import PostgresConsequenceLookup +from cora.trust.features import deny_ratification, grant_ratification, request_ratification +from cora.trust.features.deny_ratification import DenyRatification +from cora.trust.features.grant_ratification import GrantRatification +from cora.trust.features.request_ratification import RequestRatification +from cora.trust.projections import RatificationCoverageProjection +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") +_OPERATOR = UUID("01900000-0000-7000-8000-0000000000b1") +_CO_SIGNER = UUID("01900000-0000-7000-8000-0000000000b2") + + +def _deps(db_pool: asyncpg.Pool) -> Kernel: + """Kernel with the REAL Postgres consequence lookup (not the AlwaysRatified + stub), so the gate actually consults the coverage projection.""" + return build_postgres_deps( + db_pool, + now=_NOW, + id_generator=UUIDv7Generator(), + consequence_lookup=PostgresConsequenceLookup(db_pool), + ) + + +async def _start_run_as(deps: Kernel, *, starter: UUID) -> UUID: + family_id = await define_family.bind(deps)( + DefineFamily(name="FlyMotion-cg", affordances=frozenset()), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + asset_id = await register_asset.bind(deps)( + RegisterAsset(name="Eiger-cg", 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="XRF cg", + needed_family_ids=frozenset({family_id}), + ), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + practice_id = await define_practice.bind(deps)( + DefinePractice(name="APS cg", method_id=method_id, site_id=uuid4()), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + plan_id = await define_plan.bind(deps)( + DefinePlan(name="cg plan", 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="Sample-cg"), + 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="cg session", plan_id=plan_id, subject_id=subject_id), + principal_id=starter, + correlation_id=_CORRELATION_ID, + ) + + +async def _drain(db_pool: asyncpg.Pool, deps: Kernel) -> None: + """Drain the coverage projection + both enforcer subscribers together. + + Seeds bookmarks via the production `ensure_bookmarks` helper (the same one the + projection worker lifespan runs at startup) so the Reactions have a cursor: the + coverage projection's row comes from its migration, the two subscribers' rows + from ensure_bookmarks. The bare `drain_projections` helper does not seed (only + the lifespan does), so the test invokes the seam explicitly. + """ + registry = ProjectionRegistry() + registry.register(RatificationCoverageProjection()) + registry.register(make_ratification_hold_subscriber(deps)) + registry.register(make_ratification_release_subscriber(deps)) + await ensure_bookmarks(db_pool, registry.names()) + await drain_projections(db_pool, registry, deadline_seconds=2.0) + + +async def _status(deps: Kernel, run_id: UUID) -> RunStatus: + """Load a run and return its status, asserting it exists (test convenience).""" + state = await load_run(deps.event_store, run_id) + assert state is not None + return state.status + + +@pytest.mark.integration +async def test_consequence_gate_full_cosign_flow(db_pool: asyncpg.Pool) -> None: + deps = _deps(db_pool) + await seed_ratification_enforcer_agent(deps) + run_id = await _start_run_as(deps, starter=_OPERATOR) + + # 1. Uncovered stop is REFUSED. + with pytest.raises(RunRequiresRatificationError): + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + assert await _status(deps, run_id) is RunStatus.RUNNING + + # 2. Operator requests a ratification for this action (caller-supplied id). + ratification_id = uuid4() + await request_ratification.bind(deps)( + RequestRatification( + ratification_id=ratification_id, + target_action_id=run_id, + command_name="StopRun", + consequence_class="irreversible", + ), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + + # 3. Drain -> hold subscriber parks the run. + await _drain(db_pool, deps) + assert await _status(deps, run_id) is RunStatus.HELD + + # 4. A SECOND, independent principal grants the co-signature. + await grant_ratification.bind(deps)( + GrantRatification(ratification_id=ratification_id), + principal_id=_CO_SIGNER, + correlation_id=_CORRELATION_ID, + ) + + # 5. Drain -> coverage marked Granted + release subscriber resumes the run. + await _drain(db_pool, deps) + assert await _status(deps, run_id) is RunStatus.RUNNING + + # 6. Re-issue stop_run: now covered, so it is ADMITTED. + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + assert await _status(deps, run_id) is RunStatus.STOPPED + + +@pytest.mark.integration +async def test_coverage_projection_replay_is_idempotent(db_pool: asyncpg.Pool) -> None: + """Re-draining the coverage projection from a reset bookmark (a rebuild) yields + one row in its final state: the INSERT's ON CONFLICT DO NOTHING + set-to-value + UPDATE make the fold idempotent, so a replayed RatificationRequested+Granted + does not double-insert or crash.""" + deps = _deps(db_pool) + await seed_ratification_enforcer_agent(deps) + run_id = await _start_run_as(deps, starter=_OPERATOR) + ratification_id = uuid4() + await request_ratification.bind(deps)( + RequestRatification( + ratification_id=ratification_id, + target_action_id=run_id, + command_name="StopRun", + consequence_class="irreversible", + ), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool, deps) + await grant_ratification.bind(deps)( + GrantRatification(ratification_id=ratification_id), + principal_id=_CO_SIGNER, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool, deps) + + # Force a full replay of the coverage projection from zero. + 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_trust_ratification_coverage'" + ) + await _drain(db_pool, deps) + + async with db_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT status FROM proj_trust_ratification_coverage WHERE ratification_id = $1", + ratification_id, + ) + assert len(rows) == 1 + assert rows[0]["status"] == "Granted" + + +@pytest.mark.integration +async def test_requested_not_yet_granted_still_refuses(db_pool: asyncpg.Pool) -> None: + """The ConsequenceLookup Granted-only filter: a merely Requested (pending) + ratification does NOT count as coverage. After request + drain (run Held, + coverage=Requested), a re-issued stop still REFUSES.""" + deps = _deps(db_pool) + await seed_ratification_enforcer_agent(deps) + run_id = await _start_run_as(deps, starter=_OPERATOR) + + with pytest.raises(RunRequiresRatificationError): + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + + ratification_id = uuid4() + await request_ratification.bind(deps)( + RequestRatification( + ratification_id=ratification_id, + target_action_id=run_id, + command_name="StopRun", + consequence_class="irreversible", + ), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool, deps) + assert await _status(deps, run_id) is RunStatus.HELD + + # Coverage is only Requested, not Granted -> the gate still refuses. (The run + # is Held so this raise happens after the gate, but the point is the gate does + # not admit on a pending request.) + with pytest.raises(RunRequiresRatificationError): + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + + +@pytest.mark.integration +async def test_denied_resumes_run_and_leaves_coverage_absent(db_pool: asyncpg.Pool) -> None: + """Denial un-parks the run (back to Running) and never grants coverage, so a + re-issued stop still refuses. The reversible hold stays reversible.""" + deps = _deps(db_pool) + await seed_ratification_enforcer_agent(deps) + run_id = await _start_run_as(deps, starter=_OPERATOR) + + with pytest.raises(RunRequiresRatificationError): + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + ratification_id = uuid4() + await request_ratification.bind(deps)( + RequestRatification( + ratification_id=ratification_id, + target_action_id=run_id, + command_name="StopRun", + consequence_class="irreversible", + ), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool, deps) + assert await _status(deps, run_id) is RunStatus.HELD + + # A second independent principal DENIES the co-signature. + await deny_ratification.bind(deps)( + DenyRatification(ratification_id=ratification_id, reason="not this time"), + principal_id=_CO_SIGNER, + correlation_id=_CORRELATION_ID, + ) + await _drain(db_pool, deps) + + # Run un-parked (Running again), and coverage never became Granted so a + # re-issued stop refuses. + assert await _status(deps, run_id) is RunStatus.RUNNING + with pytest.raises(RunRequiresRatificationError): + await stop_run.bind(deps)( + StopRun(run_id=run_id, reason="end early"), + principal_id=_OPERATOR, + correlation_id=_CORRELATION_ID, + ) 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 ea5b46f068d..9bfce86d943 100644 --- a/apps/api/tests/unit/agent/test_agent_subscribers_registration.py +++ b/apps/api/tests/unit/agent/test_agent_subscribers_registration.py @@ -66,6 +66,21 @@ def test_registers_authority_revocation_holder_unconditionally() -> None: assert "authority_revocation_holder" in registry.names() +@pytest.mark.unit +def test_registers_ratification_enforcer_unconditionally() -> None: + """The consequence gate (Gate IV) hold + release subscribers register ON BY + DEFAULT (no LLM, default settings): without them a refused stop is never parked + / never un-parked, so the gate's shared-hold discharge would not fire. Pins the + on-by-default contract for both subscribers.""" + registry = ProjectionRegistry() + kernel = _kernel(llm=None) + + register_agent_subscribers(registry, kernel) # type: ignore[arg-type] + + assert "ratification_hold" in registry.names() + assert "ratification_release" 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_ratification_enforcer_subscriber.py b/apps/api/tests/unit/agent/test_ratification_enforcer_subscriber.py new file mode 100644 index 00000000000..e8a4ae89942 --- /dev/null +++ b/apps/api/tests/unit/agent/test_ratification_enforcer_subscriber.py @@ -0,0 +1,326 @@ +"""Unit tests for the RatificationEnforcer subscribers (consequence gate, Gate IV). + +Against an in-memory kernel: the hold subscriber reacts to RatificationRequested +by holding the target Running run; the release subscriber reacts to +RatificationGranted by resuming the held run. Both guard status (Running for hold, +Held for release), stand down when unseeded, and are no-ops off the happy path. +""" + +# white-box test of the subscriber internals +# pyright: reportPrivateUsage=false, reportUnknownArgumentType=false, reportUnknownMemberType=false + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.agent.seed_ratification_enforcer import ( + RATIFICATION_ENFORCER_AGENT_ID, + seed_ratification_enforcer_agent, +) +from cora.agent.subscribers.ratification_hold import make_ratification_hold_subscriber +from cora.agent.subscribers.ratification_release import make_ratification_release_subscriber +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, 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 +from cora.trust.aggregates.ratification import event_type_name as rat_event_type_name +from cora.trust.aggregates.ratification import to_payload as rat_to_payload +from cora.trust.aggregates.ratification.events import RatificationRequested + +_NOW = datetime(2026, 7, 6, 12, 0, 0, tzinfo=UTC) + + +def _kernel() -> Kernel: + return make_inmemory_kernel( + settings=Settings(), # type: ignore[call-arg] + clock=FakeClock(_NOW), + id_generator=UUIDv7Generator(), + authz=AllowAllAuthorize(), + ) + + +async def _seed_running_run(store: EventStore) -> UUID: + run_id = uuid4() + started = RunStarted( + run_id=run_id, name="gated run", plan_id=uuid4(), subject_id=uuid4(), occurred_at=_NOW + ) + await store.append( + "Run", + run_id, + 0, + [ + 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=uuid4(), + ) + ], + ) + return run_id + + +async def _seed_held_run(store: EventStore, *, held_by: UUID | None = None) -> UUID: + """Seed a Running run then a RunHeld. `held_by` is the hold's envelope principal + (who placed the hold); defaults to the enforcer, so the release's correlation + guard treats it as its own hold. Pass a foreign id to simulate a kill-switch / + operator hold the release must NOT clear.""" + run_id = await _seed_running_run(store) + held = RunHeld(run_id=run_id, decided_by_decision_id=None, occurred_at=_NOW) + await store.append( + "Run", + run_id, + 1, + [ + 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=held_by if held_by is not None else RATIFICATION_ENFORCER_AGENT_ID, + ) + ], + ) + return run_id + + +async def _seed_ratification(store: EventStore, *, target_run_id: UUID) -> UUID: + """Append a RatificationRequested so load_ratification resolves the target.""" + ratification_id = uuid4() + requested = RatificationRequested( + ratification_id=ratification_id, + target_action_id=target_run_id, + command_name="StopRun", + consequence_class="irreversible", + requested_by=uuid4(), + occurred_at=_NOW, + ) + await store.append( + "Ratification", + ratification_id, + 0, + [ + to_new_event( + event_type=rat_event_type_name(requested), + payload=rat_to_payload(requested), + occurred_at=_NOW, + event_id=uuid4(), + command_name="RequestRatification", + correlation_id=uuid4(), + causation_id=None, + principal_id=uuid4(), + ) + ], + ) + return ratification_id + + +def _requested_event(*, target_run_id: UUID) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Ratification", + stream_id=uuid4(), + version=1, + event_type="RatificationRequested", + schema_version=1, + payload={ + "ratification_id": str(uuid4()), + "target_action_id": str(target_run_id), + "command_name": "StopRun", + "consequence_class": "irreversible", + "requested_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +def _granted_event(*, ratification_id: UUID) -> StoredEvent: + return StoredEvent( + position=2, + event_id=uuid4(), + stream_type="Ratification", + stream_id=ratification_id, + version=2, + event_type="RatificationGranted", + schema_version=1, + payload={"ratification_id": str(ratification_id), "occurred_at": _NOW.isoformat()}, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +def _denied_event(*, ratification_id: UUID) -> StoredEvent: + return StoredEvent( + position=2, + event_id=uuid4(), + stream_type="Ratification", + stream_id=ratification_id, + version=2, + event_type="RatificationDenied", + schema_version=1, + payload={ + "ratification_id": str(ratification_id), + "reason": "co-sign refused", + "occurred_at": _NOW.isoformat(), + }, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +# --- hold subscriber --- + + +@pytest.mark.unit +async def test_hold_subscriber_holds_the_target_running_run() -> None: + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + run_id = await _seed_running_run(kernel.event_store) + sub = make_ratification_hold_subscriber(kernel) + + await sub.apply(_requested_event(target_run_id=run_id), conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None and state.status is RunStatus.HELD + + +@pytest.mark.unit +async def test_hold_subscriber_noop_when_run_not_running() -> None: + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + run_id = await _seed_held_run(kernel.event_store) # already Held + sub = make_ratification_hold_subscriber(kernel) + + await sub.apply(_requested_event(target_run_id=run_id), conn=None) # type: ignore[arg-type] + + # Still exactly one RunHeld (no second hold appended). + stored, _ = await kernel.event_store.load("Run", run_id) + assert sum(1 for s in stored if s.event_type == "RunHeld") == 1 + + +@pytest.mark.unit +async def test_hold_subscriber_stands_down_when_unseeded() -> None: + kernel = _kernel() # NOT seeded + run_id = await _seed_running_run(kernel.event_store) + sub = make_ratification_hold_subscriber(kernel) + + await sub.apply(_requested_event(target_run_id=run_id), conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None and state.status is RunStatus.RUNNING + + +# --- release subscriber --- + + +@pytest.mark.unit +async def test_release_subscriber_resumes_the_held_run() -> None: + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + run_id = await _seed_held_run(kernel.event_store) + ratification_id = await _seed_ratification(kernel.event_store, target_run_id=run_id) + sub = make_ratification_release_subscriber(kernel) + + await sub.apply(_granted_event(ratification_id=ratification_id), conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None and state.status is RunStatus.RUNNING + + +@pytest.mark.unit +async def test_release_subscriber_noop_when_run_not_held() -> None: + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + run_id = await _seed_running_run(kernel.event_store) # Running, not Held + ratification_id = await _seed_ratification(kernel.event_store, target_run_id=run_id) + sub = make_ratification_release_subscriber(kernel) + + await sub.apply(_granted_event(ratification_id=ratification_id), conn=None) # type: ignore[arg-type] + + stored, _ = await kernel.event_store.load("Run", run_id) + assert sum(1 for s in stored if s.event_type == "RunResumed") == 0 + + +@pytest.mark.unit +async def test_release_subscriber_ignores_unknown_ratification() -> None: + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + sub = make_ratification_release_subscriber(kernel) + # A Granted event whose ratification stream does not exist -> no crash, no-op. + await sub.apply(_granted_event(ratification_id=uuid4()), conn=None) # type: ignore[arg-type] + + +@pytest.mark.unit +async def test_release_subscriber_resumes_on_denial() -> None: + """A DENIED ratification also un-parks the run: leaving it Held forever would + make the reversible hold un-reversible. The run returns to Running; the stop + stays un-admitted because coverage is never Granted.""" + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + run_id = await _seed_held_run(kernel.event_store) + ratification_id = await _seed_ratification(kernel.event_store, target_run_id=run_id) + sub = make_ratification_release_subscriber(kernel) + + await sub.apply(_denied_event(ratification_id=ratification_id), conn=None) # type: ignore[arg-type] + + state = await load_run(kernel.event_store, run_id) + assert state is not None and state.status is RunStatus.RUNNING + + +@pytest.mark.unit +async def test_release_does_not_clear_a_foreign_hold() -> None: + """The load-bearing correlation guard: a run Held by a DIFFERENT concern (e.g. + the kill-switch, a foreign principal) must NOT be resumed by a stray + RatificationGranted, or the release would defeat that hold.""" + kernel = _kernel() + await seed_ratification_enforcer_agent(kernel) + foreign_holder = uuid4() # e.g. the kill-switch's authority_revocation_holder + run_id = await _seed_held_run(kernel.event_store, held_by=foreign_holder) + ratification_id = await _seed_ratification(kernel.event_store, target_run_id=run_id) + sub = make_ratification_release_subscriber(kernel) + + await sub.apply(_granted_event(ratification_id=ratification_id), conn=None) # type: ignore[arg-type] + + # The foreign hold survives: still Held, no RunResumed appended. + state = await load_run(kernel.event_store, run_id) + assert state is not None and state.status is RunStatus.HELD + stored, _ = await kernel.event_store.load("Run", run_id) + assert sum(1 for s in stored if s.event_type == "RunResumed") == 0 + + +@pytest.mark.unit +async def test_make_subscribers_wire_metadata() -> None: + kernel = _kernel() + hold = make_ratification_hold_subscriber(kernel) + release = make_ratification_release_subscriber(kernel) + assert hold.name == "ratification_hold" + assert hold.subscribed_event_types == frozenset({"RatificationRequested"}) + assert release.name == "ratification_release" + assert release.subscribed_event_types == frozenset( + {"RatificationGranted", "RatificationDenied"} + ) diff --git a/apps/api/tests/unit/run/test_stop_run_consequence_gate.py b/apps/api/tests/unit/run/test_stop_run_consequence_gate.py new file mode 100644 index 00000000000..4d9196fec08 --- /dev/null +++ b/apps/api/tests/unit/run/test_stop_run_consequence_gate.py @@ -0,0 +1,99 @@ +"""Unit tests for the consequence gate (Gate IV) arm on the stop_run decider. + +StopRun is in the declared consequence class, so the decider refuses with +RunRequiresRatificationError when coverage is absent, and admits (falls through to +the normal transition) when coverage is present. The gate is the OUTER +precondition: it is checked before state/status, and it is kind-blind (no actor +kind is read). +""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.run.aggregates.run import ( + Run, + RunName, + RunRequiresRatificationError, + RunStatus, + RunStopped, +) +from cora.run.features.stop_run import decide +from cora.run.features.stop_run.command import StopRun + +_NOW = datetime(2026, 7, 6, 12, 0, 0, tzinfo=UTC) + + +def _running_run(run_id: UUID) -> Run: + return Run( + id=run_id, + name=RunName("test run"), + plan_id=uuid4(), + subject_id=None, + status=RunStatus.RUNNING, + ) + + +@pytest.mark.unit +def test_uncovered_stop_is_refused_with_ratification_error() -> None: + run_id = uuid4() + with pytest.raises(RunRequiresRatificationError) as exc: + decide( + state=_running_run(run_id), + command=StopRun(run_id=run_id, reason="end session early"), + now=_NOW, + ratification_covered=False, + ) + assert exc.value.run_id == run_id + assert exc.value.command_name == "StopRun" + + +@pytest.mark.unit +def test_covered_stop_is_admitted() -> None: + run_id = uuid4() + events = decide( + state=_running_run(run_id), + command=StopRun(run_id=run_id, reason="end session early"), + now=_NOW, + ratification_covered=True, + ) + assert len(events) == 1 + assert isinstance(events[0], RunStopped) + assert events[0].run_id == run_id + + +@pytest.mark.unit +def test_gate_precedes_state_check() -> None: + """The consequence gate is the OUTER precondition: an uncovered stop on a + missing run raises RunRequiresRatificationError (the gate), NOT + RunNotFoundError. Admission is decided before existence.""" + run_id = uuid4() + with pytest.raises(RunRequiresRatificationError): + decide( + state=None, + command=StopRun(run_id=run_id, reason="x"), + now=_NOW, + ratification_covered=False, + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "status", + [RunStatus.RUNNING, RunStatus.HELD, RunStatus.COMPLETED, RunStatus.ABORTED, RunStatus.STOPPED], +) +@pytest.mark.parametrize("reason", ["ok reason", "", "x" * 999]) +def test_uncovered_stop_always_refuses_regardless_of_state(status: RunStatus, reason: str) -> None: + """The gate is TOTAL and OUTERMOST: for any run status and any reason (even a + malformed one), an uncovered StopRun raises RunRequiresRatificationError before + the reason-validation / status-transition checks can raise anything else.""" + run_id = uuid4() + state = Run(id=run_id, name=RunName("r"), plan_id=uuid4(), subject_id=None, status=status) + with pytest.raises(RunRequiresRatificationError): + decide( + state=state, + command=StopRun(run_id=run_id, reason=reason), + now=_NOW, + ratification_covered=False, + ) diff --git a/apps/api/tests/unit/run/test_stop_run_decider.py b/apps/api/tests/unit/run/test_stop_run_decider.py index fc0e7849b7d..ac98633616d 100644 --- a/apps/api/tests/unit/run/test_stop_run_decider.py +++ b/apps/api/tests/unit/run/test_stop_run_decider.py @@ -42,6 +42,7 @@ def test_decide_emits_run_stopped_for_running_state() -> None: state=state, command=StopRun(run_id=state.id, reason="hit time budget cleanly"), now=_NOW, + ratification_covered=True, ) assert events == [ RunStopped( @@ -60,6 +61,7 @@ def test_decide_accepts_held_source_state() -> None: state=state, command=StopRun(run_id=state.id, reason="ending early during hold"), now=_NOW, + ratification_covered=True, ) assert len(events) == 1 assert events[0].reason == "ending early during hold" @@ -72,6 +74,7 @@ def test_decide_trims_reason_via_value_object() -> None: state=state, command=StopRun(run_id=state.id, reason=" scan complete; ending early "), now=_NOW, + ratification_covered=True, ) assert events[0].reason == "scan complete; ending early" @@ -84,6 +87,7 @@ def test_decide_raises_run_not_found_when_state_is_none() -> None: state=None, command=StopRun(run_id=target_id, reason="X"), now=_NOW, + ratification_covered=True, ) assert exc_info.value.run_id == target_id @@ -96,6 +100,7 @@ def test_decide_raises_invalid_reason_for_whitespace_only() -> None: state=state, command=StopRun(run_id=state.id, reason=" "), now=_NOW, + ratification_covered=True, ) @@ -107,6 +112,7 @@ def test_decide_raises_invalid_reason_for_too_long() -> None: state=state, command=StopRun(run_id=state.id, reason="a" * 501), now=_NOW, + ratification_covered=True, ) @@ -122,6 +128,7 @@ def test_decide_raises_cannot_stop_from_any_terminal(terminal: RunStatus) -> Non state=state, command=StopRun(run_id=state.id, reason="X"), now=_NOW, + ratification_covered=True, ) assert exc_info.value.current_status is terminal @@ -134,6 +141,7 @@ def test_decide_error_message_names_required_running_or_held_status() -> None: state=state, command=StopRun(run_id=state.id, reason="X"), now=_NOW, + ratification_covered=True, ) msg = str(exc_info.value) assert "Running" in msg @@ -144,8 +152,8 @@ def test_decide_error_message_names_required_running_or_held_status() -> None: def test_decide_is_pure_same_inputs_same_outputs() -> None: state = _run() command = StopRun(run_id=state.id, reason="X") - first = stop_run.decide(state=state, command=command, now=_NOW) - second = stop_run.decide(state=state, command=command, now=_NOW) + first = stop_run.decide(state=state, command=command, now=_NOW, ratification_covered=True) + second = stop_run.decide(state=state, command=command, now=_NOW, ratification_covered=True) assert first == second @@ -157,6 +165,7 @@ def test_decide_defaults_decided_by_decision_id_to_none_when_omitted() -> None: state=state, command=StopRun(run_id=state.id, reason="hit time budget cleanly"), now=_NOW, + ratification_covered=True, ) assert events[0].decided_by_decision_id is None @@ -174,5 +183,6 @@ def test_decide_threads_decided_by_decision_id_through_to_event() -> None: decided_by_decision_id=decision_id, ), now=_NOW, + ratification_covered=True, ) assert events[0].decided_by_decision_id == decision_id diff --git a/apps/api/tests/unit/run/test_stop_run_decider_properties.py b/apps/api/tests/unit/run/test_stop_run_decider_properties.py index 010650fcb22..0c6913b33f3 100644 --- a/apps/api/tests/unit/run/test_stop_run_decider_properties.py +++ b/apps/api/tests/unit/run/test_stop_run_decider_properties.py @@ -69,7 +69,12 @@ def test_stop_with_none_state_always_raises_not_found( ) -> None: """Empty stream always raises `RunNotFoundError` carrying command.run_id.""" with pytest.raises(RunNotFoundError) as exc: - stop_run.decide(state=None, command=StopRun(run_id=run_id, reason=reason), now=now) + stop_run.decide( + state=None, + command=StopRun(run_id=run_id, reason=reason), + now=now, + ratification_covered=True, + ) assert exc.value.run_id == run_id @@ -91,6 +96,7 @@ def test_stop_from_permitted_source_emits_single_event( state=_run(run_id=run_id, status=source), command=StopRun(run_id=run_id, reason=reason), now=now, + ratification_covered=True, ) assert events == [RunStopped(run_id=run_id, reason=reason, occurred_at=now)] @@ -118,6 +124,7 @@ def test_stop_from_terminal_source_always_raises_cannot_stop( state=_run(run_id=run_id, status=source), command=StopRun(run_id=run_id, reason=reason), now=now, + ratification_covered=True, ) assert exc.value.current_status is source @@ -143,6 +150,7 @@ def test_stop_uses_state_id_not_command_run_id( state=_run(run_id=state_run_id, status=source), command=StopRun(run_id=command_run_id, reason=reason), now=now, + ratification_covered=True, ) assert events[0].run_id == state_run_id @@ -157,6 +165,6 @@ def test_stop_is_pure_same_input_same_output( """Two calls with identical args return equal events (no clock leakage).""" state = _run(run_id=run_id, status=RunStatus.RUNNING) command = StopRun(run_id=run_id, reason=reason) - first = stop_run.decide(state=state, command=command, now=now) - second = stop_run.decide(state=state, command=command, now=now) + first = stop_run.decide(state=state, command=command, now=now, ratification_covered=True) + second = stop_run.decide(state=state, command=command, now=now, ratification_covered=True) assert first == second diff --git a/apps/api/tests/unit/trust/test_ratification_coverage_projection.py b/apps/api/tests/unit/trust/test_ratification_coverage_projection.py new file mode 100644 index 00000000000..d66f0329714 --- /dev/null +++ b/apps/api/tests/unit/trust/test_ratification_coverage_projection.py @@ -0,0 +1,104 @@ +"""Unit tests for RatificationCoverageProjection (consequence gate coverage). + +Pins per-event apply() dispatch against a mocked connection: RatificationRequested +INSERTs a Requested row carrying target_action_id + command_name from the genesis +payload; Granted/Denied UPDATE status by ratification_id; an unsubscribed event is +a no-op. The Postgres-side behavior (the actual rows + the Granted filter) is in +the integration/lookup test. +""" + +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.trust.projections import RatificationCoverageProjection + +_RATIFICATION_ID = uuid4() +_RUN_ID = uuid4() +_NOW = datetime(2026, 7, 6, 14, 0, 0, tzinfo=UTC) + + +def _stored(event_type: str, payload: dict[str, Any]) -> StoredEvent: + return StoredEvent( + position=1, + event_id=uuid4(), + stream_type="Ratification", + stream_id=_RATIFICATION_ID, + version=1, + event_type=event_type, + schema_version=1, + payload=payload, + correlation_id=uuid4(), + causation_id=None, + occurred_at=_NOW, + recorded_at=_NOW, + principal_id=uuid4(), + ) + + +@pytest.mark.unit +def test_projection_metadata() -> None: + proj = RatificationCoverageProjection() + assert proj.name == "proj_trust_ratification_coverage" + assert proj.subscribed_event_types == frozenset( + {"RatificationRequested", "RatificationGranted", "RatificationDenied"} + ) + + +@pytest.mark.unit +async def test_requested_inserts_scope_from_genesis_payload() -> None: + conn = AsyncMock() + proj = RatificationCoverageProjection() + await proj.apply( + _stored( + "RatificationRequested", + { + "ratification_id": str(_RATIFICATION_ID), + "target_action_id": str(_RUN_ID), + "command_name": "StopRun", + "consequence_class": "irreversible", + "requested_by": str(uuid4()), + "occurred_at": _NOW.isoformat(), + }, + ), + conn, + ) + conn.execute.assert_awaited_once() + args = conn.execute.await_args.args + assert "INSERT INTO proj_trust_ratification_coverage" in args[0] + assert args[1] == _RATIFICATION_ID + assert args[2] == _RUN_ID + assert args[3] == "StopRun" + # created_at is the envelope occurred_at. + assert args[4] == _NOW + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("event_type", "expected_status"), + [("RatificationGranted", "Granted"), ("RatificationDenied", "Denied")], +) +async def test_transition_updates_status_by_ratification_id( + event_type: str, expected_status: str +) -> None: + conn = AsyncMock() + proj = RatificationCoverageProjection() + payload = {"ratification_id": str(_RATIFICATION_ID), "occurred_at": _NOW.isoformat()} + await proj.apply(_stored(event_type, payload), conn) + conn.execute.assert_awaited_once() + args = conn.execute.await_args.args + assert "UPDATE proj_trust_ratification_coverage" in args[0] + assert args[1] == _RATIFICATION_ID + assert args[2] == expected_status + + +@pytest.mark.unit +async def test_unsubscribed_event_is_a_noop() -> None: + conn = AsyncMock() + proj = RatificationCoverageProjection() + await proj.apply(_stored("PolicyDefined", {"ratification_id": str(_RATIFICATION_ID)}), conn) + conn.execute.assert_not_awaited() diff --git a/infra/atlas/migrations/20260706000000_init_proj_trust_ratification_coverage.sql b/infra/atlas/migrations/20260706000000_init_proj_trust_ratification_coverage.sql new file mode 100644 index 00000000000..5f1b325b108 --- /dev/null +++ b/infra/atlas/migrations/20260706000000_init_proj_trust_ratification_coverage.sql @@ -0,0 +1,42 @@ +-- Consequence gate (Gate IV): the ratification-coverage read model. +-- +-- Answers "is action (target_action_id, command_name) covered by a GRANTED +-- Ratification?" so Run BC's stop_run gate (via the ConsequenceLookup port) can +-- admit a consequence-classed command only after a second, independent principal +-- has co-signed. The target action is the run being stopped, so the consumer +-- passes run_id as target_action_id. +-- +-- Folded from Ratification events. The scope keys (target_action_id, +-- command_name) live only on the genesis RatificationRequested; Granted/Denied +-- carry ratification_id alone, so the projection carries the scope from genesis +-- and folds status forward by ratification_id: +-- RatificationRequested -> INSERT (status='Requested') +-- RatificationGranted -> UPDATE status='Granted' +-- RatificationDenied -> UPDATE status='Denied' +-- +-- The lookup filters to status='Granted'. Denied/Requested rows are retained +-- (auditable); coverage keys on status, not row presence. +-- +-- Mutable read model. cora_app gets full DML. + +CREATE TABLE proj_trust_ratification_coverage ( + ratification_id UUID PRIMARY KEY, + target_action_id UUID NOT NULL, + command_name TEXT NOT NULL, + status TEXT NOT NULL CHECK ( + status IN ('Requested', 'Granted', 'Denied') + ), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The lookup's hot path: is (target_action_id, command_name) Granted? +CREATE INDEX proj_trust_ratification_coverage_scope_idx + ON proj_trust_ratification_coverage (target_action_id, command_name, status); + +GRANT SELECT, INSERT, UPDATE, DELETE + ON proj_trust_ratification_coverage TO cora_app; + +INSERT INTO projection_bookmarks (name) +VALUES ('proj_trust_ratification_coverage') +ON CONFLICT DO NOTHING; diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index f2de8f90647..cd8156692f1 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:nbGdEKznh7y8O9/XylcvRr0vHmIA3pEH6hRPvc5CHnk= +h1:LrBOk59Yp2sE2L8tLKVj8ub9cHMVU+GTR7cEn8QDzeY= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -155,3 +155,4 @@ h1:nbGdEKznh7y8O9/XylcvRr0vHmIA3pEH6hRPvc5CHnk= 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= +20260706000000_init_proj_trust_ratification_coverage.sql h1:YDYrypgQNOid1GnlfYbXI0j7300NUcGdu5L0Tsev/3I=