diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 298655cd9..9bf765e63 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7384,6 +7384,36 @@ "title": "PromoteDatasetRequest", "type": "object" }, + "PublishCalibrationRevisionRequest": { + "description": "Body for `POST /calibrations/{id}/revisions/{revision_id}/publish`.", + "properties": { + "peer_facility_id": { + "description": "Opaque id of the peer facility this publication is targeted at. Resolved at the handler via PermitLookup to locate the matching Active outbound Permit; missing or inactive permits raise 409.", + "title": "Peer Facility Id", + "type": "string" + } + }, + "required": [ + "peer_facility_id" + ], + "title": "PublishCalibrationRevisionRequest", + "type": "object" + }, + "PublishCalibrationRevisionResponse": { + "description": "Response body for the publish action.", + "properties": { + "receipt_id": { + "format": "uuid", + "title": "Receipt Id", + "type": "string" + } + }, + "required": [ + "receipt_id" + ], + "title": "PublishCalibrationRevisionResponse", + "type": "object" + }, "RateDecisionRequest": { "description": "Body for `POST /decisions/{decision_id}/ratings`.", "properties": { @@ -15690,6 +15720,138 @@ ] } }, + "/calibrations/{calibration_id}/revisions/{revision_id}/publish": { + "post": { + "operationId": "post_publish_calibration_revision_calibrations__calibration_id__revisions__revision_id__publish_post", + "parameters": [ + { + "description": "Target calibration's id.", + "in": "path", + "name": "calibration_id", + "required": true, + "schema": { + "description": "Target calibration's id.", + "format": "uuid", + "title": "Calibration Id", + "type": "string" + } + }, + { + "description": "Revision on that calibration to publish.", + "in": "path", + "name": "revision_id", + "required": true, + "schema": { + "description": "Revision on that calibration to publish.", + "format": "uuid", + "title": "Revision Id", + "type": "string" + } + }, + { + "in": "header", + "name": "Idempotency-Key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Idempotency-Key" + } + }, + { + "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/PublishCalibrationRevisionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublishCalibrationRevisionResponse" + } + } + }, + "description": "Successful Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Authorize port denied the publish command." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No calibration or no revision exists with the given ids." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Publish-time FSM rejection: revision lacks content_hash, or no Active outbound Permit authorizes publishing this artifact to the peer." + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Publish an existing Calibration revision to a peer facility", + "tags": [ + "calibration" + ] + } + }, "/campaigns": { "get": { "operationId": "list_campaigns_campaigns_get", diff --git a/apps/api/src/cora/agent/subscribers/caution_drafter.py b/apps/api/src/cora/agent/subscribers/caution_drafter.py index 135d89ac5..28f893341 100644 --- a/apps/api/src/cora/agent/subscribers/caution_drafter.py +++ b/apps/api/src/cora/agent/subscribers/caution_drafter.py @@ -554,12 +554,17 @@ async def _maybe_sign(self, new_event: NewEvent, *, actor: Actor) -> NewEvent: """ if self.signer is None or new_event.event_type not in SIGNED_EVENT_TYPES: return new_event - signature, kid = await self.signer.sign( + signature, kid, signing_version = await self.signer.sign( event_type=new_event.event_type, payload=new_event.payload, actor_id=actor.id, ) - return replace(new_event, signature=signature, signature_kid=kid) + return replace( + new_event, + signature=signature, + signature_kid=kid, + signature_version=signing_version, + ) def _proposed_target_in_candidates(proposed: Any, valid_target_ids: frozenset[UUID]) -> bool: diff --git a/apps/api/src/cora/agent/subscribers/run_debriefer.py b/apps/api/src/cora/agent/subscribers/run_debriefer.py index e07d2e2ce..6cdd58383 100644 --- a/apps/api/src/cora/agent/subscribers/run_debriefer.py +++ b/apps/api/src/cora/agent/subscribers/run_debriefer.py @@ -581,12 +581,17 @@ async def _maybe_sign(self, new_event: NewEvent, *, actor: Actor) -> NewEvent: """ if self.signer is None or new_event.event_type not in SIGNED_EVENT_TYPES: return new_event - signature, kid = await self.signer.sign( + signature, kid, signing_version = await self.signer.sign( event_type=new_event.event_type, payload=new_event.payload, actor_id=actor.id, ) - return replace(new_event, signature=signature, signature_kid=kid) + return replace( + new_event, + signature=signature, + signature_kid=kid, + signature_version=signing_version, + ) def make_run_debriefer_subscriber(deps: Kernel) -> RunDebrieferSubscriber: diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py index 98a40ebdf..23f5d7e1d 100644 --- a/apps/api/src/cora/api/main.py +++ b/apps/api/src/cora/api/main.py @@ -110,6 +110,9 @@ wire_federation, ) from cora.federation.adapters import PostgresCredentialLookup +from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup +from cora.federation.adapters.in_memory_publish_port import InMemoryPublishPort +from cora.federation.adapters.in_memory_signature_port import InMemorySignaturePort from cora.infrastructure.auth.bearer_auth_middleware import BearerAuthMiddleware from cora.infrastructure.auth.exception_handlers import register_auth_exception_handlers from cora.infrastructure.config import Settings @@ -395,6 +398,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: caution_lookup_factory=PostgresCautionLookup, supply_lookup_factory=PostgresSupplyLookup, credential_lookup_factory=PostgresCredentialLookup, + # publish_revision slice deps: in-memory adapters + # wired by default until the rule-of-two trigger + # fires per project_federation_port_design.md. + # Production deployments will override these with + # the DSSE+Sigstore + COSE+SCITT wire-tier adapters + # when the wire-tier work lands. + publish_port_factory=InMemoryPublishPort, + signature_port_factory=InMemorySignaturePort, + permit_lookup_factory=InMemoryPermitLookup, llm_factory=build_llm, # Pass the create_app-time Settings through so tests # overriding identity_providers / require_auth / etc. diff --git a/apps/api/src/cora/calibration/aggregates/calibration/__init__.py b/apps/api/src/cora/calibration/aggregates/calibration/__init__.py index 85f892e5a..7ba7bcd90 100644 --- a/apps/api/src/cora/calibration/aggregates/calibration/__init__.py +++ b/apps/api/src/cora/calibration/aggregates/calibration/__init__.py @@ -10,6 +10,7 @@ CalibrationDefined, CalibrationEvent, CalibrationRevisionAppended, + CalibrationRevisionPublished, deserialize_source, event_type_name, from_stored, @@ -27,10 +28,12 @@ AssertedSource, Calibration, CalibrationAlreadyExistsError, + CalibrationCannotPublishRevisionError, CalibrationDescription, CalibrationIdentityAlreadyExistsError, CalibrationNotFoundError, CalibrationRevision, + CalibrationRevisionNotFoundError, CalibrationSource, CalibrationStatus, ComputedSource, @@ -40,6 +43,7 @@ InvalidCalibrationValueError, InvalidOperatingPointError, MeasuredSource, + OutboundPermitNotActiveError, SupersedesRevisionNotFoundError, reject_empty_against_required, ) @@ -49,6 +53,7 @@ "AssertedSource", "Calibration", "CalibrationAlreadyExistsError", + "CalibrationCannotPublishRevisionError", "CalibrationDefined", "CalibrationDescription", "CalibrationEvent", @@ -57,6 +62,8 @@ "CalibrationNotFoundError", "CalibrationRevision", "CalibrationRevisionAppended", + "CalibrationRevisionNotFoundError", + "CalibrationRevisionPublished", "CalibrationSource", "CalibrationStatus", "ComputedSource", @@ -66,6 +73,7 @@ "InvalidCalibrationValueError", "InvalidOperatingPointError", "MeasuredSource", + "OutboundPermitNotActiveError", "SupersedesRevisionNotFoundError", "deserialize_source", "event_type_name", diff --git a/apps/api/src/cora/calibration/aggregates/calibration/events.py b/apps/api/src/cora/calibration/aggregates/calibration/events.py index 5e6a9da54..79309707c 100644 --- a/apps/api/src/cora/calibration/aggregates/calibration/events.py +++ b/apps/api/src/cora/calibration/aggregates/calibration/events.py @@ -214,8 +214,64 @@ class CalibrationRevisionAppended: content_hash: str | None = None +@dataclass(frozen=True) +class CalibrationRevisionPublished: + """A revision was published to the federation surface under an outbound permit. + + Cross-BC iter-b federation event. Records the publication action + on the Calibration stream; the matching `PublicationReceiptRecorded` + on the Permit stream (Federation BC) lands atomically via the + handler's `EventStore.append_streams` call per cross-BC append- + streams discipline. + + Per [[project_federation_port_design]]: + - `signature_envelope_kind` is the SignatureEnvelope union + discriminator at port-tier; one of "dsse_static_jwks", + "dsse_sigstore_keyless", "cose_sign1_scitt" today. + - `signing_version` is the signing-recipe identifier per + [[project_canonicalization_port_design]] (the v1 default + is "cora/v1"); the verifier dispatches to the matching + SigningPort adapter via the SigningRegistry. + - `signature_bytes_hex` is the raw signature bytes encoded as + hex string for jsonb storage; the verifier decodes with + `bytes.fromhex(...)`. + - `signature_kid` is the adapter-specific key identifier. + - `receipt_id` is the UUID minted by the PublishPort adapter + (the cross-BC `PublicationReceiptRecorded` on the Permit + stream carries the same receipt_id for join purposes). + - `published_by_actor_id` is the envelope `principal_id` of + the publish-slice caller (for human-initiated publish); + AI agent publication goes through `promote_*_publication` + per the propose-then-promote pattern, and the handler + resolves the human promoter's actor_id. + - `publication_status` is the FSM position at publish time; + "Live" today. Yanked / Withdrawn transitions land in a + follow-up iteration. + + State-folding posture (Stage 3d2 canary): the evolver records + this event as a no-op fold on Calibration state today; the + publication block on `CalibrationRevision` is deferred to + Stage 3d3 alongside the projection write-path. The event is + the source of truth; aggregate read-back of publication + metadata lands when the projection materializes. + """ + + calibration_id: UUID + revision_id: UUID + outbound_permit_id: UUID + signature_envelope_kind: str + signing_version: str + signature_bytes_hex: str + signature_kid: str + receipt_id: UUID + published_at: datetime + published_by_actor_id: UUID + publication_status: str + occurred_at: datetime + + # Discriminated union of every event the Calibration aggregate emits. -CalibrationEvent = CalibrationDefined | CalibrationRevisionAppended +CalibrationEvent = CalibrationDefined | CalibrationRevisionAppended | CalibrationRevisionPublished def event_type_name(event: CalibrationEvent) -> str: @@ -289,6 +345,34 @@ def to_payload(event: CalibrationEvent) -> dict[str, Any]: if content_hash is not None: payload["content_hash"] = content_hash return payload + case CalibrationRevisionPublished( + calibration_id=calibration_id, + revision_id=revision_id, + outbound_permit_id=outbound_permit_id, + signature_envelope_kind=signature_envelope_kind, + signing_version=signing_version, + signature_bytes_hex=signature_bytes_hex, + signature_kid=signature_kid, + receipt_id=receipt_id, + published_at=published_at, + published_by_actor_id=published_by_actor_id, + publication_status=publication_status, + occurred_at=occurred_at, + ): + return { + "calibration_id": str(calibration_id), + "revision_id": str(revision_id), + "outbound_permit_id": str(outbound_permit_id), + "signature_envelope_kind": signature_envelope_kind, + "signing_version": signing_version, + "signature_bytes_hex": signature_bytes_hex, + "signature_kid": signature_kid, + "receipt_id": str(receipt_id), + "published_at": published_at.isoformat(), + "published_by_actor_id": str(published_by_actor_id), + "publication_status": publication_status, + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover # exhaustiveness guard assert_never(event) @@ -346,6 +430,29 @@ def _build_revision_appended() -> CalibrationRevisionAppended: _build_revision_appended, extra=(ValueError,), ) + case "CalibrationRevisionPublished": + + def _build_revision_published() -> CalibrationRevisionPublished: + return CalibrationRevisionPublished( + calibration_id=UUID(payload["calibration_id"]), + revision_id=UUID(payload["revision_id"]), + outbound_permit_id=UUID(payload["outbound_permit_id"]), + signature_envelope_kind=payload["signature_envelope_kind"], + signing_version=payload["signing_version"], + signature_bytes_hex=payload["signature_bytes_hex"], + signature_kid=payload["signature_kid"], + receipt_id=UUID(payload["receipt_id"]), + published_at=datetime.fromisoformat(payload["published_at"]), + published_by_actor_id=UUID(payload["published_by_actor_id"]), + publication_status=payload["publication_status"], + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ) + + return deserialize_or_raise( + "CalibrationRevisionPublished", + _build_revision_published, + extra=(ValueError,), + ) case unknown: msg = f"Unknown Calibration event type: {unknown!r}" raise ValueError(msg) @@ -355,6 +462,7 @@ def _build_revision_appended() -> CalibrationRevisionAppended: "CalibrationDefined", "CalibrationEvent", "CalibrationRevisionAppended", + "CalibrationRevisionPublished", "deserialize_source", "event_type_name", "from_stored", diff --git a/apps/api/src/cora/calibration/aggregates/calibration/evolver.py b/apps/api/src/cora/calibration/aggregates/calibration/evolver.py index bf5351927..8632a5a09 100644 --- a/apps/api/src/cora/calibration/aggregates/calibration/evolver.py +++ b/apps/api/src/cora/calibration/aggregates/calibration/evolver.py @@ -32,6 +32,7 @@ CalibrationDefined, CalibrationEvent, CalibrationRevisionAppended, + CalibrationRevisionPublished, deserialize_source, ) from cora.calibration.aggregates.calibration.state import ( @@ -114,6 +115,9 @@ def evolve(state: Calibration | None, event: CalibrationEvent) -> Calibration: revisions=(*prior.revisions, revision), defined_by_actor_id=prior.defined_by_actor_id, ) + case CalibrationRevisionPublished(): + prior = require_state(state, "CalibrationRevisionPublished") + return prior case _: # pragma: no cover # exhaustiveness guard assert_never(event) diff --git a/apps/api/src/cora/calibration/aggregates/calibration/state.py b/apps/api/src/cora/calibration/aggregates/calibration/state.py index d112af529..7ea060adb 100644 --- a/apps/api/src/cora/calibration/aggregates/calibration/state.py +++ b/apps/api/src/cora/calibration/aggregates/calibration/state.py @@ -277,6 +277,64 @@ def __init__( self.operating_point = operating_point +# --------------------------------------------------------------------------- +# publish_revision domain errors +# --------------------------------------------------------------------------- + + +class CalibrationRevisionNotFoundError(Exception): + """The named revision does not exist on this Calibration.""" + + def __init__(self, calibration_id: UUID, revision_id: UUID) -> None: + super().__init__(f"Calibration {calibration_id} has no revision {revision_id}") + self.calibration_id = calibration_id + self.revision_id = revision_id + + +class CalibrationCannotPublishRevisionError(Exception): + """The named revision was appended before content_hash was kernel-fused. + + Legacy pre-rollout revisions carry `content_hash = None` per the + additive-event pattern; publishing requires a deterministic + artifact hash. Operators re-append the revision to populate the + hash before retrying the publish. + """ + + def __init__(self, calibration_id: UUID, revision_id: UUID) -> None: + super().__init__( + f"Calibration {calibration_id} revision {revision_id} carries no content_hash; " + f"re-append the revision to populate it before publishing" + ) + self.calibration_id = calibration_id + self.revision_id = revision_id + + +class OutboundPermitNotActiveError(Exception): + """No Active outbound Permit authorizes publishing this artifact kind to the peer. + + Covers both "no Permit configured" (status surfaced as the + sentinel `""`) and "Permit exists but is not in + the Active FSM position" (`Defined`, `Suspended`, or `Revoked`). + The decider routes this to a single error class so operator + diagnostics surface a uniform "configure or activate the + outbound permit" message regardless of the underlying gap. + """ + + def __init__( + self, + peer_facility_id: str, + artifact_kind: str, + status: str, + ) -> None: + super().__init__( + f"No Active outbound Permit for peer={peer_facility_id!r} " + f"artifact_kind={artifact_kind!r} (current status={status!r})" + ) + self.peer_facility_id = peer_facility_id + self.artifact_kind = artifact_kind + self.status = status + + # --------------------------------------------------------------------------- # Shared value-validation helper (used by both define_calibration and # append_calibration_revision deciders) diff --git a/apps/api/src/cora/calibration/errors.py b/apps/api/src/cora/calibration/errors.py index fefafca06..71b59673a 100644 --- a/apps/api/src/cora/calibration/errors.py +++ b/apps/api/src/cora/calibration/errors.py @@ -20,3 +20,21 @@ class UnauthorizedError(Exception): def __init__(self, reason: str) -> None: super().__init__(reason) self.reason = reason + + +class PublishPortNotWiredError(RuntimeError): + """The publish-time Kernel deps (publish_port + signature_port + permit_lookup) + are not all wired. + + Raised by the publish_revision handler's bind() at startup so a + misconfigured deployment surfaces immediately instead of failing + silently mid-request. BC-application-layer error (lives here, not + on the aggregate kernel) because the failure mode is wiring, not + a domain invariant. + """ + + def __init__(self, missing: tuple[str, ...]) -> None: + super().__init__( + f"publish_revision handler requires Kernel deps {missing!r} to be non-None" + ) + self.missing = missing diff --git a/apps/api/src/cora/calibration/features/publish_revision/__init__.py b/apps/api/src/cora/calibration/features/publish_revision/__init__.py new file mode 100644 index 000000000..98ae5f2ef --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/__init__.py @@ -0,0 +1,35 @@ +"""publish_revision slice: publish a Calibration revision to a peer. + +Cross-BC publish slice that emits an atomic event pair ( +CalibrationRevisionPublished on the Calibration stream + +PublicationReceiptRecorded on the matching outbound Permit stream) +via EventStore.append_streams. The handler canonicalizes the +artifact, calls SignaturePort.sign, calls PublishPort.publish, then +appends both events atomically. +""" + +from cora.calibration.features.publish_revision import tool +from cora.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.calibration.features.publish_revision.decider import ( + PublishRevisionEvents, + decide, +) +from cora.calibration.features.publish_revision.handler import ( + Handler, + IdempotentHandler, + bind, +) +from cora.calibration.features.publish_revision.route import router + +__all__ = [ + "Handler", + "IdempotentHandler", + "PublishCalibrationRevision", + "PublishRevisionEvents", + "bind", + "decide", + "router", + "tool", +] diff --git a/apps/api/src/cora/calibration/features/publish_revision/command.py b/apps/api/src/cora/calibration/features/publish_revision/command.py new file mode 100644 index 000000000..771fd92bb --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/command.py @@ -0,0 +1,35 @@ +"""The `PublishCalibrationRevision` command: intent for the publish slice. + +Caller-controlled inputs for publishing a named revision of an +existing Calibration to a peer facility under an Active outbound +Permit: + + - `calibration_id`: target Calibration aggregate. + - `revision_id`: the revision on this Calibration to publish; the + decider raises `CalibrationRevisionNotFoundError` on miss and + `CalibrationRevisionMissingContentHashError` on legacy revisions + without a kernel-fused content_hash. + - `peer_facility_id`: opaque string id of the peer the publish is + intended for; resolved at the handler tier via PermitLookup to + locate the matching outbound Permit. + +Server-side concerns (signature envelope, receipt id, published_at +timestamp, published_by_actor_id) are injected by the handler from +infrastructure ports + the request envelope; the decider takes them +as separate parameters so the command DTO stays narrow. +""" + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class PublishCalibrationRevision: + """Publish an existing revision of a Calibration to a peer facility.""" + + calibration_id: UUID + revision_id: UUID + peer_facility_id: str + + +__all__ = ["PublishCalibrationRevision"] diff --git a/apps/api/src/cora/calibration/features/publish_revision/decider.py b/apps/api/src/cora/calibration/features/publish_revision/decider.py new file mode 100644 index 000000000..5ce81db9d --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/decider.py @@ -0,0 +1,175 @@ +"""Pure decider for the `PublishCalibrationRevision` command. + +Cross-BC federation decider. Validates the loaded Calibration +aggregate + the looked-up outbound Permit, then emits two events to +be persisted atomically by the handler via `EventStore.append_streams`: + + - `CalibrationRevisionPublished` onto the Calibration stream + - `PublicationReceiptRecorded` onto the matching outbound Permit stream + +The decider stays pure: handler-injected parameters carry the +SignatureEnvelope (from SignaturePort.sign), the receipt_id (from +PublishPort.publish), the wall-clock `now`, and the +published_by_actor_id (from the request envelope's principal_id). +The decider's job is to validate the publication is authorized + +deterministic, then transform the inputs into the locked event +shapes. + +Invariants: + - Calibration state must not be None -> `CalibrationNotFoundError` + - Named revision must exist on aggregate.revisions -> + `CalibrationRevisionNotFoundError` + - Revision must carry a non-null content_hash -> + `CalibrationCannotPublishRevisionError` + - PermitLookup must return an Active outbound Permit -> + `OutboundPermitNotActiveError` (covers miss + non-Active status) + +## What the decider does NOT validate + + - DCO chain shape: enforced at the verify-then-apply orchestrator + when the artifact is re-verified on the consumer side, plus the + architecture-fitness test that walks publish_* handlers asserting + `published_by_actor_id` resolves to a human Actor. The handler + composes the chain from the envelope principal at publish time; + the decider trusts that handler. + - Permit's terms allowing this artifact kind: the handler-tier + PermitLookup resolves outbound permits keyed on (peer_facility_id, + artifact_kind), so the lookup hitting at all is the kind-match + evidence. A future iteration may move this check earlier when + the lookup widens to return the matched terms. + - Signature shape: the SignaturePort.sign call already enforces + the envelope-vs-canonicalization-version invariant; the decider + trusts the envelope it receives. + - Idempotency of repeated publish: a follow-up iteration adds a + state-folded `is_published` check; today the decider always emits + new events on every call (handler is expected to wrap with + Idempotency-Key per the AppendRevision precedent). +""" + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from cora.calibration.aggregates.calibration import ( + Calibration, + CalibrationCannotPublishRevisionError, + CalibrationNotFoundError, + CalibrationRevision, + CalibrationRevisionNotFoundError, + CalibrationRevisionPublished, + OutboundPermitNotActiveError, +) +from cora.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.federation.aggregates.permit.events import PublicationReceiptRecorded +from cora.infrastructure.ports.federation import ( + PermitLookupResult, + SignatureEnvelope, +) + +_HOME_STREAM_TYPE = "Calibration" +_PUBLICATION_STATUS_LIVE = "Live" + + +@dataclass(frozen=True) +class PublishRevisionEvents: + """Cross-BC event pair the handler appends atomically. + + `calibration_event` lands on the Calibration stream; + `permit_event` lands on the outbound Permit stream. Both share + the same `receipt_id` for cross-stream audit joins. + """ + + calibration_event: CalibrationRevisionPublished + permit_event: PublicationReceiptRecorded + + +def decide( + state: Calibration | None, + command: PublishCalibrationRevision, + *, + permit_result: PermitLookupResult | None, + signature_envelope: SignatureEnvelope, + signature_kid: str, + receipt_id: UUID, + now: datetime, + published_by_actor_id: UUID, +) -> PublishRevisionEvents: + """Validate the publish + emit the cross-BC event pair. + + Invariants: + - Calibration state must not be None -> CalibrationNotFoundError + - Named revision must exist on aggregate.revisions -> + CalibrationRevisionNotFoundError + - Revision must carry a non-null content_hash -> + CalibrationCannotPublishRevisionError + - PermitLookup must return an Active outbound Permit -> + OutboundPermitNotActiveError (covers miss + non-Active status) + """ + if state is None: + raise CalibrationNotFoundError(command.calibration_id) + + revision = _find_revision(state, command.revision_id) + if revision is None: + raise CalibrationRevisionNotFoundError( + calibration_id=command.calibration_id, + revision_id=command.revision_id, + ) + if revision.content_hash is None: + raise CalibrationCannotPublishRevisionError( + calibration_id=command.calibration_id, + revision_id=command.revision_id, + ) + + if permit_result is None: + raise OutboundPermitNotActiveError( + peer_facility_id=command.peer_facility_id, + artifact_kind="CalibrationRevision", + status="", + ) + if permit_result.status != "Active": + raise OutboundPermitNotActiveError( + peer_facility_id=command.peer_facility_id, + artifact_kind="CalibrationRevision", + status=permit_result.status, + ) + + calibration_event = CalibrationRevisionPublished( + calibration_id=command.calibration_id, + revision_id=command.revision_id, + outbound_permit_id=permit_result.permit_id, + signature_envelope_kind=signature_envelope.kind, + signing_version=signature_envelope.signing_version, + signature_bytes_hex=signature_envelope.payload_bytes.hex(), + signature_kid=signature_kid, + receipt_id=receipt_id, + published_at=now, + published_by_actor_id=published_by_actor_id, + publication_status=_PUBLICATION_STATUS_LIVE, + occurred_at=now, + ) + permit_event = PublicationReceiptRecorded( + permit_id=permit_result.permit_id, + content_hash=revision.content_hash, + home_stream_type=_HOME_STREAM_TYPE, + home_stream_id=command.calibration_id, + home_artifact_id=command.revision_id, + receipt_id=receipt_id, + recorded_at=now, + occurred_at=now, + ) + return PublishRevisionEvents( + calibration_event=calibration_event, + permit_event=permit_event, + ) + + +def _find_revision(state: Calibration, revision_id: UUID) -> CalibrationRevision | None: + for revision in state.revisions: + if revision.revision_id == revision_id: + return revision + return None + + +__all__ = ["PublishRevisionEvents", "decide"] diff --git a/apps/api/src/cora/calibration/features/publish_revision/handler.py b/apps/api/src/cora/calibration/features/publish_revision/handler.py new file mode 100644 index 000000000..2051ad662 --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/handler.py @@ -0,0 +1,336 @@ +"""Application handler for the `publish_revision` slice. + +Cross-BC iter-b federation handler. Loads the Calibration aggregate ++ the matching outbound Permit via PermitLookup, canonicalizes the +artifact via the deployment-default CanonicalizationPort, signs via +SignaturePort, publishes via PublishPort, and atomically appends the +event pair onto both streams via `EventStore.append_streams`. + +Per project_federation_port_design.md Section 'Cross-BC atomic writes': +two streams, one transaction. The handler short-circuits on any +decider domain error before any port IO; the SignaturePort.sign and +PublishPort.publish calls happen ONLY after the pre-flight pure +decider has validated the publish is authorized + deterministic. + +Per AH#17 + arch-2: SignaturePort.sign delegates to SigningPort for +raw crypto; the handler does not invoke a crypto library directly. +The artifact's `content_hash` is reused verbatim from the revision +state per the content-addressed-identity rollout (no port-side +canonicalization re-recompute). + +Kernel deps consumed: + - authz: authorize the publish command before any IO + - event_store: load both aggregates + append_streams the pair + - permit_lookup: resolve the outbound Permit by (peer, artifact_kind) + - canonicalization_registry: resolve the default adapter for sign + - signature_port: sign canonicalized bytes under trust context + - publish_port: publish the artifact and receive the receipt + - clock + id_generator: server-side wall-clock + receipt_id + +Production wiring (Kernel construction site, follow-up commit): +PermitLookup -> PostgresPermitLookup (reads +proj_federation_permit_summary); SignaturePort + PublishPort wire +to InMemory adapters today (test substitute until the rule-of-two +trigger fires per [[project_federation_port_design]]). +""" + +from typing import Protocol +from uuid import UUID + +from cora.calibration.aggregates.calibration import ( + CalibrationNotFoundError, + event_type_name, + from_stored, + to_payload, +) +from cora.calibration.aggregates.calibration.evolver import fold +from cora.calibration.errors import PublishPortNotWiredError, UnauthorizedError +from cora.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.calibration.features.publish_revision.decider import ( + PublishRevisionEvents, + decide, +) +from cora.federation.aggregates.permit import event_type_name as permit_event_type_name +from cora.federation.aggregates.permit import to_payload as permit_to_payload +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.ports.event_store import StreamAppend +from cora.infrastructure.ports.federation import ( + CredentialRef, + FederationTrustContext, + PublishedArtifact, + SignatureEnvelope, + SignedOffBy, +) +from cora.infrastructure.routing import NIL_SENTINEL_ID + +_STREAM_TYPE_CALIBRATION = "Calibration" +_STREAM_TYPE_PERMIT = "Permit" +_COMMAND_NAME = "PublishCalibrationRevision" +_ARTIFACT_KIND = "CalibrationRevision" +_PAYLOAD_TYPE = "application/vnd.cora.calibration-revision-published+json" +_SCHEMA_VERSION = 1 + +_log = get_logger(__name__) + + +class Handler(Protocol): + """Bare publish_revision handler returned by `bind()`.""" + + async def __call__( + self, + command: PublishCalibrationRevision, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: ... + + +class IdempotentHandler(Protocol): + """publish_revision handler with Idempotency-Key support. + + `with_idempotency` at wire.py wraps the bare Handler; retries + with the same key + body return the cached receipt_id instead + of double-publishing. + """ + + async def __call__( + self, + command: PublishCalibrationRevision, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + idempotency_key: str | None = None, + ) -> UUID: ... + + +def bind(deps: Kernel) -> Handler: + """Build a publish_revision handler closed over the shared deps. + + Raises `PublishPortNotWiredError` when any of the publish-side + deps are absent so misconfiguration surfaces at startup time. + """ + missing = tuple( + name + for name, value in ( + ("publish_port", deps.publish_port), + ("signature_port", deps.signature_port), + ("permit_lookup", deps.permit_lookup), + ) + if value is None + ) + if missing: + raise PublishPortNotWiredError(missing=missing) + publish_port = deps.publish_port + signature_port = deps.signature_port + permit_lookup = deps.permit_lookup + assert publish_port is not None + assert signature_port is not None + assert permit_lookup is not None + + async def handler( + command: PublishCalibrationRevision, + *, + principal_id: UUID, + correlation_id: UUID, + causation_id: UUID | None = None, + surface_id: UUID = NIL_SENTINEL_ID, + ) -> UUID: + _log.info( + "publish_revision.start", + command_name=_COMMAND_NAME, + calibration_id=str(command.calibration_id), + revision_id=str(command.revision_id), + peer_facility_id=command.peer_facility_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( + "publish_revision.denied", + command_name=_COMMAND_NAME, + calibration_id=str(command.calibration_id), + revision_id=str(command.revision_id), + principal_id=str(principal_id), + reason=decision.reason, + ) + raise UnauthorizedError(decision.reason) + + stored_cal, cal_version = await deps.event_store.load( + _STREAM_TYPE_CALIBRATION, command.calibration_id + ) + state = fold([from_stored(s) for s in stored_cal]) + if state is None: + raise CalibrationNotFoundError(command.calibration_id) + + permit_result = await permit_lookup.lookup_outbound( + peer_facility_id=command.peer_facility_id, artifact_kind=_ARTIFACT_KIND + ) + + revision = next((r for r in state.revisions if r.revision_id == command.revision_id), None) + content_hash_hex = ( + revision.content_hash if revision is not None and revision.content_hash else "" + ) + canonicalization_adapter = deps.canonicalization_registry.resolve( + deps.canonicalization_registry.default_version() + ) + canonicalized = canonicalization_adapter.canonicalize( + _PAYLOAD_TYPE, + { + "calibration_id": str(command.calibration_id), + "revision_id": str(command.revision_id), + "content_hash": content_hash_hex, + }, + ) + + trust_context = _build_trust_context(permit_result, command.peer_facility_id) + signature_envelope: SignatureEnvelope = await signature_port.sign( + canonicalized, trust_context + ) + + receipt_id = deps.id_generator.new_id() + now = deps.clock.now() + + artifact = _build_published_artifact( + command=command, + revision_content_hash=content_hash_hex, + canonical_bytes=canonicalized.bytes_, + envelope=signature_envelope, + published_at=now, + published_by_actor_id=principal_id, + permit_abi_tier_floor=( + permit_result.abi_tier_floor if permit_result is not None else "Stable" + ), + canonicalization_version=canonicalized.adapter_version, + ) + await publish_port.publish(artifact) + + events: PublishRevisionEvents = decide( + state, + command, + permit_result=permit_result, + signature_envelope=signature_envelope, + signature_kid=_extract_kid(signature_envelope), + receipt_id=receipt_id, + now=now, + published_by_actor_id=principal_id, + ) + + assert permit_result is not None # decide raises before this line if None + calibration_new_event = to_new_event( + event_type=event_type_name(events.calibration_event), + payload=to_payload(events.calibration_event), + occurred_at=events.calibration_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, + ) + permit_new_event = to_new_event( + event_type=permit_event_type_name(events.permit_event), + payload=permit_to_payload(events.permit_event), + occurred_at=events.permit_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, + ) + + await deps.event_store.append_streams( + [ + StreamAppend( + stream_type=_STREAM_TYPE_CALIBRATION, + stream_id=command.calibration_id, + expected_version=cal_version, + events=[calibration_new_event], + ), + StreamAppend( + stream_type=_STREAM_TYPE_PERMIT, + stream_id=permit_result.permit_id, + expected_version=permit_result.current_version, + events=[permit_new_event], + ), + ] + ) + + _log.info( + "publish_revision.success", + command_name=_COMMAND_NAME, + calibration_id=str(command.calibration_id), + revision_id=str(command.revision_id), + receipt_id=str(receipt_id), + outbound_permit_id=str(permit_result.permit_id), + ) + return receipt_id + + return handler + + +def _build_trust_context( + permit_result: object | None, peer_facility_id: str +) -> FederationTrustContext: + abi_tier_floor = getattr(permit_result, "abi_tier_floor", "Stable") + return FederationTrustContext( + permit_id=getattr(permit_result, "permit_id", NIL_SENTINEL_ID), + allowed_credentials=frozenset[CredentialRef](), + allowed_payload_types=frozenset({_PAYLOAD_TYPE}), + abi_tier_floor=abi_tier_floor, + ) + + +def _build_published_artifact( + *, + command: PublishCalibrationRevision, + revision_content_hash: str, + canonical_bytes: bytes, + envelope: SignatureEnvelope, + published_at: object, + published_by_actor_id: UUID, + permit_abi_tier_floor: str, + canonicalization_version: str, +) -> PublishedArtifact: + from datetime import datetime + + assert isinstance(published_at, datetime) + return PublishedArtifact( + content_hash=(bytes.fromhex(revision_content_hash) if revision_content_hash else b""), + canonical_bytes=canonical_bytes, + payload_type=_PAYLOAD_TYPE, + signature_envelope=envelope, + source_facility_id=command.calibration_id, + published_at=published_at, + expires_at=None, + abi_tier=permit_abi_tier_floor, + dco_chain=(SignedOffBy(actor_id=published_by_actor_id, signed_at=published_at),), + schema_version=_SCHEMA_VERSION, + canonicalization_version=canonicalization_version, + ) + + +def _extract_kid(envelope: SignatureEnvelope) -> str: + return getattr(envelope, "kid", "in-memory-kid") + + +__all__ = [ + "Handler", + "IdempotentHandler", + "bind", +] diff --git a/apps/api/src/cora/calibration/features/publish_revision/route.py b/apps/api/src/cora/calibration/features/publish_revision/route.py new file mode 100644 index 000000000..c99436a09 --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/route.py @@ -0,0 +1,99 @@ +"""HTTP route for the `publish_revision` slice. + +Action endpoint at +`POST /calibrations/{calibration_id}/revisions/{revision_id}/publish`. +201 + body `{receipt_id}` on success. Idempotency-Key wrapped per +the design memo so retries do not double-publish. +""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Header, Path, Request, status +from pydantic import BaseModel, Field + +from cora.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.calibration.features.publish_revision.handler import IdempotentHandler +from cora.infrastructure.routing import ( + ErrorResponse, + get_correlation_id, + get_principal_id, + get_surface_id, +) + + +class PublishCalibrationRevisionRequest(BaseModel): + """Body for `POST /calibrations/{id}/revisions/{revision_id}/publish`.""" + + peer_facility_id: str = Field( + ..., + description=( + "Opaque id of the peer facility this publication is targeted at. " + "Resolved at the handler via PermitLookup to locate the matching " + "Active outbound Permit; missing or inactive permits raise 409." + ), + ) + + +class PublishCalibrationRevisionResponse(BaseModel): + """Response body for the publish action.""" + + receipt_id: UUID + + +def _get_handler(request: Request) -> IdempotentHandler: + handler: IdempotentHandler = request.app.state.calibration.publish_revision + return handler + + +router = APIRouter(tags=["calibration"]) + + +@router.post( + "/calibrations/{calibration_id}/revisions/{revision_id}/publish", + status_code=status.HTTP_201_CREATED, + response_model=PublishCalibrationRevisionResponse, + responses={ + status.HTTP_403_FORBIDDEN: { + "model": ErrorResponse, + "description": "Authorize port denied the publish command.", + }, + status.HTTP_404_NOT_FOUND: { + "model": ErrorResponse, + "description": "No calibration or no revision exists with the given ids.", + }, + status.HTTP_409_CONFLICT: { + "model": ErrorResponse, + "description": ( + "Publish-time FSM rejection: revision lacks content_hash, " + "or no Active outbound Permit authorizes publishing this " + "artifact to the peer." + ), + }, + }, + summary="Publish an existing Calibration revision to a peer facility", +) +async def post_publish_calibration_revision( + calibration_id: Annotated[UUID, Path(description="Target calibration's id.")], + revision_id: Annotated[UUID, Path(description="Revision on that calibration to publish.")], + body: PublishCalibrationRevisionRequest, + handler: Annotated[IdempotentHandler, 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)], + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +) -> PublishCalibrationRevisionResponse: + receipt_id = await handler( + PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=body.peer_facility_id, + ), + principal_id=principal_id, + correlation_id=cid, + surface_id=surface_id, + idempotency_key=idempotency_key, + ) + return PublishCalibrationRevisionResponse(receipt_id=receipt_id) diff --git a/apps/api/src/cora/calibration/features/publish_revision/tool.py b/apps/api/src/cora/calibration/features/publish_revision/tool.py new file mode 100644 index 000000000..b22a85e70 --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/tool.py @@ -0,0 +1,74 @@ +"""MCP tool for the `publish_revision` slice. + +Mirrors the REST route shape: publishes a named revision of an +existing Calibration to a peer facility under an Active outbound +Permit. Returns the receipt_id for audit anchoring. +""" + +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.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.calibration.features.publish_revision.handler import IdempotentHandler +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 + + +class PublishCalibrationRevisionOutput(BaseModel): + """Structured output of the `publish_revision` MCP tool.""" + + receipt_id: UUID + + +def register(mcp: FastMCP, *, get_handler: Callable[[], IdempotentHandler]) -> None: + """Register the `publish_revision` tool on the given MCP server.""" + + @mcp.tool( + name="publish_revision", + description=( + "Publish an existing Calibration revision to a peer facility " + "under an Active outbound Permit. Resolves the Permit via " + "PermitLookup keyed on (peer_facility_id, CalibrationRevision); " + "raises if no Active permit authorizes the publish. The " + "handler canonicalizes the artifact, signs via SignaturePort, " + "publishes via PublishPort, then atomically appends " + "CalibrationRevisionPublished + PublicationReceiptRecorded " + "across the Calibration and Permit streams." + ), + ) + async def publish_revision_tool( # pyright: ignore[reportUnusedFunction] + ctx: Context[Any, Any, Any], + calibration_id: Annotated[UUID, Field(description="Target calibration's id.")], + revision_id: Annotated[ + UUID, + Field(description="Revision on the calibration to publish."), + ], + peer_facility_id: Annotated[ + str, + Field(description="Opaque peer-facility id; matched to the outbound Permit."), + ], + idempotency_key: Annotated[ + str | None, + Field(default=None, description="Optional Idempotency-Key per logical request."), + ] = None, + ) -> PublishCalibrationRevisionOutput: + handler = get_handler() + receipt_id = await handler( + PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ), + principal_id=get_mcp_principal_id(ctx), + correlation_id=current_correlation_id(), + surface_id=get_mcp_surface_id(), + idempotency_key=idempotency_key, + ) + return PublishCalibrationRevisionOutput(receipt_id=receipt_id) diff --git a/apps/api/src/cora/calibration/routes.py b/apps/api/src/cora/calibration/routes.py index 3b1ba04b8..ce087b341 100644 --- a/apps/api/src/cora/calibration/routes.py +++ b/apps/api/src/cora/calibration/routes.py @@ -28,13 +28,16 @@ from cora.calibration.aggregates.calibration import ( CalibrationAlreadyExistsError, + CalibrationCannotPublishRevisionError, CalibrationIdentityAlreadyExistsError, CalibrationNotFoundError, + CalibrationRevisionNotFoundError, InvalidCalibrationDescriptionError, InvalidCalibrationQuantityError, InvalidCalibrationSourceError, InvalidCalibrationValueError, InvalidOperatingPointError, + OutboundPermitNotActiveError, SupersedesRevisionNotFoundError, ) from cora.calibration.errors import UnauthorizedError @@ -43,6 +46,7 @@ define_calibration, get_calibration, list_calibrations, + publish_revision, ) @@ -73,6 +77,22 @@ async def _handle_not_found(request: Request, exc: Exception) -> JSONResponse: ) +async def _handle_conflict(request: Request, exc: Exception) -> JSONResponse: + """409 handler for publish-time FSM-rejection errors. + + Covers OutboundPermitNotActiveError and + CalibrationCannotPublishRevisionError: the publish slice cannot + proceed because the permit is not Active or the revision lacks + a content_hash. Distinct from 422 because the request shape is + valid; the state of the world does not allow the action. + """ + _ = request + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": str(exc)}, + ) + + async def _handle_already_exists(request: Request, exc: Exception) -> JSONResponse: """Defensive 409 handler. @@ -94,6 +114,7 @@ def register_calibration_routes(app: FastAPI) -> None: """Attach Calibration slice routers and exception handlers to the FastAPI app.""" app.include_router(define_calibration.router) app.include_router(append_calibration_revision.router) + app.include_router(publish_revision.router) app.include_router(get_calibration.router) app.include_router(list_calibrations.router) for validation_cls in ( @@ -105,11 +126,19 @@ def register_calibration_routes(app: FastAPI) -> None: SupersedesRevisionNotFoundError, ): app.add_exception_handler(validation_cls, _handle_validation_error) - for not_found_cls in (CalibrationNotFoundError,): + for not_found_cls in ( + CalibrationNotFoundError, + CalibrationRevisionNotFoundError, + ): app.add_exception_handler(not_found_cls, _handle_not_found) for already_exists_cls in ( CalibrationAlreadyExistsError, CalibrationIdentityAlreadyExistsError, ): app.add_exception_handler(already_exists_cls, _handle_already_exists) + for conflict_cls in ( + CalibrationCannotPublishRevisionError, + OutboundPermitNotActiveError, + ): + app.add_exception_handler(conflict_cls, _handle_conflict) app.add_exception_handler(UnauthorizedError, _handle_unauthorized) diff --git a/apps/api/src/cora/calibration/tools.py b/apps/api/src/cora/calibration/tools.py index 0a3b1ebd8..555a7f750 100644 --- a/apps/api/src/cora/calibration/tools.py +++ b/apps/api/src/cora/calibration/tools.py @@ -17,6 +17,7 @@ from cora.calibration.features.define_calibration import tool as define_calibration_tool from cora.calibration.features.get_calibration import tool as get_calibration_tool from cora.calibration.features.list_calibrations import tool as list_calibrations_tool +from cora.calibration.features.publish_revision import tool as publish_revision_tool from cora.calibration.wire import CalibrationHandlers @@ -34,6 +35,10 @@ def register_calibration_tools( mcp, get_handler=lambda: get_handlers().append_calibration_revision, ) + publish_revision_tool.register( + mcp, + get_handler=lambda: get_handlers().publish_revision, + ) get_calibration_tool.register( mcp, get_handler=lambda: get_handlers().get_calibration, diff --git a/apps/api/src/cora/calibration/wire.py b/apps/api/src/cora/calibration/wire.py index 468cd32e4..b650b453c 100644 --- a/apps/api/src/cora/calibration/wire.py +++ b/apps/api/src/cora/calibration/wire.py @@ -34,6 +34,7 @@ define_calibration, get_calibration, list_calibrations, + publish_revision, ) from cora.infrastructure.idempotency import with_idempotency from cora.infrastructure.kernel import Kernel @@ -48,6 +49,7 @@ class CalibrationHandlers: define_calibration: define_calibration.IdempotentHandler append_calibration_revision: append_calibration_revision.IdempotentHandler + publish_revision: publish_revision.IdempotentHandler get_calibration: get_calibration.Handler list_calibrations: list_calibrations.Handler @@ -79,6 +81,18 @@ def wire_calibration(deps: Kernel) -> CalibrationHandlers: command_name="AppendCalibrationRevision", bc=_BC, ), + publish_revision=with_tracing( + with_idempotency( + publish_revision.bind(deps), + deps.idempotency_store, + command_name="PublishCalibrationRevision", + serialize_result=str, + deserialize_result=UUID, + lock_stale_seconds=deps.settings.idempotency_lock_stale_seconds, + ), + command_name="PublishCalibrationRevision", + bc=_BC, + ), get_calibration=with_tracing( get_calibration.bind(deps), command_name="GetCalibration", diff --git a/apps/api/src/cora/federation/adapters/federation_registry.py b/apps/api/src/cora/federation/adapters/federation_registry.py new file mode 100644 index 000000000..813b80a59 --- /dev/null +++ b/apps/api/src/cora/federation/adapters/federation_registry.py @@ -0,0 +1,110 @@ +"""FederationRegistry: composite PublishPort + PullPort dispatcher. + +Per project_federation_port_design.md: handlers see ONE port; the +routing table is configuration not code. The registry implements +both PublishPort and PullPort by routing each call to the matching +backing adapter via longest-prefix-match on the artifact's +`source_facility_id` UUID-prefix string. + +Modelled after `cora.operation.adapters.control_port_registry.ControlPortRegistry`: +construct empty, call `register(prefix, port)` for each peer +facility at app startup, then hand the registry to the Federation +BC handlers as a single PublishPort + PullPort. + +`aclose()` fan-out in registration order with `contextlib.suppress(Exception)` +and a `_closed` idempotency flag so one flaky peer adapter cannot +strand its siblings. + +Routing rule: longest-prefix-match by the source_facility_id's +hex-string representation. A more specific prefix wins over a +shorter one. No regex, no glob. Matches the +NoAdapterForAddressError analog in ControlPort with +NoAdapterForFacilityError here. +""" + +import contextlib + +from cora.infrastructure.ports.federation import ( + ArtifactReference, + NoAdapterForFacilityError, + PublishedArtifact, + PublishPort, + PublishReceipt, + PulledArtifact, + PullPort, +) + + +class FederationRegistry: + """Composite PublishPort + PullPort with prefix-routed dispatch. + + Construct empty, call `register(prefix, port)` for each peer + facility, then hand the registry to handlers as a single port. + The routing table is the only configuration that varies between + deployments; the handler tier sees a stable port surface. + """ + + def __init__(self) -> None: + self._routes: list[tuple[str, PublishPort | PullPort]] = [] + self._closed = False + + def register(self, prefix: str, port: PublishPort | PullPort) -> None: + """Add a route. Re-registering a prefix REPLACES the prior entry. + + Replacement is intentional: hot-swapping a peer-facility + adapter during integration tests should not require dropping + and reconstructing the registry. Matches the + ControlPortRegistry precedent verbatim. + """ + self._routes = [(p, a) for (p, a) in self._routes if p != prefix] + self._routes.append((prefix, port)) + + def route_publish(self, artifact: PublishedArtifact) -> PublishPort: + """Return the PublishPort adapter for `artifact`'s source facility.""" + adapter = self._route_by_facility_hex(artifact.source_facility_id.hex) + if not isinstance(adapter, PublishPort): + raise NoAdapterForFacilityError(source_facility_id=artifact.source_facility_id) + return adapter + + def route_pull(self, reference: ArtifactReference) -> PullPort: + """Return the PullPort adapter for `reference`'s source facility.""" + adapter = self._route_by_facility_hex(reference.source_facility_id.hex) + if not isinstance(adapter, PullPort): + raise NoAdapterForFacilityError(source_facility_id=reference.source_facility_id) + return adapter + + def _route_by_facility_hex(self, facility_hex: str) -> PublishPort | PullPort: + for prefix, adapter in sorted(self._routes, key=lambda r: -len(r[0])): + if facility_hex.startswith(prefix): + return adapter + raise NoAdapterForFacilityError(source_facility_id=None) # type: ignore[arg-type] + + async def publish(self, artifact: PublishedArtifact) -> PublishReceipt: + return await self.route_publish(artifact).publish(artifact) + + async def fetch(self, reference: ArtifactReference) -> PulledArtifact: + return await self.route_pull(reference).fetch(reference) + + def registered_prefixes(self) -> tuple[str, ...]: + """Return the registered prefixes in registration order.""" + return tuple(p for p, _ in self._routes) + + async def aclose(self) -> None: + """Close every registered adapter; idempotent. + + Suppresses per-adapter close errors so one flaky adapter + cannot strand siblings. Production deployments call this + from the FastAPI lifespan exit handler. + """ + if self._closed: + return + self._closed = True + for _, adapter in self._routes: + close = getattr(adapter, "aclose", None) + if close is None: + continue + with contextlib.suppress(Exception): + await close() + + +__all__ = ["FederationRegistry"] diff --git a/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py b/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py new file mode 100644 index 000000000..1f5530b67 --- /dev/null +++ b/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py @@ -0,0 +1,113 @@ +"""In-memory PermitLookup adapter for tests and dev fixtures. + +Dict-backed, mirrors the InMemoryCredentialLookup precedent. Test +entry verb is `register(...)` which primes a (peer_facility_id, +artifact_kind, direction) -> PermitLookupResult lookup; the +production PostgresPermitLookup reads the same projection columns +from `proj_federation_permit_summary`. +""" + +from uuid import UUID + +from cora.infrastructure.ports.federation.permit_lookup import ( + PermitLookup, + PermitLookupResult, +) + + +def _key(peer_facility_id: str, artifact_kind: str, direction: str) -> tuple[str, str, str]: + return (peer_facility_id, artifact_kind, direction) + + +class InMemoryPermitLookup(PermitLookup): + """Dict-backed PermitLookup with `register` test entry point. + + Construct empty, call `register(...)` for each permit the test + needs, then hand the adapter to the handler under test. The + seeded permits survive across `lookup_outbound` / `lookup_inbound` + calls until `clear()` is invoked. + """ + + def __init__(self) -> None: + self._permits: dict[tuple[str, str, str], PermitLookupResult] = {} + + def register( + self, + *, + peer_facility_id: str, + artifact_kind: str, + direction: str, + result: PermitLookupResult, + ) -> None: + """Seed a permit for a (peer, artifact_kind, direction) lookup key.""" + self._permits[_key(peer_facility_id, artifact_kind, direction)] = result + + def register_outbound( + self, + *, + peer_facility_id: str, + artifact_kind: str, + permit_id: UUID, + status: str = "Active", + abi_tier_floor: str = "Stable", + current_version: int = 0, + ) -> PermitLookupResult: + """Convenience: seed an outbound permit; returns the seeded result for assertions.""" + result = PermitLookupResult( + permit_id=permit_id, + peer_facility_id=peer_facility_id, + direction="Outbound", + status=status, + abi_tier_floor=abi_tier_floor, + current_version=current_version, + ) + self.register( + peer_facility_id=peer_facility_id, + artifact_kind=artifact_kind, + direction="Outbound", + result=result, + ) + return result + + def register_inbound( + self, + *, + peer_facility_id: str, + artifact_kind: str, + permit_id: UUID, + status: str = "Active", + abi_tier_floor: str = "Stable", + current_version: int = 0, + ) -> PermitLookupResult: + """Convenience: seed an inbound permit; returns the seeded result for assertions.""" + result = PermitLookupResult( + permit_id=permit_id, + peer_facility_id=peer_facility_id, + direction="Inbound", + status=status, + abi_tier_floor=abi_tier_floor, + current_version=current_version, + ) + self.register( + peer_facility_id=peer_facility_id, + artifact_kind=artifact_kind, + direction="Inbound", + result=result, + ) + return result + + async def lookup_outbound( + self, peer_facility_id: str, artifact_kind: str + ) -> PermitLookupResult | None: + return self._permits.get(_key(peer_facility_id, artifact_kind, "Outbound")) + + async def lookup_inbound( + self, peer_facility_id: str, artifact_kind: str + ) -> PermitLookupResult | None: + return self._permits.get(_key(peer_facility_id, artifact_kind, "Inbound")) + + def clear(self) -> None: + self._permits.clear() + + +__all__ = ["InMemoryPermitLookup"] diff --git a/apps/api/src/cora/federation/adapters/in_memory_publish_port.py b/apps/api/src/cora/federation/adapters/in_memory_publish_port.py new file mode 100644 index 000000000..2ece2df4c --- /dev/null +++ b/apps/api/src/cora/federation/adapters/in_memory_publish_port.py @@ -0,0 +1,72 @@ +"""In-memory PublishPort adapter for tests and dev fixtures. + +Dict-backed, no sockets. Mirrors `InMemoryEventStore` shape. Test +entry verbs use a `simulate_*` naming convention so a test that +needs to exercise a publish-time failure can opt in without +plumbing a separate fake. + +Production-tier substitute until the rule-of-two trigger fires +(see [[project_federation_port_design]] for the trigger criteria). +Wire-tier adapters land in a follow-up iteration with the matching +library pins. +""" + +import contextlib +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.federation import ( + FederationCredentialRevokedError, + PublishedArtifact, + PublishReceipt, +) + + +class InMemoryPublishPort: + """Dict-backed PublishPort with simulate_* test entry points. + + Construct empty, call `publish(artifact)` to record, call + `published_artifacts()` to assert. Test entry verbs: + + - `simulate_credential_revoked(credential_id, revoked_at)`: + next `publish` call raises `FederationCredentialRevokedError` + when ANY credential id matches; clear with + `clear_simulations()`. + """ + + def __init__(self, *, clock: object | None = None) -> None: + self._published: list[PublishedArtifact] = [] + self._revoked_credentials: dict[UUID, datetime] = {} + self._next_receipt_id = 0 + self._clock = clock + + async def publish(self, artifact: PublishedArtifact) -> PublishReceipt: + for credential_id, revoked_at in self._revoked_credentials.items(): + raise FederationCredentialRevokedError( + credential_id=credential_id, revoked_at=revoked_at + ) + self._published.append(artifact) + self._next_receipt_id += 1 + return PublishReceipt( + receipt_bytes=f"in-memory-receipt-{self._next_receipt_id}".encode(), + receipt_format_hint="in-memory/v1", + transparency_log_hint="none", + recorded_at=artifact.published_at, + ) + + def published_artifacts(self) -> tuple[PublishedArtifact, ...]: + return tuple(self._published) + + def simulate_credential_revoked(self, credential_id: UUID, revoked_at: datetime) -> None: + self._revoked_credentials[credential_id] = revoked_at + + def clear_simulations(self) -> None: + self._revoked_credentials = {} + + async def aclose(self) -> None: + with contextlib.suppress(Exception): + self._published.clear() + self._revoked_credentials.clear() + + +__all__ = ["InMemoryPublishPort"] diff --git a/apps/api/src/cora/federation/adapters/in_memory_pull_port.py b/apps/api/src/cora/federation/adapters/in_memory_pull_port.py new file mode 100644 index 000000000..00dc4e8f4 --- /dev/null +++ b/apps/api/src/cora/federation/adapters/in_memory_pull_port.py @@ -0,0 +1,96 @@ +"""In-memory PullPort adapter for tests and dev fixtures. + +Dict-backed, no sockets. Test entry verbs: + + - `set_pull_response(reference, pulled)`: prime a fetch response. + - `simulate_registry_unreachable(source_facility_id, opened_at)`: + next `fetch` for that facility raises `FederationCircuitOpenError`. + - `simulate_content_drift(reference)`: next `fetch` of that + reference returns drifted bytes that hash to something other + than the reference's `content_hash`, triggering + `FederationPublicationContentDriftError` BEFORE returning. + +Per AH#17: `fetch` MUST raise `FederationPublicationContentDriftError` +when fetched bytes do not hash to `reference.content_hash`. The +in-memory adapter implements the same invariant via the simulate +verb to make TOCTOU defenses testable end-to-end. +""" + +import contextlib +import hashlib +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.federation import ( + ArtifactReference, + FederationCircuitOpenError, + FederationPublicationContentDriftError, + FetchProvenance, + PulledArtifact, +) + + +def _reference_key(reference: ArtifactReference) -> tuple[bytes, str]: + return (reference.content_hash, reference.payload_type) + + +class InMemoryPullPort: + """Dict-backed PullPort with simulate_* test entry points.""" + + def __init__(self) -> None: + self._responses: dict[tuple[bytes, str], PulledArtifact] = {} + self._unreachable_facilities: dict[UUID, datetime] = {} + self._drift_references: set[tuple[bytes, str]] = set() + + async def fetch(self, reference: ArtifactReference) -> PulledArtifact: + opened_at = self._unreachable_facilities.get(reference.source_facility_id) + if opened_at is not None: + raise FederationCircuitOpenError( + source_facility_id=reference.source_facility_id, opened_at=opened_at + ) + key = _reference_key(reference) + if key in self._drift_references: + fetched_bytes = b"DRIFT:" + reference.content_hash + raise FederationPublicationContentDriftError( + reference_content_hash=reference.content_hash, + fetched_content_hash=hashlib.sha256(fetched_bytes).digest(), + ) + response = self._responses.get(key) + if response is None: + raise KeyError( + f"InMemoryPullPort has no response primed for " + f"reference={reference!r}; call set_pull_response first" + ) + return response + + def set_pull_response(self, reference: ArtifactReference, pulled: PulledArtifact) -> None: + self._responses[_reference_key(reference)] = pulled + + def simulate_registry_unreachable(self, source_facility_id: UUID, opened_at: datetime) -> None: + self._unreachable_facilities[source_facility_id] = opened_at + + def simulate_content_drift(self, reference: ArtifactReference) -> None: + self._drift_references.add(_reference_key(reference)) + + def clear_simulations(self) -> None: + self._unreachable_facilities.clear() + self._drift_references.clear() + + @staticmethod + def make_provenance(byte_count: int) -> FetchProvenance: + """Helper for tests that need a non-zero FetchProvenance shape.""" + return FetchProvenance( + locator_used="in-memory://x", + wire_content_type="application/dsse+json", + fetch_duration_ms=1, + byte_count=byte_count, + ) + + async def aclose(self) -> None: + with contextlib.suppress(Exception): + self._responses.clear() + self._unreachable_facilities.clear() + self._drift_references.clear() + + +__all__ = ["InMemoryPullPort"] diff --git a/apps/api/src/cora/federation/adapters/in_memory_signature_port.py b/apps/api/src/cora/federation/adapters/in_memory_signature_port.py new file mode 100644 index 000000000..370231cbc --- /dev/null +++ b/apps/api/src/cora/federation/adapters/in_memory_signature_port.py @@ -0,0 +1,110 @@ +"""In-memory SignaturePort adapter for tests and dev fixtures. + +Dict-backed, no crypto. Test entry verbs: + + - `set_verification_outcome(content_hash, outcome)`: prime a + `VerificationOutcome` to return on the next `verify(artifact, ...)` + call whose `artifact.content_hash` matches. + - `simulate_signature_invalid(content_hash, failed_stage)`: next + `verify` for an artifact with that content_hash returns a + `Rejected` outcome carrying the named failed stage. + - `set_sign_envelope(canonicalization_version, envelope)`: prime + a fresh envelope to return on `sign(canonicalized, ...)` when + `canonicalized.adapter_version` matches. + +Note on the arch-2 delegation invariant: the IN-MEMORY adapter +does NOT need a SigningPort instance because it does no crypto; +it returns canned outcomes for testing. The architecture-fitness +test `test_signature_port_delegates_to_signing_port.py` (lands at +the same time as the first wire-tier adapter) walks PRODUCTION +adapters under `cora/federation/adapters/_signature_port.py`, +not this in-memory test fixture. +""" + +import contextlib + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.federation import ( + DsseStaticJwksEnvelope, + FederationTrustContext, + PublishedArtifact, + Rejected, + RejectionReason, + SignatureEnvelope, + StageName, + StageResult, + VerificationOutcome, + Verified, +) + + +class InMemorySignaturePort: + """Dict-backed SignaturePort with simulate_* test entry points. + + Default verify outcome is `Verified` with all stages passing; + `simulate_signature_invalid` flips that for specific + content_hashes. Default sign outcome is a placeholder + `DsseStaticJwksEnvelope`; `set_sign_envelope` overrides per + canonicalization version. + """ + + def __init__(self) -> None: + self._verification_outcomes: dict[bytes, VerificationOutcome] = {} + self._sign_envelopes: dict[str, SignatureEnvelope] = {} + + async def verify( + self, + artifact: PublishedArtifact, + trust_context: FederationTrustContext, + ) -> VerificationOutcome: + _ = trust_context + primed = self._verification_outcomes.get(artifact.content_hash) + if primed is not None: + return primed + return Verified( + stage_results=( + StageResult(stage="content_hash", outcome="pass"), + StageResult(stage="signature", outcome="pass"), + ) + ) + + async def sign( + self, + canonicalized: CanonicalizedBytes, + trust_context: FederationTrustContext, + ) -> SignatureEnvelope: + _ = trust_context + primed = self._sign_envelopes.get(canonicalized.adapter_version) + if primed is not None: + return primed + return DsseStaticJwksEnvelope( + signing_version=canonicalized.adapter_version, + payload_bytes=b"in-memory-signature-over:" + canonicalized.bytes_, + ) + + def set_verification_outcome(self, content_hash: bytes, outcome: VerificationOutcome) -> None: + self._verification_outcomes[content_hash] = outcome + + def simulate_signature_invalid(self, content_hash: bytes, failed_stage: StageName) -> None: + self._verification_outcomes[content_hash] = Rejected( + stage_results=(StageResult(stage=failed_stage, outcome="fail"),), + rejection=RejectionReason( + failed_stage=failed_stage, + reason="in-memory simulate_signature_invalid", + ), + ) + + def set_sign_envelope(self, canonicalization_version: str, envelope: SignatureEnvelope) -> None: + self._sign_envelopes[canonicalization_version] = envelope + + def clear_simulations(self) -> None: + self._verification_outcomes.clear() + self._sign_envelopes.clear() + + async def aclose(self) -> None: + with contextlib.suppress(Exception): + self._verification_outcomes.clear() + self._sign_envelopes.clear() + + +__all__ = ["InMemorySignaturePort"] diff --git a/apps/api/src/cora/federation/aggregates/permit/events.py b/apps/api/src/cora/federation/aggregates/permit/events.py index ff9407622..001bba1d0 100644 --- a/apps/api/src/cora/federation/aggregates/permit/events.py +++ b/apps/api/src/cora/federation/aggregates/permit/events.py @@ -161,7 +161,48 @@ class PermitRevoked: reason: str | None = None -PermitEvent = PermitDefined | PermitActivated | PermitSuspended | PermitResumed | PermitRevoked +@dataclass(frozen=True, slots=True) +class PublicationReceiptRecorded: + """A per-BC publish slice recorded a receipt against this outbound permit. + + Cross-BC iter-b federation event. The matching home-aggregate + event (`Published` on the home BC stream) lands + atomically via the handler's `EventStore.append_streams` call + per cross-BC append-streams discipline. + + `content_hash` is the artifact's port-tier content hash + (recomputed via the matching CanonicalizationPort adapter on + the verify side); `home_stream_type` + `home_stream_id` + + `home_artifact_id` denorm the cross-stream join so audit + queries do not require a separate index lookup. `receipt_id` + is the UUID minted by the PublishPort adapter and matches the + receipt_id on the home-BC published event. + + No status transition: the Permit FSM is `Defined / Active / + Suspended / Revoked`; recording a receipt is orthogonal to + those positions. The decider enforces the Active-only + invariant before emitting this event (publishing under a + Suspended or Revoked permit is rejected). + """ + + permit_id: UUID + content_hash: str + home_stream_type: str + home_stream_id: UUID + home_artifact_id: UUID + receipt_id: UUID + recorded_at: datetime + occurred_at: datetime + + +PermitEvent = ( + PermitDefined + | PermitActivated + | PermitSuspended + | PermitResumed + | PermitRevoked + | PublicationReceiptRecorded +) def event_type_name(event: PermitEvent) -> str: @@ -241,6 +282,26 @@ def to_payload(event: PermitEvent) -> dict[str, Any]: "occurred_at": occurred_at.isoformat(), "reason": reason, } + case PublicationReceiptRecorded( + permit_id=permit_id, + content_hash=content_hash, + home_stream_type=home_stream_type, + home_stream_id=home_stream_id, + home_artifact_id=home_artifact_id, + receipt_id=receipt_id, + recorded_at=recorded_at, + occurred_at=occurred_at, + ): + return { + "permit_id": str(permit_id), + "content_hash": content_hash, + "home_stream_type": home_stream_type, + "home_stream_id": str(home_stream_id), + "home_artifact_id": str(home_artifact_id), + "receipt_id": str(receipt_id), + "recorded_at": recorded_at.isoformat(), + "occurred_at": occurred_at.isoformat(), + } case _: # pragma: no cover assert_never(event) @@ -312,6 +373,20 @@ def from_stored(stored: StoredEvent) -> PermitEvent: reason=payload.get("reason"), ), ) + case "PublicationReceiptRecorded": + return deserialize_or_raise( + "PublicationReceiptRecorded", + lambda: PublicationReceiptRecorded( + permit_id=UUID(payload["permit_id"]), + content_hash=payload["content_hash"], + home_stream_type=payload["home_stream_type"], + home_stream_id=UUID(payload["home_stream_id"]), + home_artifact_id=UUID(payload["home_artifact_id"]), + receipt_id=UUID(payload["receipt_id"]), + recorded_at=datetime.fromisoformat(payload["recorded_at"]), + occurred_at=datetime.fromisoformat(payload["occurred_at"]), + ), + ) case unknown: msg = f"Unknown Permit event type: {unknown!r}" raise ValueError(msg) @@ -324,6 +399,7 @@ def from_stored(stored: StoredEvent) -> PermitEvent: "PermitResumed", "PermitRevoked", "PermitSuspended", + "PublicationReceiptRecorded", "deserialize_terms", "event_type_name", "from_stored", diff --git a/apps/api/src/cora/federation/aggregates/permit/evolver.py b/apps/api/src/cora/federation/aggregates/permit/evolver.py index 3a88ae364..8bc95a17d 100644 --- a/apps/api/src/cora/federation/aggregates/permit/evolver.py +++ b/apps/api/src/cora/federation/aggregates/permit/evolver.py @@ -25,6 +25,7 @@ PermitResumed, PermitRevoked, PermitSuspended, + PublicationReceiptRecorded, ) from cora.federation.aggregates.permit.state import Permit, PermitStatus from cora.infrastructure.evolver import require_state @@ -70,6 +71,9 @@ def evolve(state: Permit | None, event: PermitEvent) -> Permit: case PermitRevoked(): prior = require_state(state, "PermitRevoked") return _replace_status(prior, PermitStatus.REVOKED) + case PublicationReceiptRecorded(): + prior = require_state(state, "PublicationReceiptRecorded") + return prior case _: # pragma: no cover assert_never(event) diff --git a/apps/api/src/cora/infrastructure/adapters/canonicalization_registry.py b/apps/api/src/cora/infrastructure/adapters/canonicalization_registry.py new file mode 100644 index 000000000..0f75aa8e8 --- /dev/null +++ b/apps/api/src/cora/infrastructure/adapters/canonicalization_registry.py @@ -0,0 +1,107 @@ +"""Per-version dispatch for CanonicalizationPort adapters. + +`CanonicalizationRegistry` is the deployment-wide dispatch table: +every adapter registered under its `adapter_version` string is the +verification path for events that carry that version. The default +version is established at Kernel construction and stays immutable +for the lifetime of the deployment. + +Modelled after `cora.operation.adapters.control_port_registry.ControlPortRegistry` +but with EXACT-version-match (not longest-prefix-match): the +`adapter_version` string is an identity, not an address space. +Duplicate-version `register()` raises `ValueError` per the locked +anti-hook (no silent replacement; that would let a deployment +re-register `"cora/v1"` with a non-default adapter and silently +break verification). + +`default_version()` is the WRITE-side dispatch handle. All new +content_hash sites resolve via the default; only VERIFY-side sites +resolve by the per-event recorded version. The default is set at +construction and exposed read-only. +""" + +import contextlib + +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationPort, + UnsupportedCanonicalizationVersionError, +) + + +class CanonicalizationRegistry: + """Per-version dispatch for CanonicalizationPort adapters. + + Construct, register one or more adapters by `adapter_version` + string, optionally set the deployment-wide default version, + then resolve at read- or write-time. Kernel construction is the + single shipped registration site; production startup registers + `DefaultCanonicalizationAdapter` under `"cora/v1"` and sets the + default to `"cora/v1"`. + """ + + def __init__(self) -> None: + self._routes: list[tuple[str, CanonicalizationPort]] = [] + self._default: str | None = None + self._closed = False + + def register(self, version: str, adapter: CanonicalizationPort) -> None: + """Register `adapter` under `version`. Duplicate version raises ValueError.""" + for existing_version, _ in self._routes: + if existing_version == version: + raise ValueError( + f"Canonicalization adapter already registered for version " + f"{version!r}; re-registration is forbidden" + ) + self._routes.append((version, adapter)) + + def set_default(self, version: str) -> None: + """Set the deployment-wide default version. Must already be registered.""" + if not any(v == version for v, _ in self._routes): + raise UnsupportedCanonicalizationVersionError( + requested_version=version, + registered_versions=self.registered_versions(), + ) + self._default = version + + def resolve(self, version: str) -> CanonicalizationPort: + """Return the adapter registered under `version`. Exact match only.""" + for registered_version, adapter in self._routes: + if registered_version == version: + return adapter + raise UnsupportedCanonicalizationVersionError( + requested_version=version, + registered_versions=self.registered_versions(), + ) + + def default_version(self) -> str: + """Return the deployment-wide default canonicalization version.""" + if self._default is None: + raise UnsupportedCanonicalizationVersionError( + requested_version="", + registered_versions=self.registered_versions(), + ) + return self._default + + def registered_versions(self) -> tuple[str, ...]: + """Return the tuple of registered version strings in registration order.""" + return tuple(v for v, _ in self._routes) + + async def aclose(self) -> None: + """Close every registered adapter; idempotent. + + Suppresses per-adapter close errors so one flaky adapter + cannot strand its siblings. Mirrors the ControlPortRegistry + lifecycle. + """ + if self._closed: + return + self._closed = True + for _, adapter in self._routes: + close = getattr(adapter, "aclose", None) + if close is None: + continue + with contextlib.suppress(Exception): + await close() + + +__all__ = ["CanonicalizationRegistry"] diff --git a/apps/api/src/cora/infrastructure/adapters/default_canonicalization_adapter.py b/apps/api/src/cora/infrastructure/adapters/default_canonicalization_adapter.py new file mode 100644 index 000000000..11b9def8c --- /dev/null +++ b/apps/api/src/cora/infrastructure/adapters/default_canonicalization_adapter.py @@ -0,0 +1,89 @@ +"""Default v1 canonicalization adapter delegating to the shipped helpers. + +`DefaultCanonicalizationAdapter` IS the shipped v1 recipe per +[[project_canonicalization_port_design]]: NFC + sort-keys JSON + +DSSE PAE + SHA-256. The adapter is a thin wrapper around +`cora.infrastructure.content_hash` helpers; bit-identical output +is guaranteed because they share the implementation. + +Constraint on `payload_type`: the v1 adapter accepts ONLY URIs that +match the `application/vnd.cora.+json` scheme. Any +other payload_type raises `CanonicalizationFailedError` before any +canonicalization runs. This anchors the v1 recipe to the CORA event- +type vocabulary; future arms (CBOR, COSE_Sign1) may accept different +URI schemes. + +The `adapter_version` is the literal string `"cora/v1"`. This string +IS the version identity; never derived from package metadata or +config. +""" + +from typing import Any + +from cora.infrastructure.content_hash import ( + canonical_body_bytes, + compute_content_hash, + pae_bytes, +) +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationFailedError, + CanonicalizedBytes, +) + +_PAYLOAD_TYPE_PREFIX = "application/vnd.cora." +_PAYLOAD_TYPE_SUFFIX = "+json" +_ADAPTER_VERSION = "cora/v1" + + +class DefaultCanonicalizationAdapter: + """v1 canonicalization adapter: stdlib json sort-keys + DSSE PAE + SHA-256.""" + + @property + def adapter_version(self) -> str: + return _ADAPTER_VERSION + + def canonicalize(self, payload_type: str, payload: Any) -> CanonicalizedBytes: + self._validate_payload_type(payload_type) + try: + body_bytes = canonical_body_bytes(payload) + wrapped = pae_bytes(payload_type, body_bytes) + except (TypeError, ValueError) as exc: + raise CanonicalizationFailedError( + payload_type=payload_type, + adapter_version=_ADAPTER_VERSION, + reason=str(exc), + ) from exc + return CanonicalizedBytes( + bytes_=wrapped, + adapter_version=_ADAPTER_VERSION, + payload_type=payload_type, + ) + + def verify_content_hash(self, payload_type: str, payload: Any, claimed_hash: str) -> bool: + self._validate_payload_type(payload_type) + try: + recomputed = compute_content_hash(payload_type, payload) + except (TypeError, ValueError) as exc: + raise CanonicalizationFailedError( + payload_type=payload_type, + adapter_version=_ADAPTER_VERSION, + reason=str(exc), + ) from exc + return recomputed == claimed_hash + + @staticmethod + def _validate_payload_type(payload_type: str) -> None: + if not payload_type.startswith(_PAYLOAD_TYPE_PREFIX) or not payload_type.endswith( + _PAYLOAD_TYPE_SUFFIX + ): + raise CanonicalizationFailedError( + payload_type=payload_type, + adapter_version=_ADAPTER_VERSION, + reason=( + f"v1 adapter accepts only {_PAYLOAD_TYPE_PREFIX}" + f"{_PAYLOAD_TYPE_SUFFIX} URIs" + ), + ) + + +__all__ = ["DefaultCanonicalizationAdapter"] diff --git a/apps/api/src/cora/infrastructure/adapters/default_signing_adapter.py b/apps/api/src/cora/infrastructure/adapters/default_signing_adapter.py new file mode 100644 index 000000000..1a9b1b65f --- /dev/null +++ b/apps/api/src/cora/infrastructure/adapters/default_signing_adapter.py @@ -0,0 +1,160 @@ +"""Default v1 signing adapter: Ed25519 over canonicalized bytes. + +`DefaultSigningAdapter` IS the shipped v1 signing recipe per +[[project_canonicalization_port_design]]: Ed25519 detached signature +over `CanonicalizedBytes.bytes_`, with `KeyHandle` narrowed to a +`JwksKid(kid: str)` frozen dataclass. + +The adapter pairs by `adapter_version`: it refuses to sign or verify +over `CanonicalizedBytes` whose `adapter_version` does not match +`"cora/v1"`, raising `CanonicalizationVersionMismatchError`. The +architecture-fitness suite enforces the same invariant row-by-row +on stored events. + +Verification verdict mapping: + + - `Valid` : Ed25519 verify returned successfully + - `Invalid` : Ed25519 verify raised `InvalidSignature` (math rejected) + - `Unverifiable` : key not in trust context, public-key bytes malformed, + or resolver raised (key gone, transient outage); the + verifier could not run the math at all + +The adapter takes a private-key loader (sign-side) and a public-key +resolver (verify-side) at construction. Resolvers are call-site +specific (in-memory cache for hot verify paths, JWKS-backed for +federated paths). The constructor injection keeps the port surface +narrow and matches the existing `cora.infrastructure.signing.verify_signature` +resolver-callable shape. +""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import datetime + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.signing import ( + CanonicalizationVersionMismatchError, + Signature, + SignatureVerification, + SigningKeyNotFoundError, + SigningTrustContext, +) + +_ADAPTER_VERSION = "cora/v1" + + +@dataclass(frozen=True, slots=True) +class JwksKid: + """v1 KeyHandle: opaque JWKS kid string. + + Frozen so it hashes consistently for `SigningTrustContext.trusted_signing_keys` + membership tests. The `kid` is the same string the JWKS adapter + maps to a public key; the adapter's resolver callable does the + lookup at verify time. + """ + + kid: str + + +class DefaultSigningAdapter: + """v1 signing adapter: Ed25519 detached signature over PAE bytes.""" + + def __init__( + self, + *, + private_key_loader: Callable[[JwksKid], Awaitable[bytes]], + public_key_resolver: Callable[[str], Awaitable[bytes]], + clock: Callable[[], datetime], + ) -> None: + self._private_key_loader = private_key_loader + self._public_key_resolver = public_key_resolver + self._clock = clock + + @property + def adapter_version(self) -> str: + return _ADAPTER_VERSION + + async def sign( + self, + canonicalized: CanonicalizedBytes, + key_handle: JwksKid, + ) -> Signature: + self._require_matching_version(canonicalized) + try: + private_bytes = await self._private_key_loader(key_handle) + except KeyError as exc: + raise SigningKeyNotFoundError( + key_handle=key_handle, adapter_version=_ADAPTER_VERSION + ) from exc + private_key = Ed25519PrivateKey.from_private_bytes(private_bytes) + signature_bytes = private_key.sign(canonicalized.bytes_) + return Signature( + bytes_=signature_bytes, + adapter_version=_ADAPTER_VERSION, + key_handle=key_handle, + signed_at=self._clock(), + ) + + async def verify( + self, + canonicalized: CanonicalizedBytes, + signature: Signature, + signing_trust_context: SigningTrustContext, + ) -> SignatureVerification: + self._require_matching_version(canonicalized) + if signature.adapter_version != _ADAPTER_VERSION: + raise CanonicalizationVersionMismatchError( + canonicalized_version=canonicalized.adapter_version, + signing_version=signature.adapter_version, + ) + if signature.key_handle not in signing_trust_context.trusted_signing_keys: + return SignatureVerification( + verdict="Unverifiable", + detail=f"key_handle={signature.key_handle!r} not in trust context", + ) + kid = self._extract_kid(signature.key_handle) + try: + public_key_bytes = await self._public_key_resolver(kid) + except KeyError as exc: + return SignatureVerification( + verdict="Unverifiable", + detail=f"public key not resolvable for kid={kid!r}: {exc}", + ) + try: + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + except ValueError as exc: + return SignatureVerification( + verdict="Unverifiable", + detail=f"public key bytes malformed for kid={kid!r}: {exc}", + ) + try: + public_key.verify(signature.bytes_, canonicalized.bytes_) + except InvalidSignature: + return SignatureVerification( + verdict="Invalid", + detail=f"Ed25519 verify rejected signature for kid={kid!r}", + ) + return SignatureVerification(verdict="Valid") + + @staticmethod + def _require_matching_version(canonicalized: CanonicalizedBytes) -> None: + if canonicalized.adapter_version != _ADAPTER_VERSION: + raise CanonicalizationVersionMismatchError( + canonicalized_version=canonicalized.adapter_version, + signing_version=_ADAPTER_VERSION, + ) + + @staticmethod + def _extract_kid(key_handle: object) -> str: + if isinstance(key_handle, JwksKid): + return key_handle.kid + raise SigningKeyNotFoundError(key_handle=key_handle, adapter_version=_ADAPTER_VERSION) + + +__all__ = ["DefaultSigningAdapter", "JwksKid"] diff --git a/apps/api/src/cora/infrastructure/adapters/in_memory_event_store.py b/apps/api/src/cora/infrastructure/adapters/in_memory_event_store.py index 9b801fc91..496ea73a8 100644 --- a/apps/api/src/cora/infrastructure/adapters/in_memory_event_store.py +++ b/apps/api/src/cora/infrastructure/adapters/in_memory_event_store.py @@ -149,6 +149,7 @@ async def append_streams( principal_id=event.principal_id, signature=event.signature, signature_kid=event.signature_kid, + signature_version=event.signature_version, ) existing.append(stored) self._event_ids.add(event.event_id) diff --git a/apps/api/src/cora/infrastructure/adapters/postgres_event_store.py b/apps/api/src/cora/infrastructure/adapters/postgres_event_store.py index 406739562..d308dbcdf 100644 --- a/apps/api/src/cora/infrastructure/adapters/postgres_event_store.py +++ b/apps/api/src/cora/infrastructure/adapters/postgres_event_store.py @@ -42,7 +42,7 @@ SELECT position, event_id, stream_type, stream_id, version, event_type, schema_version, payload, metadata, correlation_id, causation_id, principal_id, occurred_at, recorded_at, - signature, signature_kid, + signature, signature_kid, signature_version, transaction_id::text AS transaction_id_text FROM events WHERE stream_type = $1 AND stream_id = $2 @@ -59,8 +59,8 @@ INSERT INTO events ( event_id, stream_type, stream_id, version, event_type, schema_version, payload, metadata, correlation_id, causation_id, occurred_at, - principal_id, signature, signature_kid -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + principal_id, signature, signature_kid, signature_version +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) """ _CURRENT_VERSION_SQL = """ @@ -160,6 +160,7 @@ async def append_streams( event.principal_id, event.signature, event.signature_kid, + event.signature_version, ) new_versions[stream.stream_id] = next_version return new_versions @@ -226,6 +227,7 @@ async def _append_in_conn( event.principal_id, event.signature, event.signature_kid, + event.signature_version, ) new_versions[stream.stream_id] = next_version return new_versions @@ -269,4 +271,5 @@ def _row_to_event(row: Any) -> StoredEvent: principal_id=row["principal_id"], signature=row["signature"], signature_kid=row["signature_kid"], + signature_version=row["signature_version"], ) diff --git a/apps/api/src/cora/infrastructure/adapters/signing_registry.py b/apps/api/src/cora/infrastructure/adapters/signing_registry.py new file mode 100644 index 000000000..1353b9e46 --- /dev/null +++ b/apps/api/src/cora/infrastructure/adapters/signing_registry.py @@ -0,0 +1,75 @@ +"""Per-version dispatch for SigningPort adapters. + +`SigningRegistry` is the sibling to `CanonicalizationRegistry` for +the signing side. Same shape: register by `adapter_version`, resolve +by exact-match, raise on duplicates, async-close-all on teardown. +The one shape difference: no `default_version()`. The signing +adapter is dispatched by the canonicalization version on the same +event (the matched-pair invariant); a deployment-wide signing +default would let writers pair a v1 canonicalization with a v2 +signing adapter on the same event, which the row-level fitness +ban explicitly forbids. + +The registry can be empty at construction. The Kernel wires +`DefaultCanonicalizationAdapter` immediately (zero injected deps) +but defers `DefaultSigningAdapter` registration until a later +stage wires concrete key sources (private key loader + public key +resolver). Empty registries are valid; `resolve()` against an +unregistered version raises +`UnsupportedCanonicalizationVersionError` exactly as the +canonicalization side does. +""" + +import contextlib + +from cora.infrastructure.ports.canonicalization import ( + UnsupportedCanonicalizationVersionError, +) +from cora.infrastructure.ports.signing import SigningPort + + +class SigningRegistry: + """Per-version dispatch for SigningPort adapters.""" + + def __init__(self) -> None: + self._routes: list[tuple[str, SigningPort]] = [] + self._closed = False + + def register(self, version: str, adapter: SigningPort) -> None: + """Register `adapter` under `version`. Duplicate version raises ValueError.""" + for existing_version, _ in self._routes: + if existing_version == version: + raise ValueError( + f"Signing adapter already registered for version " + f"{version!r}; re-registration is forbidden" + ) + self._routes.append((version, adapter)) + + def resolve(self, version: str) -> SigningPort: + """Return the adapter registered under `version`. Exact match only.""" + for registered_version, adapter in self._routes: + if registered_version == version: + return adapter + raise UnsupportedCanonicalizationVersionError( + requested_version=version, + registered_versions=self.registered_versions(), + ) + + def registered_versions(self) -> tuple[str, ...]: + """Return the tuple of registered version strings in registration order.""" + return tuple(v for v, _ in self._routes) + + async def aclose(self) -> None: + """Close every registered adapter; idempotent.""" + if self._closed: + return + self._closed = True + for _, adapter in self._routes: + close = getattr(adapter, "aclose", None) + if close is None: + continue + with contextlib.suppress(Exception): + await close() + + +__all__ = ["SigningRegistry"] diff --git a/apps/api/src/cora/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index 25b2e9f13..6234be64d 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -39,10 +39,25 @@ test file individually. """ -from typing import Protocol +from typing import TYPE_CHECKING, Protocol import asyncpg +if TYPE_CHECKING: + from collections.abc import Callable + + from cora.infrastructure.ports.federation import ( + PermitLookup, + PublishPort, + SignaturePort, + ) + +from cora.infrastructure.adapters.canonicalization_registry import ( + CanonicalizationRegistry, +) +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) from cora.infrastructure.adapters.in_memory_credential_lookup import InMemoryCredentialLookup from cora.infrastructure.adapters.in_memory_event_store import InMemoryEventStore from cora.infrastructure.adapters.in_memory_idempotency_store import InMemoryIdempotencyStore @@ -50,6 +65,7 @@ from cora.infrastructure.adapters.postgres_event_store import PostgresEventStore from cora.infrastructure.adapters.postgres_idempotency_store import PostgresIdempotencyStore from cora.infrastructure.adapters.postgres_profile_store import PostgresProfileStore +from cora.infrastructure.adapters.signing_registry import SigningRegistry from cora.infrastructure.auth import build_idp_registry, build_static_subject_mapper from cora.infrastructure.config import Settings from cora.infrastructure.kernel import Kernel, Teardown @@ -96,6 +112,21 @@ def __call__( ) -> Authorize: ... +def _build_default_canonicalization_registry() -> CanonicalizationRegistry: + """Return a CanonicalizationRegistry with the v1 default adapter registered. + + The v1 adapter MUST be registered in every CORA deployment per + project_canonicalization_port_design.md; the default version is + set to "cora/v1" so write-side call sites resolve via the + deployment-wide default. Future v2+ adapters register alongside + via configuration without dislodging v1. + """ + registry = CanonicalizationRegistry() + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + registry.set_default("cora/v1") + return registry + + def make_postgres_kernel( pool: asyncpg.Pool, *, @@ -113,6 +144,9 @@ def make_postgres_kernel( llm: LLM | None = None, logbook_mirror: LogbookMirror | None = None, token_verifier: TokenVerifier | None = None, + publish_port: "PublishPort | None" = None, + signature_port: "SignaturePort | None" = None, + permit_lookup: "PermitLookup | None" = None, ) -> Kernel: """Postgres-backed Kernel primitive. @@ -191,6 +225,11 @@ def make_postgres_kernel( credential_lookup if credential_lookup is not None else InMemoryCredentialLookup() ), profile_store=(profile_store if profile_store is not None else PostgresProfileStore(pool)), + canonicalization_registry=_build_default_canonicalization_registry(), + signing_registry=SigningRegistry(), + publish_port=publish_port, + signature_port=signature_port, + permit_lookup=permit_lookup, pool=pool, llm=llm, logbook_mirror=logbook_mirror, @@ -214,6 +253,9 @@ def make_inmemory_kernel( llm: LLM | None = None, logbook_mirror: LogbookMirror | None = None, token_verifier: TokenVerifier | None = None, + publish_port: "PublishPort | None" = None, + signature_port: "SignaturePort | None" = None, + permit_lookup: "PermitLookup | None" = None, pool: object | None = None, ) -> Kernel: """In-memory Kernel primitive. @@ -286,6 +328,11 @@ def make_inmemory_kernel( credential_lookup if credential_lookup is not None else InMemoryCredentialLookup() ), profile_store=profile_store if profile_store is not None else InMemoryProfileStore(), + canonicalization_registry=_build_default_canonicalization_registry(), + signing_registry=SigningRegistry(), + publish_port=publish_port, + signature_port=signature_port, + permit_lookup=permit_lookup, # pool is intentionally typed `object | None` on this factory's # signature (per the docstring: idempotency-pruner tests pass a # non-None sentinel without standing up real asyncpg). Kernel.pool @@ -411,6 +458,9 @@ async def build_kernel( caution_lookup_factory: CautionLookupFactory | None = None, supply_lookup_factory: SupplyLookupFactory | None = None, credential_lookup_factory: CredentialLookupFactory | None = None, + publish_port_factory: "Callable[[], PublishPort] | None" = None, + signature_port_factory: "Callable[[], SignaturePort] | None" = None, + permit_lookup_factory: "Callable[[], PermitLookup] | None" = None, llm_factory: LLMFactory | None = None, settings: Settings | None = None, ) -> tuple[Kernel, Teardown]: @@ -466,6 +516,9 @@ async def build_kernel( event_store=event_store, idempotency_store=idempotency_store, token_verifier=token_verifier, + publish_port=publish_port_factory() if publish_port_factory is not None else None, + signature_port=signature_port_factory() if signature_port_factory is not None else None, + permit_lookup=permit_lookup_factory() if permit_lookup_factory is not None else None, ) return kernel, _noop_teardown @@ -518,6 +571,9 @@ async def build_kernel( credential_lookup=credential_lookup, llm=llm, token_verifier=token_verifier, + publish_port=publish_port_factory() if publish_port_factory is not None else None, + signature_port=signature_port_factory() if signature_port_factory is not None else None, + permit_lookup=permit_lookup_factory() if permit_lookup_factory is not None else None, ) return kernel, _compose_teardowns([_maybe_llm_teardown(llm), _make_pool_teardown(pool)]) diff --git a/apps/api/src/cora/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index e8edf796e..87abfdc7e 100644 --- a/apps/api/src/cora/infrastructure/kernel.py +++ b/apps/api/src/cora/infrastructure/kernel.py @@ -33,6 +33,10 @@ import asyncpg +from cora.infrastructure.adapters.canonicalization_registry import ( + CanonicalizationRegistry, +) +from cora.infrastructure.adapters.signing_registry import SigningRegistry from cora.infrastructure.config import Settings from cora.infrastructure.ports import ( LLM, @@ -50,6 +54,11 @@ SupplyLookup, TokenVerifier, ) +from cora.infrastructure.ports.federation import ( + PermitLookup, + PublishPort, + SignaturePort, +) @dataclass(frozen=True) @@ -169,11 +178,16 @@ class Kernel: supply_lookup: SupplyLookup credential_lookup: CredentialLookup profile_store: ProfileStore + canonicalization_registry: CanonicalizationRegistry + signing_registry: SigningRegistry pool: asyncpg.Pool | None = None llm: LLM | None = None logbook_mirror: LogbookMirror | None = None token_verifier: TokenVerifier | None = None signer: Signer | None = None + publish_port: PublishPort | None = None + signature_port: SignaturePort | None = None + permit_lookup: PermitLookup | None = None Teardown = Callable[[], Awaitable[None]] diff --git a/apps/api/src/cora/infrastructure/ports/canonicalization.py b/apps/api/src/cora/infrastructure/ports/canonicalization.py new file mode 100644 index 000000000..b6f15bcb4 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/canonicalization.py @@ -0,0 +1,131 @@ +"""Canonicalization port: substrate-neutral content-hash production. + +`CanonicalizationPort` lifts the shipped v1 recipe (stdlib json sort-keys ++ DSSE PAE + SHA-256) into a swappable port surface. The v1 adapter +delegates byte-for-byte to `cora.infrastructure.content_hash` helpers; +future adapters at versions like `"cora/v2-cose"` can ride alongside +v1 without invalidating any shipped `content_hash` on Method, +Plan, CalibrationRevision, or signed DecisionRegistered. + +The `adapter_version` field on `CanonicalizedBytes` is mandatory and +travels with the bytes for the lifetime of the artifact: the verifier +dispatches on it via `CanonicalizationRegistry.resolve(version)`, +never on a deployment-wide default. See +[[project_canonicalization_port_design]] for the lock memo. + +Errors are co-located here per the port-pattern convention: they are +adapter-tier, not HTTP-mapped, and handlers capture them into event +payload metadata per the non-determinism principle. +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class CanonicalizedBytes: + """Canonical wire bytes plus the adapter version that produced them. + + `payload_type` is the URI the bytes were canonicalized under (and + bound into the PAE wrapper at v1). `adapter_version` MUST be set + explicitly; there is no inferred default per the locked invariant + on the value-type boundary. + """ + + bytes_: bytes + adapter_version: str + payload_type: str + + +@runtime_checkable +class CanonicalizationPort(Protocol): + """Produce wire-canonical bytes and round-trip-verify content hashes. + + Sibling to SigningPort. The two are paired by `adapter_version` + (a v1 SigningPort signs over v1 canonicalized bytes); cross-version + pairing is rejected at the signing-port boundary via + `CanonicalizationVersionMismatchError`. + """ + + @property + def adapter_version(self) -> str: ... + + def canonicalize(self, payload_type: str, payload: Any) -> CanonicalizedBytes: ... + + def verify_content_hash(self, payload_type: str, payload: Any, claimed_hash: str) -> bool: ... + + +class CanonicalizationFailedError(Exception): + """Input payload could not be canonicalized under the adapter's allowlist. + + v1 raises this when the recursive canonicalize pass encounters a + type outside the closed allowlist (`UUID | str | int | bool | None + | dict[str, primitive] | list[primitive] | enum | rfc3339-string`). + Decimal, NaN float, bytes leaf, datetime-not-pre-stringified, and + unhashable types all surface here. Reason carries the offending + type or value so audits can replay the failure. + """ + + def __init__(self, payload_type: str, adapter_version: str, reason: str) -> None: + super().__init__( + f"Canonicalization failed for payload_type={payload_type!r} " + f"under adapter_version={adapter_version!r}: {reason}" + ) + self.payload_type = payload_type + self.adapter_version = adapter_version + self.reason = reason + + +class ContentHashMismatchError(Exception): + """Round-trip content-hash verification rejected a payload. + + Raised by `verify_content_hash` only when the caller opts for + raise-on-mismatch instead of the default bool return. Carries + both hashes for forensic comparison and the adapter version that + recomputed the hash. + """ + + def __init__( + self, + payload_type: str, + claimed_hash: str, + recomputed_hash: str, + adapter_version: str, + ) -> None: + super().__init__( + f"Content hash mismatch for payload_type={payload_type!r} " + f"under adapter_version={adapter_version!r}: " + f"claimed={claimed_hash!r} recomputed={recomputed_hash!r}" + ) + self.payload_type = payload_type + self.claimed_hash = claimed_hash + self.recomputed_hash = recomputed_hash + self.adapter_version = adapter_version + + +class UnsupportedCanonicalizationVersionError(Exception): + """`CanonicalizationRegistry.resolve` was asked for an unregistered version. + + Carries the requested version plus the set of registered versions + so operator diagnostics can immediately see the deployment surface + without rerunning the resolver. + """ + + def __init__(self, requested_version: str, registered_versions: Iterable[str]) -> None: + registered_tuple = tuple(registered_versions) + super().__init__( + f"Canonicalization adapter not registered for version " + f"{requested_version!r}; registered={registered_tuple!r}" + ) + self.requested_version = requested_version + self.registered_versions = registered_tuple + + +__all__ = [ + "CanonicalizationFailedError", + "CanonicalizationPort", + "CanonicalizedBytes", + "ContentHashMismatchError", + "UnsupportedCanonicalizationVersionError", +] diff --git a/apps/api/src/cora/infrastructure/ports/event_store.py b/apps/api/src/cora/infrastructure/ports/event_store.py index fa674eed8..77df055f2 100644 --- a/apps/api/src/cora/infrastructure/ports/event_store.py +++ b/apps/api/src/cora/infrastructure/ports/event_store.py @@ -94,6 +94,7 @@ class NewEvent: principal_id: UUID | None = field(kw_only=True) signature: bytes | None = field(default=None, kw_only=True) signature_kid: str | None = field(default=None, kw_only=True) + signature_version: str | None = field(default=None, kw_only=True) @dataclass(frozen=True) @@ -141,6 +142,7 @@ class StoredEvent: principal_id: UUID | None = None signature: bytes | None = None signature_kid: str | None = None + signature_version: str | None = None class ConcurrencyError(Exception): diff --git a/apps/api/src/cora/infrastructure/ports/federation/__init__.py b/apps/api/src/cora/infrastructure/ports/federation/__init__.py new file mode 100644 index 000000000..85cc86a93 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/__init__.py @@ -0,0 +1,110 @@ +"""Federation port-tier package. + +Three Protocols (`PublishPort`, `PullPort`, `SignaturePort`) co-located +in this sub-package because the value-type catalog plus the 12-error +family is more than one file's worth and mirrors +`cora/infrastructure/observability/` shape. + +The fourth federation-relevant port (`SecretStore`) is a kernel-tier +sibling at `cora.infrastructure.ports.secret_store` (consumed by any +BC needing key/secret material, not federation-exclusive). + +All wire-tier vocabulary (DSSE, COSE, JWS, Sigstore, Fulcio, Rekor, +SCITT, JWKS, CBOR) is owned by the adapters under +`cora/federation/adapters/*` and `cora/infrastructure/adapters/*`. +The port tier here stays substrate-neutral. +""" + +from cora.infrastructure.ports.federation.errors import ( + FederationAdoptionWindowClosedError, + FederationCanonicalizationMismatchError, + FederationCircuitOpenError, + FederationCredentialRevokedError, + FederationPermitNotFoundError, + FederationPublicationContentDriftError, + FederationRateLimitExceededError, + FederationReceiptMissingError, + FederationRetryExhaustedError, + FederationSignatureInvalidError, + FederationSignerUntrustedError, + NoAdapterForFacilityError, +) +from cora.infrastructure.ports.federation.permit_lookup import ( + PermitLookup, + PermitLookupResult, +) +from cora.infrastructure.ports.federation.publish_port import PublishPort +from cora.infrastructure.ports.federation.pull_port import PullPort +from cora.infrastructure.ports.federation.signature_port import SignaturePort +from cora.infrastructure.ports.federation.value_types import ( + ArtifactReference, + AssistedBy, + CoDevelopedBy, + CoseSign1ScittEnvelope, + CredentialRef, + DcoEntry, + DsseSigstoreKeylessEnvelope, + DsseStaticJwksEnvelope, + FederationTrustContext, + FetchProvenance, + PublicationStatus, + PublishedArtifact, + PublishReceipt, + PulledArtifact, + Receipt, + Rejected, + RejectionReason, + SignatureEnvelope, + SignedOffBy, + StageName, + StageResult, + UnverifiabilityReason, + Unverifiable, + VerificationOutcome, + Verified, +) + +__all__ = [ + "ArtifactReference", + "AssistedBy", + "CoDevelopedBy", + "CoseSign1ScittEnvelope", + "CredentialRef", + "DcoEntry", + "DsseSigstoreKeylessEnvelope", + "DsseStaticJwksEnvelope", + "FederationAdoptionWindowClosedError", + "FederationCanonicalizationMismatchError", + "FederationCircuitOpenError", + "FederationCredentialRevokedError", + "FederationPermitNotFoundError", + "FederationPublicationContentDriftError", + "FederationRateLimitExceededError", + "FederationReceiptMissingError", + "FederationRetryExhaustedError", + "FederationSignatureInvalidError", + "FederationSignerUntrustedError", + "FederationTrustContext", + "FetchProvenance", + "NoAdapterForFacilityError", + "PermitLookup", + "PermitLookupResult", + "PublicationStatus", + "PublishPort", + "PublishReceipt", + "PublishedArtifact", + "PullPort", + "PulledArtifact", + "Receipt", + "Rejected", + "RejectionReason", + "SignatureEnvelope", + "SignaturePort", + "SignedOffBy", + "StageName", + "StageResult", + "UnverifiabilityReason", + "Unverifiable", + "VerificationOutcome", + "Verified", +] diff --git a/apps/api/src/cora/infrastructure/ports/federation/errors.py b/apps/api/src/cora/infrastructure/ports/federation/errors.py new file mode 100644 index 000000000..d6a5f5d4c --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/errors.py @@ -0,0 +1,269 @@ +"""Federation port-tier error family. + +Mirrors the ControlPort error-family pattern: every error carries +identifying context as keyword fields so logs and decider captures +do not string-parse. None of these are HTTP-mapped at the port tier; +the BC's route layer wraps them into HTTP responses per +`cora.federation.routes` exception handlers. + +`UnauthorizedError` lives in `cora.federation.errors` (BC-tier), NOT +here, because it maps to HTTP 403 and is operator-facing; port-tier +keeps the system-shaped errors that the verifier and adapter recipes +raise. +""" + +from collections.abc import Iterable +from datetime import datetime +from uuid import UUID + +from cora.infrastructure.ports.federation.value_types import ( + ArtifactReference, + PublicationStatus, + StageName, +) + + +class FederationPermitNotFoundError(Exception): + """Port-tier 404-shaped: a permit lookup missed. + + `lookup_kind` discriminates inbound-vs-outbound lookup paths so + the decider can route the diagnostic to the right operator + workflow. + """ + + def __init__(self, permit_id: UUID, lookup_kind: str) -> None: + super().__init__( + f"Federation permit not found: permit_id={permit_id!r} lookup_kind={lookup_kind!r}" + ) + self.permit_id = permit_id + self.lookup_kind = lookup_kind + + +class FederationSignatureInvalidError(Exception): + """Verification ran the math and the artifact failed a stage. + + `failed_stage` names which stage of `VerificationOutcome.stage_results` + flipped to `fail`; recoverable diagnostic for the operator. + """ + + def __init__(self, content_hash: bytes, envelope_kind: str, failed_stage: StageName) -> None: + super().__init__( + f"Federation signature invalid: envelope_kind={envelope_kind!r} " + f"failed_stage={failed_stage!r} content_hash={content_hash.hex()}" + ) + self.content_hash = content_hash + self.envelope_kind = envelope_kind + self.failed_stage = failed_stage + + +class FederationSignerUntrustedError(Exception): + """Math passed but the resolved key is not in our trust anchor. + + Distinct from `FederationSignatureInvalidError` so the operator + can tell "the math failed" from "the math passed but the signer + is not allowed." + """ + + def __init__(self, content_hash: bytes, envelope_kind: str, attempted_key_ref: str) -> None: + super().__init__( + f"Federation signer untrusted: envelope_kind={envelope_kind!r} " + f"attempted_key_ref={attempted_key_ref!r} content_hash={content_hash.hex()}" + ) + self.content_hash = content_hash + self.envelope_kind = envelope_kind + self.attempted_key_ref = attempted_key_ref + + +class FederationPublicationContentDriftError(Exception): + """TOCTOU defense: fetched bytes do not hash to the referenced content. + + SLSA subject-digest binding analog. The pull adapter MUST raise + this before signature verification runs; serving bytes whose + hash does not match the reference is a content-drift attack + that signature checking would silently mask. + """ + + def __init__(self, reference_content_hash: bytes, fetched_content_hash: bytes) -> None: + super().__init__( + f"Federation publication content drift: " + f"reference={reference_content_hash.hex()} fetched={fetched_content_hash.hex()}" + ) + self.reference_content_hash = reference_content_hash + self.fetched_content_hash = fetched_content_hash + + +class FederationCredentialRevokedError(Exception): + """The signing credential for this artifact has been revoked. + + Surfaces when verify-time policy intersects the credential's + revocation timeline; distinct from `SignerUntrustedError` so the + operator can distinguish "never trusted" from "trusted then + revoked." + """ + + def __init__(self, credential_id: UUID, revoked_at: datetime) -> None: + super().__init__( + f"Federation credential revoked: credential_id={credential_id!r} " + f"revoked_at={revoked_at.isoformat()}" + ) + self.credential_id = credential_id + self.revoked_at = revoked_at + + +class FederationRetryExhaustedError(Exception): + """Adapter exhausted its retry policy. + + Decider records the attempt count and the class of the final + error so post-mortems do not require log archaeology. + """ + + def __init__(self, reference: ArtifactReference, attempts: int, last_error_class: str) -> None: + super().__init__( + f"Federation retry exhausted after {attempts} attempts: " + f"last_error_class={last_error_class!r} " + f"source_facility_id={reference.source_facility_id!r}" + ) + self.reference = reference + self.attempts = attempts + self.last_error_class = last_error_class + + +class FederationCircuitOpenError(Exception): + """Circuit-breaker open for the source facility. + + Pull adapter returns this without making the network call, + based on a prior cluster of failures. Decider records and + surfaces a `Retry-After` to the caller. + """ + + def __init__(self, source_facility_id: UUID, opened_at: datetime) -> None: + super().__init__( + f"Federation circuit open for source_facility_id={source_facility_id!r} " + f"since {opened_at.isoformat()}" + ) + self.source_facility_id = source_facility_id + self.opened_at = opened_at + + +class FederationRateLimitExceededError(Exception): + """Peer rate-limited us; carries advertised retry-after as seconds. + + Opaque seconds at the port; the BC's route layer translates to + an HTTP `Retry-After` header on the upstream response when + propagating to the operator. + """ + + def __init__(self, source_facility_id: UUID, retry_after_seconds: int) -> None: + super().__init__( + f"Federation rate limit exceeded for source_facility_id={source_facility_id!r}; " + f"retry_after_seconds={retry_after_seconds}" + ) + self.source_facility_id = source_facility_id + self.retry_after_seconds = retry_after_seconds + + +class FederationAdoptionWindowClosedError(Exception): + """The publication is past its adoption window. + + `publication_status` discriminates the closing reason: + Yanked | Withdrawn | Expired | AbiTierObsoleteOrRemoved. The + verifier raises this BEFORE returning `Verified` so a yanked + artifact can never be adopted via a stale reference. + """ + + def __init__(self, content_hash: bytes, publication_status: PublicationStatus) -> None: + super().__init__( + f"Federation adoption window closed: status={publication_status!r} " + f"content_hash={content_hash.hex()}" + ) + self.content_hash = content_hash + self.publication_status = publication_status + + +class FederationReceiptMissingError(Exception): + """Required receipt kinds absent on a receipt-bearing envelope. + + Per sec-1: raised when `FederationTrustContext.required_receipt_kinds` + is non-empty AND `envelope.receipts` does not contain at least + one receipt of each required kind. Closes the receipt-suppression + downgrade vector where an empty receipts tuple would silently + bypass transparency-log evidence on receipt-bearing arms. + """ + + def __init__( + self, + content_hash: bytes, + envelope_kind: str, + required_receipt_kinds: Iterable[str], + observed_receipt_kinds: Iterable[str], + ) -> None: + required_tuple = tuple(sorted(required_receipt_kinds)) + observed_tuple = tuple(sorted(observed_receipt_kinds)) + super().__init__( + f"Federation receipt missing: envelope_kind={envelope_kind!r} " + f"required={required_tuple!r} observed={observed_tuple!r} " + f"content_hash={content_hash.hex()}" + ) + self.content_hash = content_hash + self.envelope_kind = envelope_kind + self.required_receipt_kinds = required_tuple + self.observed_receipt_kinds = observed_tuple + + +class NoAdapterForFacilityError(Exception): + """`FederationRegistry` has no adapter registered for this facility prefix. + + Mirrors `NoAdapterForAddressError` in ControlPort; the operator + sees the missing prefix from logs alone, without having to + reconstruct routing from config. + """ + + def __init__(self, source_facility_id: UUID) -> None: + super().__init__( + f"No federation adapter registered for source_facility_id={source_facility_id!r}" + ) + self.source_facility_id = source_facility_id + + +class FederationCanonicalizationMismatchError(Exception): + """Cross-canonicalization-profile drift detected. + + Bridges Memo 1 and Memo 3: the verifier raises this when the + expected canonicalization profile (per the trust context's + `payload_type` allowlist) does not match the profile observed + on the artifact's `canonicalization_version`. Distinct from + signature-invalid because the signature would correctly verify + against the wrong profile. + """ + + def __init__( + self, + content_hash: bytes, + expected_canonicalization_profile_id: str, + observed_profile_id: str, + ) -> None: + super().__init__( + f"Federation canonicalization mismatch: " + f"expected={expected_canonicalization_profile_id!r} " + f"observed={observed_profile_id!r} " + f"content_hash={content_hash.hex()}" + ) + self.content_hash = content_hash + self.expected_canonicalization_profile_id = expected_canonicalization_profile_id + self.observed_profile_id = observed_profile_id + + +__all__ = [ + "FederationAdoptionWindowClosedError", + "FederationCanonicalizationMismatchError", + "FederationCircuitOpenError", + "FederationCredentialRevokedError", + "FederationPermitNotFoundError", + "FederationPublicationContentDriftError", + "FederationRateLimitExceededError", + "FederationReceiptMissingError", + "FederationRetryExhaustedError", + "FederationSignatureInvalidError", + "FederationSignerUntrustedError", + "NoAdapterForFacilityError", +] diff --git a/apps/api/src/cora/infrastructure/ports/federation/permit_lookup.py b/apps/api/src/cora/infrastructure/ports/federation/permit_lookup.py new file mode 100644 index 000000000..49f6dffea --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/permit_lookup.py @@ -0,0 +1,83 @@ +"""PermitLookup port: cross-BC query for Federation BC's permit projection. + +Used by per-BC publish/pull slice handlers to resolve the matching +outbound-direction or inbound-direction Permit at command time, +before composing a `FederationTrustContext` for SignaturePort calls +or assembling the cross-BC `append_streams` over the Permit stream. + +## Convention + +Mirrors the existing CredentialLookup precedent: cross-BC port +shaped around the CONSUMER's need ("what permit authorizes this +publication / pull?"), Federation BC ships the +`PostgresPermitLookup` adapter reading +`proj_federation_permit_summary`, and the in-memory adapter at +`cora.federation.adapters.in_memory_permit_lookup` is the test +default until real federation peers connect. + +## No BC imports in the port + +`direction` and `status` are typed as `str` so this port stays +inside `cora.infrastructure`'s `depends_on = []` tach contract. +The string values match the BC-tier `Direction` and `PermitStatus` +StrEnums (`Outbound | Inbound`, `Defined | Active | Suspended | Revoked`). +Consumers compare via literal equality on the strings. + +## Why outbound/inbound on a single port + +The per-BC publish slice needs an outbound permit; the per-BC pull +slice needs an inbound permit. The two lookups share the same +projection table and almost-identical resolution logic (key on +peer_facility_id + artifact_kind + direction); collapsing onto one +port keeps both slice handlers reading from one shipped adapter +instead of two. Per consumer-shaped port discipline, both shapes +land here. +""" + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable +from uuid import UUID + + +@dataclass(frozen=True) +class PermitLookupResult: + """Summary row from `proj_federation_permit_summary` for slice handlers. + + Carries the minimal columns a per-BC publish / pull handler needs + to compose a FederationTrustContext and stage the cross-BC + `append_streams` over the Permit stream. The loaded version is + surfaced as `current_version` so the handler can pass it as the + expected_version to `append_streams` without re-loading the + full Permit stream. + """ + + permit_id: UUID + peer_facility_id: str + direction: str + status: str + abi_tier_floor: str + current_version: int + + +@runtime_checkable +class PermitLookup(Protocol): + """Cross-BC port: query Federation's permit projection by direction. + + `lookup_outbound` is the publish-side query (matches a peer- + facility id + artifact kind to the outbound Permit that + authorizes publishing to that peer); `lookup_inbound` is the + pull-side query (matches a peer-facility id + artifact kind to + the inbound Permit that authorizes pulling from that peer). + Both return None when no matching active permit exists. + """ + + async def lookup_outbound( + self, peer_facility_id: str, artifact_kind: str + ) -> PermitLookupResult | None: ... + + async def lookup_inbound( + self, peer_facility_id: str, artifact_kind: str + ) -> PermitLookupResult | None: ... + + +__all__ = ["PermitLookup", "PermitLookupResult"] diff --git a/apps/api/src/cora/infrastructure/ports/federation/publish_port.py b/apps/api/src/cora/infrastructure/ports/federation/publish_port.py new file mode 100644 index 000000000..9dbb0b958 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/publish_port.py @@ -0,0 +1,28 @@ +"""PublishPort: publish a domain-shaped artifact to the federation surface. + +Per the federation port-tier design, the port speaks +`PublishedArtifact` + `PublishReceipt`; wire-tier vocabulary +(DSSE, COSE, Sigstore, Rekor, SCITT, CBOR) is owned by the +adapters. Adapters land at `cora/federation/adapters/*`. + +An in-memory adapter ships as the test-tier substitute in a +follow-up iteration; production wire-tier adapters land later with +the matching library pins. +""" + +from typing import Protocol, runtime_checkable + +from cora.infrastructure.ports.federation.value_types import ( + PublishedArtifact, + PublishReceipt, +) + + +@runtime_checkable +class PublishPort(Protocol): + """Publish a domain-shaped artifact to the federation surface.""" + + async def publish(self, artifact: PublishedArtifact) -> PublishReceipt: ... + + +__all__ = ["PublishPort"] diff --git a/apps/api/src/cora/infrastructure/ports/federation/pull_port.py b/apps/api/src/cora/infrastructure/ports/federation/pull_port.py new file mode 100644 index 000000000..ff6ecfedb --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/pull_port.py @@ -0,0 +1,32 @@ +"""PullPort: fetch a domain-shaped artifact from a peer facility. + +The port returns the verified bytes plus the parsed envelope and +lets the verify-and-apply orchestrator at +`cora.infrastructure.published_artifact` drive the per-arm recipe. +Verification + application live in the shared kernel, not on this +port; the port is the wire-and-trust seam. + +Per AH#17: PullPort.fetch MUST raise +`FederationPublicationContentDriftError` if the fetched bytes do +not hash to `reference.content_hash` BEFORE returning. This is +the TOCTOU defense; signature verification cannot recover from a +content-drift attack because the signature would correctly verify +against the wrong bytes. +""" + +from typing import Protocol, runtime_checkable + +from cora.infrastructure.ports.federation.value_types import ( + ArtifactReference, + PulledArtifact, +) + + +@runtime_checkable +class PullPort(Protocol): + """Fetch a domain-shaped artifact by reference.""" + + async def fetch(self, reference: ArtifactReference) -> PulledArtifact: ... + + +__all__ = ["PullPort"] diff --git a/apps/api/src/cora/infrastructure/ports/federation/signature_port.py b/apps/api/src/cora/infrastructure/ports/federation/signature_port.py new file mode 100644 index 000000000..4ab452de5 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/signature_port.py @@ -0,0 +1,56 @@ +"""SignaturePort: federation-tier verify-and-sign over SignatureEnvelope. + +Per arch-2 (SignaturePort delegates to SigningPort): this port owns +envelope construction + receipt hooks + credential resolution. Raw +signature math (`Ed25519.sign`, `ECDSA.sign`) lives on +`SigningPort` at the kernel tier (Memo 3). The architecture-fitness +test `tests/architecture/test_signature_port_delegates_to_signing_port.py` +will land alongside the first wire-tier adapter and assert that +every `SignaturePort.sign` adapter calls a method on a `SigningPort` +instance for the underlying signature math, never invoking crypto +libraries (`cryptography.hazmat`, `nacl`, `pyca`) directly. + +`canonicalized` is `CanonicalizedBytes` (from the kernel SigningPort +module), NOT raw `bytes`; this prevents bypassing the canonicalization +recipe. + +Per AH#9: SignaturePort.sign MUST NOT accept raw key material. The +adapter resolves the signing credential from +`trust_context.allowed_credentials` via SecretStore and delegates +to SigningPort. No `sign_with_raw_key` variant. + +Rejected: per-family Protocols (`DsseSignaturePort`, +`CoseSignaturePort`, `PgpSignaturePort`). One Protocol over the +tagged-union `SignatureEnvelope`; per-family parse and per-arm +crypto recipes live inside `cora/federation/adapters/_signature_port.py`. +""" + +from typing import Protocol, runtime_checkable + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.federation.value_types import ( + FederationTrustContext, + PublishedArtifact, + SignatureEnvelope, + VerificationOutcome, +) + + +@runtime_checkable +class SignaturePort(Protocol): + """Verify a SignatureEnvelope; build a fresh envelope over canonical bytes.""" + + async def verify( + self, + artifact: PublishedArtifact, + trust_context: FederationTrustContext, + ) -> VerificationOutcome: ... + + async def sign( + self, + canonicalized: CanonicalizedBytes, + trust_context: FederationTrustContext, + ) -> SignatureEnvelope: ... + + +__all__ = ["SignaturePort"] diff --git a/apps/api/src/cora/infrastructure/ports/federation/value_types.py b/apps/api/src/cora/infrastructure/ports/federation/value_types.py new file mode 100644 index 000000000..ff6e9614e --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/value_types.py @@ -0,0 +1,419 @@ +"""Federation port-tier value types: domain-shaped, substrate-neutral. + +These are the vocabulary the three federation ports (`PublishPort`, +`PullPort`, `SignaturePort`) speak in. None of the names here mention +DSSE, COSE, JWS, Sigstore, Fulcio, Rekor, SCITT, JWKS, or CBOR (per +anti-hooks #1 + #3): wire-tier vocabulary is owned by the adapters +under `cora/federation/adapters/*` and `cora/infrastructure/adapters/*`. + +`abi_tier` is typed `str` at this tier for now; the closed-enum +`AbiTier(Testing | Stable | Obsolete | Removed)` lives at BC-tier in +`cora.federation.aggregates.permit.state.AbiTier` and will hoist to +this module in a follow-up iteration once the BC-tier import sites +are refactored (~9 files). The string discipline is documented but +not enforced at the port boundary today. +""" + +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal +from uuid import UUID + + +@dataclass(frozen=True, slots=True) +class CredentialRef: + """Opaque reference to a Federation BC `Credential` aggregate id. + + The verifier uses `credential_id` to look up the public-key + material via the SecretStore-backed federation adapter. The port + surface never carries raw key bytes, only the reference. + """ + + credential_id: UUID + + +@dataclass(frozen=True, slots=True) +class Receipt: + """Transparency-log inclusion proof or COSE Receipt. + + Opaque at the port. `bytes_` is the raw receipt payload; the + matching adapter (Sigstore/Rekor, SCITT, TS) parses and verifies + it. The `kind` discriminator routes verification to the right + receipt parser. + """ + + kind: Literal["scitt", "rekor_sct", "ts_authority"] + bytes_: bytes + + +@dataclass(frozen=True, slots=True) +class DsseStaticJwksEnvelope: + """SignatureEnvelope arm for DSSE + static JWKS adapter. + + The arm-specific payload fields are owned by Memo 2 (adapter + design). At the port tier we only need the discriminator + the + `signing_version` cross-tier link to the canonicalization + recipe. `payload_bytes` is opaque to the port. + """ + + signing_version: str + payload_bytes: bytes + kind: Literal["dsse_static_jwks"] = "dsse_static_jwks" + receipts: tuple[Receipt, ...] = () + + +@dataclass(frozen=True, slots=True) +class DsseSigstoreKeylessEnvelope: + """SignatureEnvelope arm for DSSE + Sigstore-keyless adapter.""" + + signing_version: str + payload_bytes: bytes + kind: Literal["dsse_sigstore_keyless"] = "dsse_sigstore_keyless" + receipts: tuple[Receipt, ...] = () + + +@dataclass(frozen=True, slots=True) +class CoseSign1ScittEnvelope: + """SignatureEnvelope arm for COSE_Sign1 + SCITT adapter.""" + + signing_version: str + payload_bytes: bytes + kind: Literal["cose_sign1_scitt"] = "cose_sign1_scitt" + receipts: tuple[Receipt, ...] = () + + +SignatureEnvelope = DsseStaticJwksEnvelope | DsseSigstoreKeylessEnvelope | CoseSign1ScittEnvelope +"""Discriminated tagged union over `kind`. Consumers dispatch via +`isinstance` or by matching on `envelope.kind`. The three arms are +the locked v1 set per the memo; new arms expand the union and add a +new `kind` literal.""" + + +@dataclass(frozen=True, slots=True) +class SignedOffBy: + """Human Signed-off-by attribution; the only DCO arm that closes the chain. + + Per the Linux-kernel `coding-assistants.rst` invariant transplant, + AI actors cannot Signed-off-by. The federation decider enforces + that `actor_id` resolves to `Actor.kind == human`; an Agent + actor_id raises `DcoChainMissingHumanSignoffError`. + """ + + actor_id: UUID + signed_at: datetime + + +@dataclass(frozen=True, slots=True) +class AssistedBy: + """AI contribution short of authorship. + + `model_ref` is a free-form string today; future iterations may + restructure to a typed VO reusing the shipped `ModelRef` + vocabulary in subscriber code. `citation` carries the prompt / + decision-id provenance link. + """ + + agent_id: UUID + model_ref: str + assisted_at: datetime + citation: str + + +@dataclass(frozen=True, slots=True) +class CoDevelopedBy: + """Collaborative attribution between TWO HUMAN actors. + + Mirrors the Linux kernel `CO-DEVELOPED-BY` convention. Both + `actor_id_a` and `actor_id_b` MUST resolve to `Actor.kind == + human`; Agent actor_ids raise `CoDevelopedByForbidsAgentError` + at the decider. + """ + + actor_id_a: UUID + actor_id_b: UUID + co_developed_at: datetime + + +DcoEntry = SignedOffBy | AssistedBy | CoDevelopedBy +"""DCO chain entry discriminated union. The chain MUST contain at +least one `SignedOffBy` entry resolving to a human actor; AI agents +ride exclusively on `AssistedBy`.""" + + +@dataclass(frozen=True, slots=True) +class PublishedArtifact: + """Domain-shaped artifact ready to publish or verify. + + The port boundary carries everything a peer facility needs to + verify the artifact end-to-end: the canonical bytes, the + signature envelope, the canonicalization recipe identifier, + the DCO chain, the ABI tier + lifecycle slots. No wire-tier + vocabulary leaks through. + + `abi_tier` is `str` at this tier (see module docstring). + `canonicalization_version` is the recipe identifier per + [[project_canonicalization_port_design]]; the verifier + dispatches to the matching adapter via + `CanonicalizationRegistry.resolve(version)`. + """ + + content_hash: bytes + canonical_bytes: bytes + payload_type: str + signature_envelope: SignatureEnvelope + source_facility_id: UUID + published_at: datetime + expires_at: datetime | None + abi_tier: str + dco_chain: tuple[DcoEntry, ...] + schema_version: int + canonicalization_version: str + + +@dataclass(frozen=True, slots=True) +class ArtifactReference: + """Opaque pointer to a `PublishedArtifact` on a peer facility. + + Security-load-bearing equality is on `content_hash + payload_type`, + NOT on `hint_locator`. The locator is the adapter's hint for how + to reach the bytes (HTTP URL, IPFS CID, registry-prefix path); + the verifier always recomputes the hash from the fetched bytes + and rejects on drift via `FederationPublicationContentDriftError`. + """ + + content_hash: bytes + payload_type: str + source_facility_id: UUID + hint_locator: str + + +@dataclass(frozen=True, slots=True) +class PublishReceipt: + """Receipt the peer-facing PublishPort hands back on success. + + `receipt_bytes` is opaque at the port. The Federation BC's + `record_receipt` slice persists it on the matching outbound- + direction `Permit` for later third-party verification. The + hints describe shape for diagnostics; no parsing required. + """ + + receipt_bytes: bytes + receipt_format_hint: str + transparency_log_hint: str + recorded_at: datetime + + +@dataclass(frozen=True, slots=True) +class FetchProvenance: + """Audit trail for a `PullPort.fetch` call. + + Captures what the adapter actually negotiated on the wire so + replays can prove integrity beyond byte-content equality: + the locator used (may differ from the reference's hint if the + adapter rewrote), the wire content-type, fetch duration, byte + count. + """ + + locator_used: str + wire_content_type: str + fetch_duration_ms: int + byte_count: int + + +@dataclass(frozen=True, slots=True) +class PulledArtifact: + """The verified artifact plus the provenance of how it arrived.""" + + artifact: PublishedArtifact + fetch_provenance: FetchProvenance + + +@dataclass(frozen=True, slots=True) +class FederationTrustContext: + """Policy under which a federation verification runs. + + Composed at verify time from the matching inbound-direction + `Permit` (per arch-7, the aggregate's `InboundTerms` variant + carries flat fields, not a `FederationTrustContext` field, to + avoid Permit -> FederationTrustContext -> Permit circular + containment). + + `accept_yanked: Literal[False]` is structural: there is no + `accept_expired` override, period (AH#19). The verifier never + accepts a yanked publication; only an explicit `unyank` + operator action restores it. + + `required_receipt_kinds` is the consumer-side floor for + transparency-log evidence per arm; default empty for backward + compat. When non-empty, the verifier raises + `FederationReceiptMissingError` if `envelope.receipts` does + not contain at least one receipt matching each required kind. + """ + + permit_id: UUID + allowed_credentials: frozenset[CredentialRef] + allowed_payload_types: frozenset[str] + abi_tier_floor: str + accept_yanked: Literal[False] = False + required_receipt_kinds: frozenset[Literal["scitt", "rekor_sct", "ts_authority"]] = field( + default_factory=lambda: frozenset[Literal["scitt", "rekor_sct", "ts_authority"]]() + ) + + +StageName = Literal[ + "payload_type_trusted", + "content_hash", + "signature", + "key_resolution", + "issuer_match", + "transparency_log_inclusion", + "key_validity_at_sign_time", + "payload_type_known", + "abi_tier", + "expires_at", + "head_pointer_fresh", + "replay_cache", + "dco_chain", +] +"""Closed enum of verifier stage names. Covers all three envelope arms; +new arms may add new stages at the end of the literal union.""" + + +@dataclass(frozen=True, slots=True) +class StageResult: + """Per-stage verification result. + + `outcome` is closed: pass | fail | skip. `detail` is opaque + forensics; never parsed by callers (mirrors + `Reading.quality_detail` in ControlPort). + """ + + stage: StageName + outcome: Literal["pass", "fail", "skip"] + detail: str = "" + + +@dataclass(frozen=True, slots=True) +class RejectionReason: + """Detail attached to a `Rejected` verification outcome. + + `failed_stage` names the first stage that flipped to `fail`; + `reason` is opaque human-readable context for operator logs. + """ + + failed_stage: StageName + reason: str + + +@dataclass(frozen=True, slots=True) +class UnverifiabilityReason: + """Detail attached to an `Unverifiable` verification outcome. + + Distinct from `RejectionReason` because the verifier could not + run the math at all (key gone, algorithm not implemented, + transient outage); a future retry may succeed where a `Rejected` + outcome would not. + """ + + failed_stage: StageName + reason: str + + +@dataclass(frozen=True, slots=True) +class Verified: + """The verifier ran every stage and all passed (or were skipped).""" + + stage_results: tuple[StageResult, ...] + + +@dataclass(frozen=True, slots=True) +class Rejected: + """The verifier ran the math and the artifact failed a stage.""" + + stage_results: tuple[StageResult, ...] + rejection: RejectionReason + + +@dataclass(frozen=True, slots=True) +class Unverifiable: + """The verifier could not run a stage to completion.""" + + stage_results: tuple[StageResult, ...] + unverifiability: UnverifiabilityReason + + +VerificationOutcome = Verified | Rejected | Unverifiable +"""Discriminated union over verifier outcome. Callers branch via +`isinstance`. The three arms map exactly to the SignatureVerification +verdict triple on the kernel-tier SigningPort: +Verified ≅ Valid, Rejected ≅ Invalid, Unverifiable ≅ Unverifiable.""" + + +PublicationStatus = Literal["Live", "Yanked", "Withdrawn", "Expired", "AbiTierObsoleteOrRemoved"] +"""Closed enum for `FederationAdoptionWindowClosedError.publication_status`. +The first three mirror the publication-lifecycle FSM; `Expired` covers +time-based windows; `AbiTierObsoleteOrRemoved` covers the ABI ladder +edges that close the adoption window without retiring the bytes.""" + + +def is_envelope_kind(envelope: SignatureEnvelope, kind: str) -> bool: + """Match an envelope against a `kind` literal without isinstance dispatch. + + Helper for routing code that holds a `kind` string from a config + or a wire payload and wants to verify it matches the envelope's + own discriminator. Keeps the dispatch out of `if/elif` ladders. + """ + return envelope.kind == kind + + +def envelope_signing_version(envelope: SignatureEnvelope) -> str: + """Extract `signing_version` regardless of arm. + + Equivalent to `envelope.signing_version` since every arm carries + it; provided as a function so future arms that need access via a + different attribute name can be funnelled through one helper. + """ + return envelope.signing_version + + +def stage_results_outcome_counts( + stage_results: Sequence[StageResult], +) -> dict[str, int]: + """Return a counts dict per outcome label for logging/diagnostics.""" + counts = {"pass": 0, "fail": 0, "skip": 0} + for r in stage_results: + counts[r.outcome] = counts[r.outcome] + 1 + return counts + + +__all__ = [ + "ArtifactReference", + "AssistedBy", + "CoDevelopedBy", + "CoseSign1ScittEnvelope", + "CredentialRef", + "DcoEntry", + "DsseSigstoreKeylessEnvelope", + "DsseStaticJwksEnvelope", + "FederationTrustContext", + "FetchProvenance", + "PublicationStatus", + "PublishReceipt", + "PublishedArtifact", + "PulledArtifact", + "Receipt", + "Rejected", + "RejectionReason", + "SignatureEnvelope", + "SignedOffBy", + "StageName", + "StageResult", + "UnverifiabilityReason", + "Unverifiable", + "VerificationOutcome", + "Verified", + "envelope_signing_version", + "is_envelope_kind", + "stage_results_outcome_counts", +] diff --git a/apps/api/src/cora/infrastructure/ports/signer.py b/apps/api/src/cora/infrastructure/ports/signer.py index 35db3774c..19735eabe 100644 --- a/apps/api/src/cora/infrastructure/ports/signer.py +++ b/apps/api/src/cora/infrastructure/ports/signer.py @@ -23,10 +23,14 @@ ## What `sign` returns -A tuple `(signature, kid)`. `signature` is the raw 64-byte Ed25519 -output (`alg=EdDSA` per the design lock); `kid` is the key identifier -that lets the verifier resolve the matching public key. The semantics -of `kid` vary by adapter: +A tuple `(signature, kid, signing_version)`. `signature` is the raw +64-byte Ed25519 output (`alg=EdDSA` per the design lock); `kid` is +the key identifier that lets the verifier resolve the matching public +key; `signing_version` is the signing-recipe identifier per +[[project_canonicalization_port_design]] (the v1 default is +`"cora/v1"`), recorded so the verifier can dispatch to the matching +SigningPort adapter via the SigningRegistry. The semantics of `kid` +vary by adapter: - Sigstore Fulcio: cert serial of the short-lived OIDC-bound cert - SPIFFE / SVID: the SVID's SPIFFE ID @@ -124,8 +128,8 @@ class Signer(Protocol): Implementations (none ship today; see module docstring for the planned set) resolve a private key for the actor, canonicalize the payload using the shared content-hash pipeline, wrap with DSSE PAE, - and sign. Return value is `(signature, kid)` for the handler to - persist alongside the event row. + and sign. Return value is `(signature, kid, signing_version)` for + the handler to persist alongside the event row. Every method is async because the production adapters (Sigstore Fulcio, KMS) are network-bound. In-process adapters (local @@ -138,7 +142,7 @@ async def sign( event_type: str, payload: Mapping[str, Any], actor_id: UUID, - ) -> tuple[bytes, str]: + ) -> tuple[bytes, str, str]: """Produce a signature over the canonicalized + PAE-wrapped payload. `event_type` is the unbracketed event-type name (for example @@ -158,9 +162,16 @@ async def sign( shares a row with an Actor.id per [[project_agent_bc_design]]). The adapter uses this to look up the private key. - Returns `(signature, kid)`. `signature` is raw bytes (64 bytes - for Ed25519). `kid` is the adapter-specific key identifier the - verifier passes to its public-key resolver. + Returns `(signature, kid, signing_version)`. `signature` is raw + bytes (64 bytes for Ed25519). `kid` is the adapter-specific key + identifier the verifier passes to its public-key resolver. + `signing_version` is the signing-recipe identifier + (`"cora/v1"` for the shipped Ed25519-over-DSSE-PAE recipe); + the verifier dispatches to the matching SigningPort adapter + via the SigningRegistry. Adapters MUST return the version + string that names their signing recipe; the matched-pair + invariant is enforced row-by-row by an architecture-fitness + test. Failure modes: - `SignerKeyNotFoundError`: actor has no registered key diff --git a/apps/api/src/cora/infrastructure/ports/signing.py b/apps/api/src/cora/infrastructure/ports/signing.py new file mode 100644 index 000000000..b4b6a1af8 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/signing.py @@ -0,0 +1,215 @@ +"""Signing port: substrate-neutral signature production over canonical bytes. + +`SigningPort` is the sibling to `CanonicalizationPort`. The two are +paired by `adapter_version` (a v1 SigningPort signs over v1 +canonicalized bytes); cross-version pairing is rejected at the +port boundary via `CanonicalizationVersionMismatchError`. + +The v1 adapter Ed25519-signs over `CanonicalizedBytes.bytes_` and +narrows `KeyHandle` to a `JwksKid` carrying the JWKS kid string. +Future arms can narrow `KeyHandle` differently (an X.509 chain +reference for Sigstore-keyless; a COSE_Key thumbprint for +COSE_Sign1) without changing the port surface. + +`SigningTrustContext` carries the policy under which signature +verification runs (trusted keys, algorithm allowlist, validity +window, expected payload type). It is sibling to Memo 1's +`FederationTrustContext` which carries federation-tier policy +(`allowed_credentials`, `abi_tier_floor`, `required_receipt_kinds`); +the two are at different tiers and have distinct shapes. + +Note on naming: this `SigningPort` is the NEW shape from the +canonicalization-port re-cut. The older `Signer` Protocol at +`cora.infrastructure.ports.signer` ships zero adapters today and +will be widened or retired in a later step per the lock memo's +iter-b step 5; the two coexist during the transition. +""" + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Literal, Protocol, runtime_checkable + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes + +KeyHandle = Any +"""Opaque adapter-specific key reference. + +Typed as `Any` at the port boundary; each adapter narrows internally. +v1 uses `JwksKid(kid: str)`; future Sigstore arm uses an X.509 chain +reference; future COSE arm uses a COSE_Key thumbprint or CBOR bstr kid. +""" + + +@dataclass(frozen=True, slots=True) +class Signature: + """Raw signature bytes plus the adapter version that produced them. + + Parallel to `CanonicalizedBytes` so `adapter_version` is the single + dispatch key on either side. `bytes_` is the raw signature output + (64 bytes for v1 Ed25519; variable for future arms). `signed_at` + captures the wall-clock moment the signature was produced and is + bound into the signature payload metadata, not the signed bytes. + """ + + bytes_: bytes + adapter_version: str + key_handle: KeyHandle + signed_at: datetime + + +@dataclass(frozen=True, slots=True) +class SigningTrustContext: + """Policy under which a signature verification runs. + + `trusted_signing_keys` is the frozenset of key handles the + verifier will accept (membership-tested against the + `Signature.key_handle` field). `algorithm_allowlist` rejects + legacy or downgrade algorithms before any crypto runs. + `expected_payload_type` is the URI the canonicalized bytes MUST + have been bound to. `validity_window` is an optional pair + `(not_before, not_after)`; `None` skips the temporal check. + """ + + trusted_signing_keys: frozenset[KeyHandle] + algorithm_allowlist: frozenset[str] + expected_payload_type: str + validity_window: tuple[datetime, datetime] | None + + +@dataclass(frozen=True, slots=True) +class SignatureVerification: + """Closed-enum verdict plus opaque detail string. + + Mirrors ControlPort's `Quality` + `quality_detail` pattern: the + verdict drives downstream policy, the detail string is for + forensics and never parsed by callers. `Unverifiable` is distinct + from `Invalid`: `Invalid` means "the math rejected the signature"; + `Unverifiable` means "the verifier could not run the math at all" + (key not resolvable, algorithm not implemented, transient backend + outage). + """ + + verdict: Literal["Valid", "Invalid", "Unverifiable"] + detail: str = "" + + +@runtime_checkable +class SigningPort(Protocol): + """Sign canonicalized bytes under a key handle; verify signatures. + + Both methods are async because production adapters are network- + bound (Fulcio short-lived cert issuance, KMS sign, transparency- + log inclusion). In-process adapters wrap their sync work as async. + """ + + @property + def adapter_version(self) -> str: ... + + async def sign( + self, + canonicalized: CanonicalizedBytes, + key_handle: KeyHandle, + ) -> Signature: ... + + async def verify( + self, + canonicalized: CanonicalizedBytes, + signature: Signature, + signing_trust_context: SigningTrustContext, + ) -> SignatureVerification: ... + + +class SigningKeyNotFoundError(Exception): + """Adapter cannot resolve the supplied `key_handle`. + + Distinct from `SignatureInvalidError` so the operator can tell + "key gone" from "key present but signature does not verify." + Surfaces when JWKS rotation is mid-flight, the kid is not in + the current JWKS, or an X.509 chain does not anchor against + pinned roots. + """ + + def __init__(self, key_handle: KeyHandle, adapter_version: str) -> None: + super().__init__( + f"Signing key not found for key_handle={key_handle!r} " + f"under adapter_version={adapter_version!r}" + ) + self.key_handle = key_handle + self.adapter_version = adapter_version + + +class SignatureInvalidError(Exception): + """Signature bytes verified against canonicalized bytes and rejected. + + Reason carries the adapter-specific failure mode: Ed25519 verify + returned False; X.509 leaf expired at sign time; SAN extension + mismatch on a Sigstore Fulcio cert. + """ + + def __init__(self, adapter_version: str, reason: str) -> None: + super().__init__(f"Signature invalid under adapter_version={adapter_version!r}: {reason}") + self.adapter_version = adapter_version + self.reason = reason + + +class UnsupportedSigningAlgorithmError(Exception): + """The `KeyHandle` references an algorithm the adapter does not implement. + + Raised before any crypto runs. Used by the algorithm-allowlist + gate inside `SigningPort.verify` so legacy algorithms cannot + downgrade a verification. + """ + + def __init__(self, requested_algorithm: str, adapter_version: str) -> None: + super().__init__( + f"Signing algorithm {requested_algorithm!r} not supported " + f"by adapter_version={adapter_version!r}" + ) + self.requested_algorithm = requested_algorithm + self.adapter_version = adapter_version + + +class CanonicalizationVersionMismatchError(Exception): + """Cross-version signing rejected at the port boundary. + + A v1 SigningPort refuses to sign over v2 CanonicalizedBytes and + vice versa: pairing is mandatory per the + `signing_version == canonicalization_version` invariant. The + architecture-fitness suite asserts this row-by-row on every + signed event. + """ + + def __init__(self, canonicalized_version: str, signing_version: str) -> None: + super().__init__( + f"Canonicalization/signing version mismatch: " + f"canonicalized={canonicalized_version!r} signing={signing_version!r}" + ) + self.canonicalized_version = canonicalized_version + self.signing_version = signing_version + + +def algorithms_intersection(requested: Iterable[str], allowlist: frozenset[str]) -> frozenset[str]: + """Return the intersection of requested algorithms with an allowlist. + + Helper that adapters reuse when narrowing a candidate algorithm + set against `SigningTrustContext.algorithm_allowlist`. Returns + the allowed subset; an empty result means the caller must raise + `UnsupportedSigningAlgorithmError` against the first requested + entry. + """ + return frozenset(requested) & allowlist + + +__all__ = [ + "CanonicalizationVersionMismatchError", + "KeyHandle", + "Signature", + "SignatureInvalidError", + "SignatureVerification", + "SigningKeyNotFoundError", + "SigningPort", + "SigningTrustContext", + "UnsupportedSigningAlgorithmError", + "algorithms_intersection", +] diff --git a/apps/api/src/cora/infrastructure/published_artifact/__init__.py b/apps/api/src/cora/infrastructure/published_artifact/__init__.py new file mode 100644 index 000000000..c2e997331 --- /dev/null +++ b/apps/api/src/cora/infrastructure/published_artifact/__init__.py @@ -0,0 +1,41 @@ +"""Generic Subdomain shared-kernel for verify-then-apply on federation artifacts. + +The verify-then-apply orchestrator at +`cora.infrastructure.published_artifact.orchestrator` is the +Generic Subdomain coordinator that the Federation BC and every +per-BC pull slice consume. It composes BEFORE-gates, the +arm-specific SignaturePort.verify dispatch, and AFTER-gates +into a single VerificationOutcome. + +Per project_federation_port_design.md the orchestrator does not +apply the artifact; the caller's per-BC pull-slice handler reads +the outcome and either appends the matching `Imported` +event (on Verified) or surfaces a diagnostic to the operator (on +Rejected / Unverifiable). +""" + +from cora.infrastructure.published_artifact._stages import ( + check_abi_tier, + check_content_hash, + check_dco_chain, + check_expires_at, + check_payload_type_trusted, + check_required_receipts_present, + dco_chain_has_human_actor, + deferred_stage, + is_terminal_publication_status, +) +from cora.infrastructure.published_artifact.orchestrator import verify_then_apply + +__all__ = [ + "check_abi_tier", + "check_content_hash", + "check_dco_chain", + "check_expires_at", + "check_payload_type_trusted", + "check_required_receipts_present", + "dco_chain_has_human_actor", + "deferred_stage", + "is_terminal_publication_status", + "verify_then_apply", +] diff --git a/apps/api/src/cora/infrastructure/published_artifact/_stages.py b/apps/api/src/cora/infrastructure/published_artifact/_stages.py new file mode 100644 index 000000000..65cf31adc --- /dev/null +++ b/apps/api/src/cora/infrastructure/published_artifact/_stages.py @@ -0,0 +1,266 @@ +"""Verifier stage helpers: pure functions, one per non-arm-specific gate. + +The verify-then-apply orchestrator at +`cora.infrastructure.published_artifact.orchestrator` composes +these helpers around the arm-specific SignaturePort.verify call. +Each helper returns a StageResult so the orchestrator can build +the final `VerificationOutcome.stage_results` tuple without +re-implementing the per-stage shape. + +Closed-set semantics on every helper: + - "pass" means the gate evaluated and accepted + - "fail" means the gate evaluated and rejected (rejection path) + - "skip" means the gate could not run (unverifiable path) OR + is deferred to a future iteration + +`detail` carries forensics for operator logs; never parsed by +callers per the ControlPort `Reading.quality_detail` precedent. +""" + +from datetime import datetime + +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationFailedError, + CanonicalizationPort, +) +from cora.infrastructure.ports.federation.value_types import ( + DcoEntry, + FederationTrustContext, + PublicationStatus, + PublishedArtifact, + Receipt, + SignatureEnvelope, + SignedOffBy, + StageResult, +) + +_ABI_TIER_ORDER: dict[str, int] = { + "Testing": 1, + "Stable": 2, + "Obsolete": 3, + "Removed": 4, +} + + +def check_payload_type_trusted( + artifact: PublishedArtifact, trust_context: FederationTrustContext +) -> StageResult: + """Pass when `artifact.payload_type` is in the trust context's allowlist.""" + if not trust_context.allowed_payload_types: + return StageResult( + stage="payload_type_trusted", + outcome="fail", + detail="trust context allowed_payload_types is empty", + ) + if artifact.payload_type in trust_context.allowed_payload_types: + return StageResult(stage="payload_type_trusted", outcome="pass") + return StageResult( + stage="payload_type_trusted", + outcome="fail", + detail=( + f"payload_type={artifact.payload_type!r} not in trust context " + f"allowed set of size {len(trust_context.allowed_payload_types)}" + ), + ) + + +def check_content_hash( + artifact: PublishedArtifact, canonicalization_adapter: CanonicalizationPort +) -> StageResult: + """Recompute the content hash via the matching canonicalization adapter. + + Returns `pass` when the recomputed hash equals the claimed one + (encoded as hex against `artifact.content_hash.hex()`). Returns + `fail` on mismatch; `skip` if the canonicalization payload is + not derivable from the port-tier shape (placeholder: the v1 + orchestrator does not yet rehydrate payload from canonical_bytes; + that work lands when the per-BC pull slice ships). + """ + if not artifact.canonical_bytes: + return StageResult( + stage="content_hash", + outcome="skip", + detail="artifact carries no canonical_bytes; content-hash recompute deferred", + ) + try: + recomputed_hex = _hash_bytes(artifact.canonical_bytes) + except CanonicalizationFailedError as exc: + return StageResult( + stage="content_hash", + outcome="skip", + detail=f"canonicalization adapter raised: {exc}", + ) + if canonicalization_adapter.adapter_version != artifact.canonicalization_version: + return StageResult( + stage="content_hash", + outcome="skip", + detail=( + f"canonicalization adapter version " + f"{canonicalization_adapter.adapter_version!r} does not match " + f"artifact canonicalization_version {artifact.canonicalization_version!r}; " + f"orchestrator must resolve a matching adapter before calling" + ), + ) + claimed_hex = artifact.content_hash.hex() + if recomputed_hex == claimed_hex: + return StageResult(stage="content_hash", outcome="pass") + return StageResult( + stage="content_hash", + outcome="fail", + detail=f"claimed={claimed_hex} recomputed={recomputed_hex}", + ) + + +def check_required_receipts_present( + envelope: SignatureEnvelope, trust_context: FederationTrustContext +) -> StageResult: + """Pass when every required receipt kind is present (or no requirements). + + Per sec-1 / AH#19: when `trust_context.required_receipt_kinds` + is non-empty AND `envelope.receipts` does not contain at least + one of each required kind, the verifier rejects. Empty + `required_receipt_kinds` (the default) is the legitimate + backward-compat path; receipts are validated when present but + not required. + """ + if not trust_context.required_receipt_kinds: + return StageResult(stage="transparency_log_inclusion", outcome="skip") + observed = {r.kind for r in envelope.receipts} + missing = trust_context.required_receipt_kinds - observed + if missing: + return StageResult( + stage="transparency_log_inclusion", + outcome="fail", + detail=( + f"required receipt kinds {sorted(trust_context.required_receipt_kinds)!r} " + f"missing {sorted(missing)!r}; observed {sorted(observed)!r}" + ), + ) + return StageResult(stage="transparency_log_inclusion", outcome="pass") + + +def check_abi_tier( + artifact: PublishedArtifact, trust_context: FederationTrustContext +) -> StageResult: + """Pass when artifact tier is at or above the trust-context floor. + + Order: Testing(1) < Stable(2) < Obsolete(3) < Removed(4). + `Removed` always fails regardless of floor (publication has + been formally withdrawn from the ABI ladder; adopting it would + contradict the lifecycle). + """ + if artifact.abi_tier == "Removed": + return StageResult( + stage="abi_tier", + outcome="fail", + detail="artifact abi_tier=Removed; publication has been withdrawn", + ) + artifact_rank = _ABI_TIER_ORDER.get(artifact.abi_tier) + floor_rank = _ABI_TIER_ORDER.get(trust_context.abi_tier_floor) + if artifact_rank is None or floor_rank is None: + return StageResult( + stage="abi_tier", + outcome="skip", + detail=( + f"unrecognized abi_tier: artifact={artifact.abi_tier!r} " + f"floor={trust_context.abi_tier_floor!r}" + ), + ) + if artifact_rank >= floor_rank: + return StageResult(stage="abi_tier", outcome="pass") + return StageResult( + stage="abi_tier", + outcome="fail", + detail=( + f"artifact abi_tier={artifact.abi_tier!r} below trust " + f"context floor={trust_context.abi_tier_floor!r}" + ), + ) + + +def check_expires_at(artifact: PublishedArtifact, *, now: datetime) -> StageResult: + """Pass when artifact is not expired (or has no expiry).""" + if artifact.expires_at is None: + return StageResult(stage="expires_at", outcome="pass") + if now < artifact.expires_at: + return StageResult(stage="expires_at", outcome="pass") + return StageResult( + stage="expires_at", + outcome="fail", + detail=(f"artifact expired at {artifact.expires_at.isoformat()}; now={now.isoformat()}"), + ) + + +def check_dco_chain(artifact: PublishedArtifact) -> StageResult: + """Pass when at least one SignedOffBy entry is present in the DCO chain. + + Per project_federation_port_design.md: the Linux-kernel + `coding-assistants.rst` invariant transplant requires at least + one human Signed-off-by. The decider enforces the deeper + invariant (actor_id resolves to Actor.kind == human) at + publish-time; this gate checks the chain shape at verify-time. + """ + if not artifact.dco_chain: + return StageResult( + stage="dco_chain", + outcome="fail", + detail="DCO chain is empty; missing required SignedOffBy entry", + ) + has_signed_off_by = any(isinstance(entry, SignedOffBy) for entry in artifact.dco_chain) + if has_signed_off_by: + return StageResult(stage="dco_chain", outcome="pass") + return StageResult( + stage="dco_chain", + outcome="fail", + detail=( + f"DCO chain has {len(artifact.dco_chain)} entries but no " + f"SignedOffBy; AI-only chains are forbidden" + ), + ) + + +def deferred_stage(stage_name: str, reason: str) -> StageResult: + """Build a skip StageResult for a stage deferred to a future iteration. + + Centralized so a future iteration that lands the deferred + infrastructure (head pointer cache, replay cache, payload-type + plugin registry) can promote each from skip to pass/fail via + a single import-site update. + """ + return StageResult(stage=stage_name, outcome="skip", detail=reason) # type: ignore[arg-type] + + +def is_envelope_receipt_kind(receipt: Receipt, kind: str) -> bool: + """Helper used by the orchestrator when filtering receipt arms.""" + return receipt.kind == kind + + +def dco_chain_has_human_actor(entries: tuple[DcoEntry, ...]) -> bool: + """Helper exposed for the per-BC publish-slice deciders to reuse.""" + return any(isinstance(e, SignedOffBy) for e in entries) + + +def is_terminal_publication_status(status: PublicationStatus) -> bool: + """Helper for adoption-window gating once head_pointer_fresh lands.""" + return status in ("Yanked", "Withdrawn", "Expired", "AbiTierObsoleteOrRemoved") + + +def _hash_bytes(canonical_bytes: bytes) -> str: + """Sha256 hex of canonical_bytes; isolated so the verifier reuses one path.""" + import hashlib + + return hashlib.sha256(canonical_bytes).hexdigest() + + +__all__ = [ + "check_abi_tier", + "check_content_hash", + "check_dco_chain", + "check_expires_at", + "check_payload_type_trusted", + "check_required_receipts_present", + "dco_chain_has_human_actor", + "deferred_stage", + "is_envelope_receipt_kind", + "is_terminal_publication_status", +] diff --git a/apps/api/src/cora/infrastructure/published_artifact/orchestrator.py b/apps/api/src/cora/infrastructure/published_artifact/orchestrator.py new file mode 100644 index 000000000..1923391c4 --- /dev/null +++ b/apps/api/src/cora/infrastructure/published_artifact/orchestrator.py @@ -0,0 +1,199 @@ +"""Verify-then-apply orchestrator for federated PublishedArtifact. + +The orchestrator is the Generic Subdomain shared-kernel coordinator +that runs the 13-stage verification sequence per +project_federation_port_design.md. It composes: + + - BEFORE-gates: payload_type_trusted, content_hash, required-receipt + presence (run synchronously; short-circuit Rejected on hard fails) + - Arm-specific stages delegated to `SignaturePort.verify(...)`: + signature, key_resolution, issuer_match, + transparency_log_inclusion (per-arm), key_validity_at_sign_time + - AFTER-gates: payload_type_known (deferred), abi_tier, expires_at, + head_pointer_fresh (deferred), replay_cache (deferred), dco_chain + +The orchestrator does NOT consult the EventStore, does NOT apply +the artifact to any home aggregate, and does NOT acquire any +locks. Application is the caller's responsibility: the per-BC +`pull_` slice handler reads the orchestrator's return, +short-circuits on Rejected / Unverifiable, and append_streams +the matching `Imported` event on Verified. + +Per AH#17 (TOCTOU defense): the `PullPort` adapter is responsible +for raising `FederationPublicationContentDriftError` BEFORE the +artifact reaches the orchestrator. The orchestrator's +`content_hash` stage is the defense-in-depth recompute when the +pull adapter did not. + +Per arch-2 (SignaturePort delegates to SigningPort): this +orchestrator delegates the arm-specific stages to +`SignaturePort.verify`. It NEVER invokes a crypto library +directly; the matching adapter under +`cora/federation/adapters/_signature_port.py` calls +SigningPort.sign/verify. +""" + +from datetime import datetime + +from cora.infrastructure.adapters.canonicalization_registry import ( + CanonicalizationRegistry, +) +from cora.infrastructure.ports.canonicalization import ( + UnsupportedCanonicalizationVersionError, +) +from cora.infrastructure.ports.federation.signature_port import SignaturePort +from cora.infrastructure.ports.federation.value_types import ( + FederationTrustContext, + PublishedArtifact, + Rejected, + RejectionReason, + StageResult, + UnverifiabilityReason, + Unverifiable, + VerificationOutcome, + Verified, +) +from cora.infrastructure.published_artifact._stages import ( + check_abi_tier, + check_content_hash, + check_dco_chain, + check_expires_at, + check_payload_type_trusted, + check_required_receipts_present, + deferred_stage, +) + + +async def verify_then_apply( + artifact: PublishedArtifact, + *, + trust_context: FederationTrustContext, + signature_port: SignaturePort, + canonicalization_registry: CanonicalizationRegistry, + now: datetime, +) -> VerificationOutcome: + """Run the 13-stage verification sequence; return a VerificationOutcome. + + Caller composes the routing (which SignaturePort adapter to use + for the artifact's envelope kind) and resolves the trust context + from the matching inbound-direction Permit. The orchestrator + runs the stages in order, short-circuits on hard fails, and + returns the synthesized outcome. The caller's pull-slice handler + decides whether to apply the artifact (on Verified) or surface a + diagnostic (on Rejected / Unverifiable). + """ + stage_results: list[StageResult] = [] + + payload_type_result = check_payload_type_trusted(artifact, trust_context) + stage_results.append(payload_type_result) + if payload_type_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason( + failed_stage="payload_type_trusted", + reason=payload_type_result.detail, + ), + ) + + try: + canonicalization_adapter = canonicalization_registry.resolve( + artifact.canonicalization_version + ) + except UnsupportedCanonicalizationVersionError as exc: + stage_results.append( + StageResult( + stage="content_hash", + outcome="skip", + detail=( + f"canonicalization_version " + f"{artifact.canonicalization_version!r} not registered: {exc}" + ), + ) + ) + return Unverifiable( + stage_results=tuple(stage_results), + unverifiability=UnverifiabilityReason( + failed_stage="content_hash", + reason="canonicalization adapter not registered", + ), + ) + + content_hash_result = check_content_hash(artifact, canonicalization_adapter) + stage_results.append(content_hash_result) + if content_hash_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason( + failed_stage="content_hash", + reason=content_hash_result.detail, + ), + ) + + receipt_result = check_required_receipts_present(artifact.signature_envelope, trust_context) + stage_results.append(receipt_result) + if receipt_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason( + failed_stage="transparency_log_inclusion", + reason=receipt_result.detail, + ), + ) + + arm_outcome = await signature_port.verify(artifact, trust_context) + stage_results.extend(arm_outcome.stage_results) + if isinstance(arm_outcome, Rejected): + return Rejected(stage_results=tuple(stage_results), rejection=arm_outcome.rejection) + if isinstance(arm_outcome, Unverifiable): + return Unverifiable( + stage_results=tuple(stage_results), unverifiability=arm_outcome.unverifiability + ) + + stage_results.append( + deferred_stage( + "payload_type_known", + "payload-type plugin registry deferred; treat as skip until per-BC slices land", + ) + ) + + abi_tier_result = check_abi_tier(artifact, trust_context) + stage_results.append(abi_tier_result) + if abi_tier_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason(failed_stage="abi_tier", reason=abi_tier_result.detail), + ) + + expires_at_result = check_expires_at(artifact, now=now) + stage_results.append(expires_at_result) + if expires_at_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason(failed_stage="expires_at", reason=expires_at_result.detail), + ) + + stage_results.append( + deferred_stage( + "head_pointer_fresh", + "Seal head-pointer freshness check deferred to a future iteration", + ) + ) + stage_results.append( + deferred_stage( + "replay_cache", + "replay cache deferred to a future iteration", + ) + ) + + dco_chain_result = check_dco_chain(artifact) + stage_results.append(dco_chain_result) + if dco_chain_result.outcome == "fail": + return Rejected( + stage_results=tuple(stage_results), + rejection=RejectionReason(failed_stage="dco_chain", reason=dco_chain_result.detail), + ) + + return Verified(stage_results=tuple(stage_results)) + + +__all__ = ["verify_then_apply"] diff --git a/apps/api/tach.toml b/apps/api/tach.toml index 1925a4b64..bdadd9372 100644 --- a/apps/api/tach.toml +++ b/apps/api/tach.toml @@ -223,7 +223,17 @@ depends_on = ["cora.infrastructure", "cora.caution.aggregates"] # lives additively on the consumer BCs, not here. [[modules]] path = "cora.calibration" -depends_on = ["cora.infrastructure", "cora.calibration.aggregates"] +depends_on = [ + "cora.infrastructure", + "cora.calibration.aggregates", + # publish_revision slice emits a cross-BC event pair + # (CalibrationRevisionPublished on Calibration; PublicationReceiptRecorded + # on the matching outbound Permit) atomically via + # EventStore.append_streams per project_federation_port_design.md + # cross-BC discipline. The decider imports the federation event class + # to construct the typed payload. + "cora.federation.aggregates", +] # Federation BC owns the Permit (unified, OutboundTerms | InboundTerms # tagged union) / Credential / Seal aggregates per diff --git a/apps/api/tests/architecture/test_canonicalization_v1_immutability.py b/apps/api/tests/architecture/test_canonicalization_v1_immutability.py new file mode 100644 index 000000000..71d18c53e --- /dev/null +++ b/apps/api/tests/architecture/test_canonicalization_v1_immutability.py @@ -0,0 +1,48 @@ +"""Architecture fitness: byte-exactness of the v1 canonicalization recipe. + +The v1 adapter is the verification path for every shipped Method, +Plan, CalibrationRevision, and signed DecisionRegistered for the +lifetime of the data per the lock memo. Changing any byte the v1 +adapter emits would silently invalidate every pinned content_hash +across CORA history. + +This fitness test pins a golden vector: for a known input payload +and a known payload_type, the v1 adapter MUST emit the same +SHA-256 digest forever. The constant below was computed from the +shipped `compute_content_hash` helper at the time this fitness was +introduced; any change that alters the constant is a regression on +the immutability invariant and MUST be reverted, not adjusted. +""" + +import hashlib + +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) + +_GOLDEN_PAYLOAD_TYPE = "application/vnd.cora.test-event+json" +_GOLDEN_PAYLOAD = {"name": "test", "value": 42} +_GOLDEN_SHA256_HEX = "1a4badf0f45fff0374a2332cfb29eb6492aecf98fc1b69418faac6a1b700ad8b" + + +def test_default_canonicalization_adapter_v1_golden_vector_sha256_is_immutable() -> None: + adapter = DefaultCanonicalizationAdapter() + out = adapter.canonicalize(_GOLDEN_PAYLOAD_TYPE, _GOLDEN_PAYLOAD) + actual_hex = hashlib.sha256(out.bytes_).hexdigest() + assert actual_hex == _GOLDEN_SHA256_HEX, ( + f"v1 canonicalization byte-exactness regression: " + f"expected {_GOLDEN_SHA256_HEX!r} got {actual_hex!r}. " + f"Any change that alters this constant invalidates every " + f"pinned content_hash across CORA history and MUST be " + f"reverted, not adjusted." + ) + + +def test_default_canonicalization_adapter_v1_verify_content_hash_matches_golden() -> None: + adapter = DefaultCanonicalizationAdapter() + assert adapter.verify_content_hash(_GOLDEN_PAYLOAD_TYPE, _GOLDEN_PAYLOAD, _GOLDEN_SHA256_HEX) + + +def test_default_canonicalization_adapter_v1_adapter_version_is_locked_to_cora_v1() -> None: + adapter = DefaultCanonicalizationAdapter() + assert adapter.adapter_version == "cora/v1" diff --git a/apps/api/tests/architecture/test_events_signature_version_matched_pair.py b/apps/api/tests/architecture/test_events_signature_version_matched_pair.py new file mode 100644 index 000000000..8df4c7e02 --- /dev/null +++ b/apps/api/tests/architecture/test_events_signature_version_matched_pair.py @@ -0,0 +1,109 @@ +"""Architecture fitness: signature_version is part of the matched-pair invariant. + +The signed-event row carries three coupled fields: `signature` (raw +bytes), `signature_kid` (key id), and `signature_version` (signing- +recipe identifier, dispatched to the matching SigningPort adapter +via the SigningRegistry per project_canonicalization_port_design.md). + +The matched-pair invariant: all three are NULL together (unsigned +row) or all three are non-NULL together (signed row). Any partial +combination is a write-side bug. + +This fitness pins the invariant at TWO layers: + + - Database: the events table CHECK constraints enforce + `(signature IS NULL) = (signature_kid IS NULL) = + (signature_version IS NULL)` via two CHECK constraints + (signature/signature_kid pair was set in + 20260523214753; signature/signature_version pair in + 20260601000000). A migration that drops or weakens either + CHECK fails this test. + - Application: the NewEvent + StoredEvent value types both carry + the three fields with `bytes | None`, `str | None`, `str | None` + types respectively, so a drift on the dataclass shape (removing + a field, changing a type) fails before any SQL ever runs. + +Per project_immutability_guarantee.md the events table is INSERT-only +and immortal. The signature columns are nullable forever, and the +matched-pair CHECKs are the structural guarantee that no +partially-signed row can ever be persisted. +""" + +import re +from dataclasses import fields + +import pytest + +from cora.infrastructure.ports.event_store import NewEvent, StoredEvent +from tests.architecture.conftest import tracked_migration_files + +_SIGNATURE_FIELD_NAMES = {"signature", "signature_kid", "signature_version"} + + +def test_new_event_carries_all_three_signature_fields_with_optional_types() -> None: + field_map = {f.name: f for f in fields(NewEvent)} + for name in _SIGNATURE_FIELD_NAMES: + assert name in field_map, ( + f"NewEvent missing signature field {name!r}; the matched-pair " + f"invariant requires all three (signature, signature_kid, " + f"signature_version)." + ) + + +def test_stored_event_carries_all_three_signature_fields_with_optional_types() -> None: + field_map = {f.name: f for f in fields(StoredEvent)} + for name in _SIGNATURE_FIELD_NAMES: + assert name in field_map, ( + f"StoredEvent missing signature field {name!r}; the matched-pair " + f"invariant requires all three (signature, signature_kid, " + f"signature_version)." + ) + + +def test_events_signature_kid_consistency_check_constraint_present_in_migrations() -> None: + haystack = _all_migration_text() + assert "events_signature_kid_consistency" in haystack, ( + "events_signature_kid_consistency CHECK constraint missing from " + "migrations. The (signature, signature_kid) matched-pair invariant " + "MUST stay enforced at the database layer; see " + "20260523214753_add_events_signature_columns.sql for the original." + ) + + +def test_events_signature_version_consistency_check_constraint_present_in_migrations() -> None: + haystack = _all_migration_text() + assert "events_signature_version_consistency" in haystack, ( + "events_signature_version_consistency CHECK constraint missing " + "from migrations. The (signature, signature_version) matched-pair " + "invariant MUST stay enforced at the database layer; see " + "20260601000000_add_events_signature_version.sql for the lock." + ) + + +@pytest.mark.parametrize( + "constraint_name", + [ + "events_signature_kid_consistency", + "events_signature_version_consistency", + ], +) +def test_signature_matched_pair_check_constraint_uses_null_equals_null_shape( + constraint_name: str, +) -> None: + haystack = _all_migration_text() + pattern = re.compile( + rf"CONSTRAINT\s+{re.escape(constraint_name)}\s+CHECK\s*\(\s*" + r"\(signature\s+IS\s+NULL\)\s*=\s*\(signature_(?:kid|version)\s+IS\s+NULL\)", + re.IGNORECASE, + ) + assert pattern.search(haystack), ( + f"CONSTRAINT {constraint_name} does not use the locked " + f"`(signature IS NULL) = (signature_X IS NULL)` matched-pair " + f"shape. Any change away from this form weakens the invariant: a " + f"signature without its companion (or vice versa) MUST be rejected " + f"at INSERT time, not papered over in application code." + ) + + +def _all_migration_text() -> str: + return "\n".join(f.read_text() for f in tracked_migration_files()) diff --git a/apps/api/tests/architecture/test_federation_signature_envelope_arms.py b/apps/api/tests/architecture/test_federation_signature_envelope_arms.py new file mode 100644 index 000000000..aca5a868d --- /dev/null +++ b/apps/api/tests/architecture/test_federation_signature_envelope_arms.py @@ -0,0 +1,87 @@ +"""Architecture fitness: SignatureEnvelope structural completeness. + +Per project_federation_port_design.md: "The three placeholder arms +are named in this lock so the architecture-fitness tests can +assert structural completeness; the arm-specific field shapes +are owned by Memo 2." + +This fitness pins the three v1 arm classes by name: +DsseStaticJwksEnvelope, DsseSigstoreKeylessEnvelope, +CoseSign1ScittEnvelope. Removing any arm without updating this +test signals an unannounced port-shape change and MUST fail CI. + +The fitness also asserts that every arm: + - carries the locked `signing_version: str` field + - carries the locked `payload_bytes: bytes` field + - carries the optional `receipts: tuple[Receipt, ...]` slot + - declares a `kind: Literal[...]` discriminator with a value + matching the locked set + +The arm-specific payload fields beyond these basics are owned by +the adapter memo, not the port, so this fitness does NOT pin them. +""" + +from dataclasses import fields + +from cora.infrastructure.ports.federation.value_types import ( + CoseSign1ScittEnvelope, + DsseSigstoreKeylessEnvelope, + DsseStaticJwksEnvelope, + SignatureEnvelope, +) + +_LOCKED_ARM_CLASSES = ( + DsseStaticJwksEnvelope, + DsseSigstoreKeylessEnvelope, + CoseSign1ScittEnvelope, +) + +_LOCKED_ARM_KINDS = { + DsseStaticJwksEnvelope: "dsse_static_jwks", + DsseSigstoreKeylessEnvelope: "dsse_sigstore_keyless", + CoseSign1ScittEnvelope: "cose_sign1_scitt", +} + + +def test_signature_envelope_carries_exactly_three_locked_v1_arms() -> None: + union_members = getattr(SignatureEnvelope, "__args__", None) + assert union_members is not None, ( + "SignatureEnvelope must be a typing.Union of the locked arms; " + "it appears to be a single class which would lose the discriminated-" + "union shape pinned in the memo." + ) + assert set(union_members) == set(_LOCKED_ARM_CLASSES), ( + f"SignatureEnvelope arm set drifted from the lock memo. " + f"Expected: {[c.__name__ for c in _LOCKED_ARM_CLASSES]}; " + f"Got: {[c.__name__ for c in union_members]}." + ) + + +def test_every_signature_envelope_arm_carries_locked_field_set() -> None: + required = {"signing_version", "payload_bytes", "kind", "receipts"} + for arm_class in _LOCKED_ARM_CLASSES: + field_names = {f.name for f in fields(arm_class)} + missing = required - field_names + assert not missing, ( + f"{arm_class.__name__} missing locked fields {sorted(missing)}; " + f"got fields {sorted(field_names)}." + ) + + +def test_every_signature_envelope_arm_kind_default_matches_locked_discriminator() -> None: + for arm_class, expected_kind in _LOCKED_ARM_KINDS.items(): + instance = arm_class(signing_version="cora/v1", payload_bytes=b"") + assert instance.kind == expected_kind, ( + f"{arm_class.__name__}.kind defaulted to {instance.kind!r} " + f"but the locked discriminator is {expected_kind!r}." + ) + + +def test_every_signature_envelope_arm_receipts_default_is_empty_tuple() -> None: + for arm_class in _LOCKED_ARM_CLASSES: + instance = arm_class(signing_version="cora/v1", payload_bytes=b"") + assert instance.receipts == (), ( + f"{arm_class.__name__}.receipts default drifted from empty tuple; " + f"changing the default lets the receipt-suppression downgrade " + f"per sec-1 land silently." + ) 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 41f2e87d8..59ed28e8d 100644 --- a/apps/api/tests/architecture/test_slice_verb_names_subject.py +++ b/apps/api/tests/architecture/test_slice_verb_names_subject.py @@ -81,6 +81,7 @@ "permission", # Trust: list_permissions "event", # Agent: dismiss_event_in_reaction "reaction", # Agent: dismiss_event_in_reaction (Reaction = subscriber class) + "revision", # Calibration: publish_revision (cross-BC federation slice) } ) diff --git a/apps/api/tests/contract/test_publish_revision_endpoint.py b/apps/api/tests/contract/test_publish_revision_endpoint.py new file mode 100644 index 000000000..5d6ec01bc --- /dev/null +++ b/apps/api/tests/contract/test_publish_revision_endpoint.py @@ -0,0 +1,172 @@ +"""Contract tests for `POST /calibrations/{calibration_id}/revisions/{revision_id}/publish`. + +The publish_revision slice's HTTP wire surface: 201 + receipt_id on +success, 403 / 404 / 409 mapping for the publish-time domain +rejections. Exercises the create_app() FastAPI surface end-to-end +against the in-memory adapters wired by make_inmemory_kernel. + +The peer Permit must be registered against the test app's permit +lookup BEFORE the publish call; the test inspects +`app.state.calibration` to reach the lookup and seed the outbound +Permit. +""" + +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup + + +def _define_body(**overrides: object) -> dict[str, Any]: + base: dict[str, Any] = { + "target_id": str(uuid4()), + "quantity": "rotation_center", + "operating_point": {"energy": 25.0, "optics_config": "5x"}, + } + base.update(overrides) + return base + + +def _revision_body(**overrides: object) -> dict[str, Any]: + base: dict[str, Any] = { + "value": {"center": 1024.5, "uncertainty": 0.3}, + "status": "Provisional", + "source": {"kind": "Measured", "procedure_id": str(uuid4())}, + } + base.update(overrides) + return base + + +def _seed_calibration_and_revision(client: TestClient) -> tuple[str, str]: + define_response = client.post("/calibrations", json=_define_body()) + assert define_response.status_code == 201, define_response.text + cid = str(define_response.json()["calibration_id"]) + revision_response = client.post(f"/calibrations/{cid}/revisions", json=_revision_body()) + assert revision_response.status_code == 201, revision_response.text + revision_id = str(revision_response.json()["revision_id"]) + return cid, revision_id + + +def _seed_outbound_permit(app: FastAPI, peer_facility_id: str = "aps-2bm") -> UUID: + permit_lookup = app.state.deps.permit_lookup + assert isinstance(permit_lookup, InMemoryPermitLookup) + permit_id = uuid4() + permit_lookup.register_outbound( + peer_facility_id=peer_facility_id, + artifact_kind="CalibrationRevision", + permit_id=permit_id, + ) + return permit_id + + +@pytest.mark.contract +def test_post_publish_returns_201_with_receipt_id_on_happy_path() -> None: + app = create_app() + with TestClient(app) as client: + cid, revision_id = _seed_calibration_and_revision(client) + _seed_outbound_permit(app) + response = client.post( + f"/calibrations/{cid}/revisions/{revision_id}/publish", + json={"peer_facility_id": "aps-2bm"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert "receipt_id" in body + UUID(body["receipt_id"]) + + +@pytest.mark.contract +def test_post_publish_returns_404_when_calibration_missing() -> None: + app = create_app() + with TestClient(app) as client: + _seed_outbound_permit(app) + unknown_calibration = uuid4() + unknown_revision = uuid4() + response = client.post( + f"/calibrations/{unknown_calibration}/revisions/{unknown_revision}/publish", + json={"peer_facility_id": "aps-2bm"}, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.contract +def test_post_publish_returns_404_when_revision_missing_on_existing_calibration() -> None: + app = create_app() + with TestClient(app) as client: + cid, _ = _seed_calibration_and_revision(client) + _seed_outbound_permit(app) + unknown_revision = uuid4() + response = client.post( + f"/calibrations/{cid}/revisions/{unknown_revision}/publish", + json={"peer_facility_id": "aps-2bm"}, + ) + assert response.status_code == 404, response.text + + +@pytest.mark.contract +def test_post_publish_returns_409_when_no_active_outbound_permit_for_peer() -> None: + app = create_app() + with TestClient(app) as client: + cid, revision_id = _seed_calibration_and_revision(client) + response = client.post( + f"/calibrations/{cid}/revisions/{revision_id}/publish", + json={"peer_facility_id": "unknown-peer"}, + ) + assert response.status_code == 409, response.text + + +@pytest.mark.contract +def test_post_publish_rejects_missing_peer_facility_id_with_422() -> None: + app = create_app() + with TestClient(app) as client: + cid, revision_id = _seed_calibration_and_revision(client) + response = client.post( + f"/calibrations/{cid}/revisions/{revision_id}/publish", + json={}, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.contract +def test_post_publish_returns_422_for_malformed_uuid_path_param() -> None: + app = create_app() + with TestClient(app) as client: + response = client.post( + "/calibrations/not-a-uuid/revisions/also-not-a-uuid/publish", + json={"peer_facility_id": "aps-2bm"}, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.contract +def test_post_publish_accepts_idempotency_key_header() -> None: + app = create_app() + with TestClient(app) as client: + cid, revision_id = _seed_calibration_and_revision(client) + _seed_outbound_permit(app) + response = client.post( + f"/calibrations/{cid}/revisions/{revision_id}/publish", + json={"peer_facility_id": "aps-2bm"}, + headers={"Idempotency-Key": "publish-key"}, + ) + assert response.status_code == 201, response.text + + +@pytest.mark.contract +def test_post_publish_response_body_carries_receipt_id_field() -> None: + app = create_app() + with TestClient(app) as client: + cid, revision_id = _seed_calibration_and_revision(client) + _seed_outbound_permit(app) + response = client.post( + f"/calibrations/{cid}/revisions/{revision_id}/publish", + json={"peer_facility_id": "aps-2bm"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert set(body.keys()) == {"receipt_id"} diff --git a/apps/api/tests/contract/test_publish_revision_mcp_tool.py b/apps/api/tests/contract/test_publish_revision_mcp_tool.py new file mode 100644 index 000000000..3ec68fa62 --- /dev/null +++ b/apps/api/tests/contract/test_publish_revision_mcp_tool.py @@ -0,0 +1,138 @@ +"""Contract tests for the `publish_revision` MCP tool.""" + +from typing import Any +from uuid import UUID, uuid4 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cora.api.main import create_app +from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup +from tests.contract._mcp_helpers import open_session, parse_sse_data + + +def _define_args() -> dict[str, Any]: + return { + "target_id": str(uuid4()), + "quantity": "rotation_center", + "operating_point": {"energy": 25.0, "optics_config": "5x"}, + } + + +def _revision_args(*, calibration_id: str) -> dict[str, Any]: + return { + "calibration_id": calibration_id, + "value": {"center": 1024.5}, + "status": "Provisional", + "source": {"kind": "Measured", "procedure_id": str(uuid4())}, + } + + +def _seed_calibration_and_revision( + client: TestClient, session_headers: dict[str, str] +) -> tuple[str, str]: + define_response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "define_calibration", "arguments": _define_args()}, + }, + headers=session_headers, + ) + assert define_response.status_code == 200, define_response.text + define_body = parse_sse_data(define_response.text) + cid = str(define_body["result"]["structuredContent"]["calibration_id"]) + + revision_response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "append_revision", "arguments": _revision_args(calibration_id=cid)}, + }, + headers=session_headers, + ) + assert revision_response.status_code == 200, revision_response.text + revision_body = parse_sse_data(revision_response.text) + revision_id = str(revision_body["result"]["structuredContent"]["revision_id"]) + return cid, revision_id + + +def _seed_outbound_permit(app: FastAPI, peer_facility_id: str = "aps-2bm") -> UUID: + permit_lookup = app.state.deps.permit_lookup + assert isinstance(permit_lookup, InMemoryPermitLookup) + permit_id = uuid4() + permit_lookup.register_outbound( + peer_facility_id=peer_facility_id, + artifact_kind="CalibrationRevision", + permit_id=permit_id, + ) + return permit_id + + +@pytest.mark.contract +def test_mcp_lists_publish_revision_tool() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "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 "publish_revision" in tool_names + + +@pytest.mark.contract +def test_mcp_publish_revision_tool_returns_receipt_id_on_happy_path() -> None: + app = create_app() + with TestClient(app) as client: + session_headers = open_session(client) + cid, revision_id = _seed_calibration_and_revision(client, session_headers) + _seed_outbound_permit(app) + response = client.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "publish_revision", + "arguments": { + "calibration_id": cid, + "revision_id": revision_id, + "peer_facility_id": "aps-2bm", + }, + }, + }, + headers=session_headers, + ) + assert response.status_code == 200, response.text + body = parse_sse_data(response.text) + receipt_id = body["result"]["structuredContent"]["receipt_id"] + UUID(receipt_id) + + +@pytest.mark.contract +def test_mcp_publish_revision_tool_carries_publish_revision_in_output_schema() -> None: + with TestClient(create_app()) as client: + session_headers = open_session(client) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + headers=session_headers, + ) + body = parse_sse_data(response.text) + tool: dict[str, Any] = next( + t for t in body["result"]["tools"] if t["name"] == "publish_revision" + ) + output_schema: dict[str, Any] = tool.get("outputSchema") or {} + assert "properties" in output_schema + properties: dict[str, Any] = output_schema["properties"] + assert "receipt_id" in properties diff --git a/apps/api/tests/unit/_helpers.py b/apps/api/tests/unit/_helpers.py index 2959b25ab..a8cebb2ab 100644 --- a/apps/api/tests/unit/_helpers.py +++ b/apps/api/tests/unit/_helpers.py @@ -149,6 +149,15 @@ def build_deps( """ if authz is None: authz = DenyAllAuthorize() if deny else AllowAllAuthorize() + # Wire the publish-side InMemory federation adapters by default so + # tests that exercise wire_calibration (which binds publish_revision) + # do not hit PublishPortNotWiredError at startup. Tests that want + # to assert the misconfiguration surface explicitly null them via + # dataclasses.replace. + from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup + from cora.federation.adapters.in_memory_publish_port import InMemoryPublishPort + from cora.federation.adapters.in_memory_signature_port import InMemorySignaturePort + return make_inmemory_kernel( settings=Settings(app_env="test"), # type: ignore[call-arg] clock=FakeClock(now or DEFAULT_NOW), @@ -158,6 +167,9 @@ def build_deps( llm=llm, profile_store=profile_store, credential_lookup=credential_lookup, + publish_port=InMemoryPublishPort(), + signature_port=InMemorySignaturePort(), + permit_lookup=InMemoryPermitLookup(), ) diff --git a/apps/api/tests/unit/agent/_helpers.py b/apps/api/tests/unit/agent/_helpers.py index 659b300c2..f89f499e6 100644 --- a/apps/api/tests/unit/agent/_helpers.py +++ b/apps/api/tests/unit/agent/_helpers.py @@ -246,8 +246,8 @@ async def sign( event_type: str, payload: Mapping[str, object], actor_id: UUID, - ) -> tuple[bytes, str]: + ) -> tuple[bytes, str, str]: self.received_actor_ids.append(actor_id) body = canonical_body_bytes(payload) pae = pae_bytes(event_type_to_payload_type(event_type), body) - return self._key.sign(pae), self._kid + return self._key.sign(pae), self._kid, "cora/v1" diff --git a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py index 9bf5ed39a..8c056437f 100644 --- a/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py +++ b/apps/api/tests/unit/agent/test_caution_drafter_subscriber.py @@ -885,7 +885,7 @@ async def sign( event_type: str, payload: Any, actor_id: UUID, - ) -> tuple[bytes, str]: + ) -> tuple[bytes, str, str]: _ = (event_type, payload, actor_id) raise self._exc diff --git a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py index a929e9dc0..db0f60762 100644 --- a/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py +++ b/apps/api/tests/unit/agent/test_run_debriefer_subscriber.py @@ -933,7 +933,7 @@ async def sign( event_type: str, payload: Any, actor_id: UUID, - ) -> tuple[bytes, str]: + ) -> tuple[bytes, str, str]: _ = (event_type, payload, actor_id) raise self._exc diff --git a/apps/api/tests/unit/calibration/test_publish_revision_decider.py b/apps/api/tests/unit/calibration/test_publish_revision_decider.py new file mode 100644 index 000000000..f10aa356e --- /dev/null +++ b/apps/api/tests/unit/calibration/test_publish_revision_decider.py @@ -0,0 +1,214 @@ +"""Unit tests for the publish_revision decider (Stage 3d2 canary).""" + +from datetime import UTC, datetime +from typing import Any +from uuid import UUID, uuid4 + +import pytest + +from cora.calibration.aggregates.calibration import ( + AssertedSource, + Calibration, + CalibrationCannotPublishRevisionError, + CalibrationNotFoundError, + CalibrationRevision, + CalibrationRevisionNotFoundError, + CalibrationStatus, + OutboundPermitNotActiveError, +) +from cora.calibration.features.publish_revision import ( + PublishCalibrationRevision, + PublishRevisionEvents, + decide, +) +from cora.federation.aggregates.permit.events import PublicationReceiptRecorded +from cora.infrastructure.ports.federation import ( + DsseStaticJwksEnvelope, + PermitLookupResult, +) + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PEER = "aps-2bm" +_PERMIT_ID = UUID("11111111-1111-1111-1111-111111111111") +_RECEIPT_ID = UUID("22222222-2222-2222-2222-222222222222") +_CALIBRATION_ID = UUID("33333333-3333-3333-3333-333333333333") +_REVISION_ID = UUID("44444444-4444-4444-4444-444444444444") +_PRINCIPAL_ID = UUID("55555555-5555-5555-5555-555555555555") + + +def _revision( + *, + revision_id: UUID = _REVISION_ID, + content_hash: str | None = "a" * 64, +) -> CalibrationRevision: + return CalibrationRevision( + revision_id=revision_id, + value={"value": 1.0}, + status=CalibrationStatus.VERIFIED, + source=AssertedSource(actor_id=uuid4()), + established_at=_NOW, + established_by_actor_id=_PRINCIPAL_ID, + decided_by_decision_id=None, + supersedes_revision_id=None, + content_hash=content_hash, + ) + + +def _calibration( + *, + revisions: tuple[CalibrationRevision, ...] | None = None, +) -> Calibration: + return Calibration( + id=_CALIBRATION_ID, + target_id=uuid4(), + quantity="rotation_center_pixels", + operating_point={}, + description=None, + revisions=(_revision(),) if revisions is None else revisions, + defined_by_actor_id=_PRINCIPAL_ID, + ) + + +def _command( + *, + calibration_id: UUID = _CALIBRATION_ID, + revision_id: UUID = _REVISION_ID, + peer_facility_id: str = _PEER, +) -> PublishCalibrationRevision: + return PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + + +def _permit_result(*, status: str = "Active") -> PermitLookupResult: + return PermitLookupResult( + permit_id=_PERMIT_ID, + peer_facility_id=_PEER, + direction="Outbound", + status=status, + abi_tier_floor="Stable", + current_version=1, + ) + + +def _envelope() -> DsseStaticJwksEnvelope: + return DsseStaticJwksEnvelope( + signing_version="cora/v1", + payload_bytes=b"\xde\xad\xbe\xef", + ) + + +def _call_decide(**overrides: Any) -> PublishRevisionEvents: + state: Calibration | None = overrides.pop("state", _calibration()) + command: PublishCalibrationRevision = overrides.pop("command", _command()) + kwargs: dict[str, Any] = { + "permit_result": _permit_result(), + "signature_envelope": _envelope(), + "signature_kid": "kid-A", + "receipt_id": _RECEIPT_ID, + "now": _NOW, + "published_by_actor_id": _PRINCIPAL_ID, + } + kwargs.update(overrides) + return decide(state, command, **kwargs) + + +def test_decide_happy_path_emits_calibration_and_permit_events() -> None: + events = _call_decide() + assert isinstance(events, PublishRevisionEvents) + + calibration_event = events.calibration_event + assert calibration_event.calibration_id == _CALIBRATION_ID + assert calibration_event.revision_id == _REVISION_ID + assert calibration_event.outbound_permit_id == _PERMIT_ID + assert calibration_event.receipt_id == _RECEIPT_ID + assert calibration_event.signature_envelope_kind == "dsse_static_jwks" + assert calibration_event.signing_version == "cora/v1" + assert calibration_event.signature_bytes_hex == "deadbeef" + assert calibration_event.signature_kid == "kid-A" + assert calibration_event.published_by_actor_id == _PRINCIPAL_ID + assert calibration_event.publication_status == "Live" + assert calibration_event.published_at == _NOW + assert calibration_event.occurred_at == _NOW + + permit_event = events.permit_event + assert isinstance(permit_event, PublicationReceiptRecorded) + assert permit_event.permit_id == _PERMIT_ID + assert permit_event.content_hash == "a" * 64 + assert permit_event.home_stream_type == "Calibration" + assert permit_event.home_stream_id == _CALIBRATION_ID + assert permit_event.home_artifact_id == _REVISION_ID + assert permit_event.receipt_id == _RECEIPT_ID + assert permit_event.recorded_at == _NOW + + +def test_decide_calibration_state_none_raises_calibration_not_found() -> None: + with pytest.raises(CalibrationNotFoundError) as exc_info: + _call_decide(state=None) + assert exc_info.value.calibration_id == _CALIBRATION_ID + + +def test_decide_revision_not_on_aggregate_raises_revision_not_found() -> None: + other_revision_id = UUID("99999999-9999-9999-9999-999999999999") + with pytest.raises(CalibrationRevisionNotFoundError) as exc_info: + _call_decide(command=_command(revision_id=other_revision_id)) + assert exc_info.value.calibration_id == _CALIBRATION_ID + assert exc_info.value.revision_id == other_revision_id + + +def test_decide_legacy_revision_without_content_hash_raises_missing_content_hash() -> None: + legacy_revision = _revision(content_hash=None) + legacy_calibration = _calibration(revisions=(legacy_revision,)) + with pytest.raises(CalibrationCannotPublishRevisionError) as exc_info: + _call_decide(state=legacy_calibration) + assert exc_info.value.calibration_id == _CALIBRATION_ID + assert exc_info.value.revision_id == _REVISION_ID + + +def test_decide_no_permit_lookup_result_raises_outbound_permit_not_active() -> None: + with pytest.raises(OutboundPermitNotActiveError) as exc_info: + _call_decide(permit_result=None) + assert exc_info.value.peer_facility_id == _PEER + assert exc_info.value.artifact_kind == "CalibrationRevision" + assert exc_info.value.status == "" + + +def test_decide_suspended_permit_raises_outbound_permit_not_active() -> None: + with pytest.raises(OutboundPermitNotActiveError) as exc_info: + _call_decide(permit_result=_permit_result(status="Suspended")) + assert exc_info.value.status == "Suspended" + + +def test_decide_revoked_permit_raises_outbound_permit_not_active() -> None: + with pytest.raises(OutboundPermitNotActiveError): + _call_decide(permit_result=_permit_result(status="Revoked")) + + +def test_decide_defined_permit_raises_outbound_permit_not_active() -> None: + with pytest.raises(OutboundPermitNotActiveError): + _call_decide(permit_result=_permit_result(status="Defined")) + + +def test_decide_picks_target_revision_when_aggregate_has_multiple() -> None: + target_revision_id = UUID("66666666-6666-6666-6666-666666666666") + other_revision_id = UUID("77777777-7777-7777-7777-777777777777") + revisions = ( + _revision(revision_id=other_revision_id, content_hash="b" * 64), + _revision(revision_id=target_revision_id, content_hash="c" * 64), + ) + calibration = _calibration(revisions=revisions) + events = _call_decide( + state=calibration, + command=_command(revision_id=target_revision_id), + ) + assert events.calibration_event.revision_id == target_revision_id + assert events.permit_event.content_hash == "c" * 64 + + +def test_decide_calibration_event_signature_bytes_hex_round_trips_via_hex() -> None: + envelope = DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"\x00\x01\x02\xff") + events = _call_decide(signature_envelope=envelope) + assert events.calibration_event.signature_bytes_hex == "000102ff" + assert bytes.fromhex(events.calibration_event.signature_bytes_hex) == envelope.payload_bytes diff --git a/apps/api/tests/unit/calibration/test_publish_revision_decider_properties.py b/apps/api/tests/unit/calibration/test_publish_revision_decider_properties.py new file mode 100644 index 000000000..37705dcb8 --- /dev/null +++ b/apps/api/tests/unit/calibration/test_publish_revision_decider_properties.py @@ -0,0 +1,534 @@ +"""Property tests for the publish_revision decider (Calibration BC). + +Cross-BC federation decider. Pure shape: + + decide(state, command, *, permit_result, signature_envelope, + signature_kid, receipt_id, now, published_by_actor_id) + -> PublishRevisionEvents + +Universal claims pinned here: + + - Genesis-as-error: state=None always raises CalibrationNotFoundError. + - Revision-presence guard: unknown revision_id always raises + CalibrationRevisionNotFoundError, taking precedence over the + permit check. + - Legacy-revision guard: revision.content_hash=None always raises + CalibrationCannotPublishRevisionError. + - Permit-active guard: any non-Active permit (or permit_result=None) + always raises OutboundPermitNotActiveError. + - Event-shape stability: when all four guards pass, the two-event + pair carries every injected field verbatim and shares receipt_id + across streams. + - Purity: identical inputs yield identical event pairs. + +The decider is acknowledged NOT idempotent; handler-side wraps with +an Idempotency-Key. Re-issue de-duplication is not asserted here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID, uuid4 + +import pytest +from hypothesis import assume, given +from hypothesis import strategies as st + +if TYPE_CHECKING: + from datetime import datetime + +from cora.calibration.aggregates.calibration import ( + AssertedSource, + Calibration, + CalibrationCannotPublishRevisionError, + CalibrationNotFoundError, + CalibrationRevision, + CalibrationRevisionNotFoundError, + CalibrationStatus, + OutboundPermitNotActiveError, +) +from cora.calibration.features.publish_revision import ( + PublishCalibrationRevision, + decide, +) +from cora.infrastructure.ports.federation import ( + DsseStaticJwksEnvelope, + PermitLookupResult, +) +from tests._strategies import aware_datetimes, printable_ascii_text + +_HEX_CHAR = st.sampled_from("0123456789abcdef") +_CONTENT_HASH = st.lists(_HEX_CHAR, min_size=64, max_size=64).map("".join) +_PEER_FACILITY_ID = printable_ascii_text(min_size=1, max_size=64) +_SIGNATURE_KID = printable_ascii_text(min_size=1, max_size=128) +_SIGNING_VERSION = st.sampled_from(["cora/v1"]) +_PAYLOAD_BYTES = st.binary(min_size=1, max_size=512) +_PERMIT_NON_ACTIVE_STATUS = st.sampled_from(["Defined", "Suspended", "Revoked"]) +_PERMIT_ANY_STATUS = st.sampled_from(["Defined", "Active", "Suspended", "Revoked"]) + + +def _revision( + *, + revision_id: UUID, + content_hash: str | None, + established_at: datetime, + established_by_actor_id: UUID, +) -> CalibrationRevision: + return CalibrationRevision( + revision_id=revision_id, + value={"value": 1.0}, + status=CalibrationStatus.VERIFIED, + source=AssertedSource(actor_id=uuid4()), + established_at=established_at, + established_by_actor_id=established_by_actor_id, + decided_by_decision_id=None, + supersedes_revision_id=None, + content_hash=content_hash, + ) + + +def _calibration( + *, + calibration_id: UUID, + revisions: tuple[CalibrationRevision, ...], + actor_id: UUID, +) -> Calibration: + return Calibration( + id=calibration_id, + target_id=uuid4(), + quantity="rotation_center_pixels", + operating_point={}, + description=None, + revisions=revisions, + defined_by_actor_id=actor_id, + ) + + +def _envelope(payload_bytes: bytes, signing_version: str) -> DsseStaticJwksEnvelope: + return DsseStaticJwksEnvelope( + signing_version=signing_version, + payload_bytes=payload_bytes, + ) + + +def _permit(*, permit_id: UUID, peer_facility_id: str, status: str) -> PermitLookupResult: + return PermitLookupResult( + permit_id=permit_id, + peer_facility_id=peer_facility_id, + direction="Outbound", + status=status, + abi_tier_floor="Stable", + current_version=1, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_with_no_calibration_always_raises_not_found( + calibration_id: UUID, + revision_id: UUID, + peer_facility_id: str, + receipt_id: UUID, + now: datetime, + actor_id: UUID, +) -> None: + """state=None always raises CalibrationNotFoundError; the genesis + guard is unconditional and runs before any permit or revision lookup.""" + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + with pytest.raises(CalibrationNotFoundError): + decide( + None, + command, + permit_result=_permit( + permit_id=uuid4(), peer_facility_id=peer_facility_id, status="Active" + ), + signature_envelope=_envelope(b"\xde\xad", "cora/v1"), + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + known_revision_id=st.uuids(), + queried_revision_id=st.uuids(), + permit_status=_PERMIT_ANY_STATUS, + content_hash=_CONTENT_HASH, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_with_unknown_revision_always_raises_revision_not_found( + calibration_id: UUID, + known_revision_id: UUID, + queried_revision_id: UUID, + permit_status: str, + content_hash: str, + peer_facility_id: str, + receipt_id: UUID, + now: datetime, + actor_id: UUID, +) -> None: + """Unknown revision_id raises CalibrationRevisionNotFoundError regardless + of permit status; pins revision-guard precedence over permit-guard.""" + assume(queried_revision_id != known_revision_id) + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=known_revision_id, + content_hash=content_hash, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=queried_revision_id, + peer_facility_id=peer_facility_id, + ) + with pytest.raises(CalibrationRevisionNotFoundError): + decide( + state, + command, + permit_result=_permit( + permit_id=uuid4(), peer_facility_id=peer_facility_id, status=permit_status + ), + signature_envelope=_envelope(b"\xde\xad", "cora/v1"), + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + permit_status=_PERMIT_ANY_STATUS, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_with_legacy_revision_always_raises_cannot_publish( + calibration_id: UUID, + revision_id: UUID, + permit_status: str, + peer_facility_id: str, + receipt_id: UUID, + now: datetime, + actor_id: UUID, +) -> None: + """A revision with content_hash=None (pre-rollout legacy) always raises + CalibrationCannotPublishRevisionError, independent of permit status.""" + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=revision_id, + content_hash=None, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + with pytest.raises(CalibrationCannotPublishRevisionError): + decide( + state, + command, + permit_result=_permit( + permit_id=uuid4(), peer_facility_id=peer_facility_id, status=permit_status + ), + signature_envelope=_envelope(b"\xde\xad", "cora/v1"), + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + content_hash=_CONTENT_HASH, + permit_status=_PERMIT_NON_ACTIVE_STATUS, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_with_inactive_permit_always_raises_permit_not_active( + calibration_id: UUID, + revision_id: UUID, + content_hash: str, + permit_status: str, + peer_facility_id: str, + receipt_id: UUID, + now: datetime, + actor_id: UUID, +) -> None: + """Any permit in Defined / Suspended / Revoked always raises + OutboundPermitNotActiveError; Active-only authorization.""" + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=revision_id, + content_hash=content_hash, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + with pytest.raises(OutboundPermitNotActiveError): + decide( + state, + command, + permit_result=_permit( + permit_id=uuid4(), peer_facility_id=peer_facility_id, status=permit_status + ), + signature_envelope=_envelope(b"\xde\xad", "cora/v1"), + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + content_hash=_CONTENT_HASH, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_with_missing_permit_always_raises_permit_not_active( + calibration_id: UUID, + revision_id: UUID, + content_hash: str, + peer_facility_id: str, + receipt_id: UUID, + now: datetime, + actor_id: UUID, +) -> None: + """permit_result=None (no matching permit found) always raises + OutboundPermitNotActiveError; pins the None branch as equivalent + to non-Active.""" + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=revision_id, + content_hash=content_hash, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + with pytest.raises(OutboundPermitNotActiveError): + decide( + state, + command, + permit_result=None, + signature_envelope=_envelope(b"\xde\xad", "cora/v1"), + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + permit_id=st.uuids(), + content_hash=_CONTENT_HASH, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + signing_version=_SIGNING_VERSION, + payload_bytes=_PAYLOAD_BYTES, + signature_kid=_SIGNATURE_KID, + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_happy_path_emits_event_pair_with_injected_fields( + calibration_id: UUID, + revision_id: UUID, + permit_id: UUID, + content_hash: str, + peer_facility_id: str, + receipt_id: UUID, + signing_version: str, + payload_bytes: bytes, + signature_kid: str, + now: datetime, + actor_id: UUID, +) -> None: + """All guards pass: the cross-BC event pair carries injected fields + verbatim. receipt_id is shared across both streams; content_hash on + the permit event mirrors the revision; signature_bytes_hex is the + hex of the envelope payload bytes.""" + envelope = _envelope(payload_bytes, signing_version) + permit = _permit(permit_id=permit_id, peer_facility_id=peer_facility_id, status="Active") + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=revision_id, + content_hash=content_hash, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + result = decide( + state, + command, + permit_result=permit, + signature_envelope=envelope, + signature_kid=signature_kid, + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + + pub = result.calibration_event + assert pub.calibration_id == calibration_id + assert pub.revision_id == revision_id + assert pub.outbound_permit_id == permit_id + assert pub.signature_envelope_kind == envelope.kind + assert pub.signing_version == signing_version + assert pub.signature_bytes_hex == payload_bytes.hex() + assert pub.signature_kid == signature_kid + assert pub.receipt_id == receipt_id + assert pub.published_at == now + assert pub.occurred_at == now + assert pub.published_by_actor_id == actor_id + assert pub.publication_status == "Live" + + rec = result.permit_event + assert rec.permit_id == permit_id + assert rec.content_hash == content_hash + assert rec.home_stream_type == "Calibration" + assert rec.home_stream_id == calibration_id + assert rec.home_artifact_id == revision_id + assert rec.receipt_id == receipt_id + assert rec.recorded_at == now + assert rec.occurred_at == now + + +@pytest.mark.unit +@given( + calibration_id=st.uuids(), + revision_id=st.uuids(), + permit_id=st.uuids(), + content_hash=_CONTENT_HASH, + peer_facility_id=_PEER_FACILITY_ID, + receipt_id=st.uuids(), + payload_bytes=_PAYLOAD_BYTES, + now=aware_datetimes(), + actor_id=st.uuids(), +) +def test_decide_is_pure_same_inputs_yield_same_events( + calibration_id: UUID, + revision_id: UUID, + permit_id: UUID, + content_hash: str, + peer_facility_id: str, + receipt_id: UUID, + payload_bytes: bytes, + now: datetime, + actor_id: UUID, +) -> None: + """Two calls with identical inputs produce equal event pairs. + Detects clock leakage, hidden uuid4() calls, or non-determinism + in the hex / hash transformations.""" + envelope = _envelope(payload_bytes, "cora/v1") + permit = _permit(permit_id=permit_id, peer_facility_id=peer_facility_id, status="Active") + state = _calibration( + calibration_id=calibration_id, + revisions=( + _revision( + revision_id=revision_id, + content_hash=content_hash, + established_at=now, + established_by_actor_id=actor_id, + ), + ), + actor_id=actor_id, + ) + command = PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + first = decide( + state, + command, + permit_result=permit, + signature_envelope=envelope, + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + second = decide( + state, + command, + permit_result=permit, + signature_envelope=envelope, + signature_kid="kid", + receipt_id=receipt_id, + now=now, + published_by_actor_id=actor_id, + ) + assert first.calibration_event == second.calibration_event + assert first.permit_event == second.permit_event diff --git a/apps/api/tests/unit/calibration/test_publish_revision_handler.py b/apps/api/tests/unit/calibration/test_publish_revision_handler.py new file mode 100644 index 000000000..ec2b050b6 --- /dev/null +++ b/apps/api/tests/unit/calibration/test_publish_revision_handler.py @@ -0,0 +1,319 @@ +"""Unit tests for the publish_revision handler (Stage 3d3 canary). + +Exercise the handler end-to-end against in-memory publish + signature ++ permit-lookup adapters. Asserts the cross-BC append_streams contract: +exactly one Calibration event + one Permit event landed in a single +transaction (single position-window in the InMemory event store). +""" + +from dataclasses import replace +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.calibration.aggregates.calibration import ( + CalibrationStatus, + event_type_name, + to_payload, +) +from cora.calibration.aggregates.calibration.events import ( + CalibrationDefined, + CalibrationRevisionAppended, +) +from cora.calibration.errors import PublishPortNotWiredError, UnauthorizedError +from cora.calibration.features.publish_revision import ( + PublishCalibrationRevision, + bind, +) +from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup +from cora.federation.adapters.in_memory_publish_port import InMemoryPublishPort +from cora.federation.adapters.in_memory_signature_port import InMemorySignaturePort +from cora.infrastructure.event_envelope import to_new_event +from cora.infrastructure.kernel import Kernel +from cora.infrastructure.ports.event_store import StreamAppend +from tests.unit._helpers import build_deps + +_NOW = datetime(2026, 6, 1, 12, 0, 0, tzinfo=UTC) +_PEER = "aps-2bm" +_CALIBRATION_ID = UUID("33333333-3333-3333-3333-333333333333") +_REVISION_ID = UUID("44444444-4444-4444-4444-444444444444") +_PRINCIPAL_ID = UUID("55555555-5555-5555-5555-555555555555") +_TARGET_ID = UUID("88888888-8888-8888-8888-888888888888") +_CORRELATION_ID = UUID("99999999-9999-9999-9999-999999999999") +_PERMIT_ID = UUID("11111111-1111-1111-1111-111111111111") +_PERMIT_VERSION = 0 # empty stream in unit tests; PG production lookup mirrors real version + + +async def _seed_calibration( + deps: Kernel, + *, + revision_content_hash: str | None = "a" * 64, +) -> None: + defined = CalibrationDefined( + calibration_id=_CALIBRATION_ID, + target_id=_TARGET_ID, + quantity="rotation_center_pixels", + operating_point={}, + description=None, + defined_by_actor_id=_PRINCIPAL_ID, + occurred_at=_NOW, + ) + appended = CalibrationRevisionAppended( + revision_id=_REVISION_ID, + calibration_id=_CALIBRATION_ID, + value={"value": 1.0}, + status=CalibrationStatus.VERIFIED, + source_procedure_id=None, + source_dataset_id=None, + source_actor_id=uuid4(), + established_at=_NOW, + established_by_actor_id=_PRINCIPAL_ID, + decided_by_decision_id=None, + supersedes_revision_id=None, + occurred_at=_NOW, + content_hash=revision_content_hash, + ) + new_events = [ + to_new_event( + event_type=event_type_name(event), + payload=to_payload(event), + occurred_at=event.occurred_at, + event_id=uuid4(), + command_name="seed", + correlation_id=_CORRELATION_ID, + causation_id=None, + principal_id=_PRINCIPAL_ID, + ) + for event in (defined, appended) + ] + await deps.event_store.append_streams( + [ + StreamAppend( + stream_type="Calibration", + stream_id=_CALIBRATION_ID, + expected_version=0, + events=new_events, + ), + ] + ) + + +def _build_publish_deps( + *, + permit_lookup: InMemoryPermitLookup | None = None, + publish_port: InMemoryPublishPort | None = None, + signature_port: InMemorySignaturePort | None = None, + deny: bool = False, + ids: list[UUID] | None = None, +) -> tuple[Kernel, InMemoryPermitLookup, InMemoryPublishPort, InMemorySignaturePort]: + pl = permit_lookup or InMemoryPermitLookup() + pp = publish_port or InMemoryPublishPort() + sp = signature_port or InMemorySignaturePort() + deps = build_deps(ids=ids, deny=deny, now=_NOW) + deps = replace(deps, permit_lookup=pl, publish_port=pp, signature_port=sp) + return deps, pl, pp, sp + + +def _command( + *, + calibration_id: UUID = _CALIBRATION_ID, + revision_id: UUID = _REVISION_ID, + peer_facility_id: str = _PEER, +) -> PublishCalibrationRevision: + return PublishCalibrationRevision( + calibration_id=calibration_id, + revision_id=revision_id, + peer_facility_id=peer_facility_id, + ) + + +def test_bind_raises_when_all_publish_deps_explicitly_unset_on_kernel() -> None: + deps = build_deps() + deps = replace(deps, publish_port=None, signature_port=None, permit_lookup=None) + with pytest.raises(PublishPortNotWiredError) as exc_info: + bind(deps) + assert set(exc_info.value.missing) == { + "publish_port", + "signature_port", + "permit_lookup", + } + + +def test_bind_with_only_some_deps_unset_lists_only_the_missing_ones() -> None: + deps = build_deps() + deps = replace(deps, signature_port=None, permit_lookup=None) + with pytest.raises(PublishPortNotWiredError) as exc_info: + bind(deps) + assert "publish_port" not in exc_info.value.missing + assert "signature_port" in exc_info.value.missing + assert "permit_lookup" in exc_info.value.missing + + +@pytest.mark.asyncio +async def test_handler_happy_path_returns_receipt_id_and_writes_both_streams() -> None: + deps, pl, _pp, _sp = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + + receipt_id = await handler( + _command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID + ) + + assert isinstance(receipt_id, UUID) + cal_events, _ = await deps.event_store.load("Calibration", _CALIBRATION_ID) + publication_events = [e for e in cal_events if e.event_type == "CalibrationRevisionPublished"] + assert len(publication_events) == 1 + assert publication_events[0].payload["calibration_id"] == str(_CALIBRATION_ID) + assert publication_events[0].payload["revision_id"] == str(_REVISION_ID) + assert publication_events[0].payload["outbound_permit_id"] == str(_PERMIT_ID) + assert publication_events[0].payload["publication_status"] == "Live" + assert publication_events[0].payload["receipt_id"] == str(receipt_id) + assert publication_events[0].payload["published_by_actor_id"] == str(_PRINCIPAL_ID) + + permit_events, _ = await deps.event_store.load("Permit", _PERMIT_ID) + recorded = [e for e in permit_events if e.event_type == "PublicationReceiptRecorded"] + assert len(recorded) == 1 + assert recorded[0].payload["receipt_id"] == str(receipt_id) + assert recorded[0].payload["home_stream_id"] == str(_CALIBRATION_ID) + assert recorded[0].payload["home_artifact_id"] == str(_REVISION_ID) + assert recorded[0].payload["content_hash"] == "a" * 64 + + +@pytest.mark.asyncio +async def test_handler_writes_both_events_in_a_single_transaction() -> None: + deps, pl, _, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + cal_events, _ = await deps.event_store.load("Calibration", _CALIBRATION_ID) + permit_events, _ = await deps.event_store.load("Permit", _PERMIT_ID) + publication_event = next( + e for e in cal_events if e.event_type == "CalibrationRevisionPublished" + ) + receipt_event = next(e for e in permit_events if e.event_type == "PublicationReceiptRecorded") + assert publication_event.transaction_id == receipt_event.transaction_id + + +@pytest.mark.asyncio +async def test_handler_records_one_published_artifact_on_publish_port_per_call() -> None: + deps, pl, pp, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + published = pp.published_artifacts() + assert len(published) == 1 + artifact = published[0] + assert artifact.payload_type == "application/vnd.cora.calibration-revision-published+json" + assert artifact.canonicalization_version == "cora/v1" + assert artifact.signature_envelope.kind == "dsse_static_jwks" + assert artifact.canonical_bytes.startswith(b"DSSEv1 ") + + +@pytest.mark.asyncio +async def test_handler_raises_unauthorized_when_authz_denies() -> None: + deps, pl, _, _ = _build_publish_deps(deny=True, ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + with pytest.raises(UnauthorizedError): + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + +@pytest.mark.asyncio +async def test_handler_short_circuits_when_calibration_missing() -> None: + deps, pl, _, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + handler = bind(deps) + from cora.calibration.aggregates.calibration import CalibrationNotFoundError + + with pytest.raises(CalibrationNotFoundError): + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + +@pytest.mark.asyncio +async def test_handler_short_circuits_when_outbound_permit_inactive() -> None: + deps, pl, _, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + status="Suspended", + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + from cora.calibration.aggregates.calibration import OutboundPermitNotActiveError + + with pytest.raises(OutboundPermitNotActiveError) as exc_info: + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + assert exc_info.value.status == "Suspended" + + +@pytest.mark.asyncio +async def test_handler_short_circuits_when_revision_missing_content_hash() -> None: + deps, pl, _, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps, revision_content_hash=None) + handler = bind(deps) + from cora.calibration.aggregates.calibration import ( + CalibrationCannotPublishRevisionError, + ) + + with pytest.raises(CalibrationCannotPublishRevisionError): + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + +@pytest.mark.asyncio +async def test_handler_signature_envelope_persisted_round_trips_signing_version_via_hex() -> None: + deps, pl, _, _ = _build_publish_deps(ids=[uuid4() for _ in range(8)]) + pl.register_outbound( + peer_facility_id=_PEER, + artifact_kind="CalibrationRevision", + permit_id=_PERMIT_ID, + current_version=_PERMIT_VERSION, + ) + await _seed_calibration(deps) + handler = bind(deps) + await handler(_command(), principal_id=_PRINCIPAL_ID, correlation_id=_CORRELATION_ID) + + cal_events, _ = await deps.event_store.load("Calibration", _CALIBRATION_ID) + publication = next(e for e in cal_events if e.event_type == "CalibrationRevisionPublished") + assert publication.payload["signing_version"] == "cora/v1" + assert publication.payload["signature_envelope_kind"] == "dsse_static_jwks" + bytes.fromhex(publication.payload["signature_bytes_hex"]) # round-trips diff --git a/apps/api/tests/unit/federation/test_federation_registry.py b/apps/api/tests/unit/federation/test_federation_registry.py new file mode 100644 index 000000000..7fd801762 --- /dev/null +++ b/apps/api/tests/unit/federation/test_federation_registry.py @@ -0,0 +1,163 @@ +"""Unit tests for FederationRegistry composite dispatcher.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.federation.adapters.federation_registry import FederationRegistry +from cora.federation.adapters.in_memory_publish_port import InMemoryPublishPort +from cora.federation.adapters.in_memory_pull_port import InMemoryPullPort +from cora.infrastructure.ports.federation import ( + ArtifactReference, + DsseStaticJwksEnvelope, + NoAdapterForFacilityError, + PublishedArtifact, + PublishPort, + PulledArtifact, + PullPort, + SignedOffBy, +) + + +def _facility(hex_prefix: str) -> UUID: + """Build a UUID whose hex representation starts with `hex_prefix`.""" + padded = hex_prefix.ljust(32, "0") + return UUID(padded) + + +def _artifact(facility_id: UUID) -> PublishedArtifact: + return PublishedArtifact( + content_hash=b"\x01" * 32, + canonical_bytes=b"DSSEv1 ...", + payload_type="application/vnd.cora.test+json", + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=facility_id, + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + + +def _reference(facility_id: UUID) -> ArtifactReference: + return ArtifactReference( + content_hash=b"\x01" * 32, + payload_type="application/vnd.cora.test+json", + source_facility_id=facility_id, + hint_locator="https://peer/x", + ) + + +def test_federation_registry_satisfies_publish_port_protocol() -> None: + assert isinstance(FederationRegistry(), PublishPort) + + +def test_federation_registry_satisfies_pull_port_protocol() -> None: + assert isinstance(FederationRegistry(), PullPort) + + +@pytest.mark.asyncio +async def test_publish_routes_to_adapter_registered_under_facility_prefix() -> None: + registry = FederationRegistry() + aps = InMemoryPublishPort() + nsls = InMemoryPublishPort() + registry.register("aaaa", aps) + registry.register("bbbb", nsls) + artifact = _artifact(_facility("aaaa1234")) + await registry.publish(artifact) + assert len(aps.published_artifacts()) == 1 + assert len(nsls.published_artifacts()) == 0 + + +@pytest.mark.asyncio +async def test_publish_longer_prefix_wins_over_shorter_prefix() -> None: + registry = FederationRegistry() + broad = InMemoryPublishPort() + specific = InMemoryPublishPort() + registry.register("aa", broad) + registry.register("aabb", specific) + artifact = _artifact(_facility("aabb1234")) + await registry.publish(artifact) + assert len(specific.published_artifacts()) == 1 + assert len(broad.published_artifacts()) == 0 + + +@pytest.mark.asyncio +async def test_publish_with_no_matching_prefix_raises_no_adapter_for_facility() -> None: + registry = FederationRegistry() + registry.register("aaaa", InMemoryPublishPort()) + with pytest.raises(NoAdapterForFacilityError): + await registry.publish(_artifact(_facility("ffff1234"))) + + +@pytest.mark.asyncio +async def test_fetch_routes_to_adapter_registered_under_facility_prefix() -> None: + registry = FederationRegistry() + aps_pull = InMemoryPullPort() + fac = _facility("aaaa") + ref = _reference(fac) + pulled = await _build_pulled(ref) + aps_pull.set_pull_response(ref, pulled) + registry.register("aaaa", aps_pull) + got = await registry.fetch(ref) + assert got is pulled + + +@pytest.mark.asyncio +async def test_fetch_with_no_matching_prefix_raises_no_adapter_for_facility() -> None: + registry = FederationRegistry() + registry.register("aaaa", InMemoryPullPort()) + with pytest.raises(NoAdapterForFacilityError): + await registry.fetch(_reference(_facility("ffff"))) + + +def test_register_with_existing_prefix_replaces_prior_entry() -> None: + registry = FederationRegistry() + first = InMemoryPublishPort() + second = InMemoryPublishPort() + registry.register("aaaa", first) + registry.register("aaaa", second) + assert registry.registered_prefixes() == ("aaaa",) + + +def test_registered_prefixes_preserves_registration_order() -> None: + registry = FederationRegistry() + registry.register("aaaa", InMemoryPublishPort()) + registry.register("bbbb", InMemoryPullPort()) + registry.register("cccc", InMemoryPublishPort()) + assert registry.registered_prefixes() == ("aaaa", "bbbb", "cccc") + + +@pytest.mark.asyncio +async def test_aclose_closes_all_registered_adapters_and_is_idempotent() -> None: + registry = FederationRegistry() + adapter = InMemoryPublishPort() + registry.register("aaaa", adapter) + await registry.aclose() + await registry.aclose() + + +@pytest.mark.asyncio +async def test_aclose_with_flaky_adapter_completes_without_raising() -> None: + class _FlakyAdapter: + async def publish(self, artifact: PublishedArtifact) -> object: + raise NotImplementedError + + async def aclose(self) -> None: + raise RuntimeError("flaky teardown") + + registry = FederationRegistry() + registry.register("aaaa", _FlakyAdapter()) # type: ignore[arg-type] + await registry.aclose() + + +async def _build_pulled(reference: ArtifactReference) -> PulledArtifact: + return PulledArtifact( + artifact=_artifact(reference.source_facility_id), + fetch_provenance=InMemoryPullPort.make_provenance(byte_count=42), + ) diff --git a/apps/api/tests/unit/federation/test_in_memory_permit_lookup.py b/apps/api/tests/unit/federation/test_in_memory_permit_lookup.py new file mode 100644 index 000000000..65c8590e2 --- /dev/null +++ b/apps/api/tests/unit/federation/test_in_memory_permit_lookup.py @@ -0,0 +1,123 @@ +"""Unit tests for InMemoryPermitLookup.""" + +from uuid import uuid4 + +import pytest + +from cora.federation.adapters.in_memory_permit_lookup import InMemoryPermitLookup +from cora.infrastructure.ports.federation import PermitLookup, PermitLookupResult + + +def test_in_memory_permit_lookup_satisfies_permit_lookup_protocol() -> None: + assert isinstance(InMemoryPermitLookup(), PermitLookup) + + +@pytest.mark.asyncio +async def test_lookup_outbound_returns_none_when_no_permit_registered() -> None: + lookup = InMemoryPermitLookup() + result = await lookup.lookup_outbound("aps-2bm", "CalibrationRevision") + assert result is None + + +@pytest.mark.asyncio +async def test_lookup_inbound_returns_none_when_no_permit_registered() -> None: + lookup = InMemoryPermitLookup() + result = await lookup.lookup_inbound("aps-2bm", "CalibrationRevision") + assert result is None + + +@pytest.mark.asyncio +async def test_register_outbound_then_lookup_outbound_returns_seeded_result() -> None: + lookup = InMemoryPermitLookup() + permit_id = uuid4() + seeded = lookup.register_outbound( + peer_facility_id="aps-2bm", + artifact_kind="CalibrationRevision", + permit_id=permit_id, + ) + result = await lookup.lookup_outbound("aps-2bm", "CalibrationRevision") + assert result == seeded + assert result is not None + assert result.permit_id == permit_id + assert result.direction == "Outbound" + assert result.status == "Active" + assert result.abi_tier_floor == "Stable" + + +@pytest.mark.asyncio +async def test_register_inbound_then_lookup_inbound_returns_seeded_result() -> None: + lookup = InMemoryPermitLookup() + permit_id = uuid4() + seeded = lookup.register_inbound( + peer_facility_id="nsls-ii", artifact_kind="Method", permit_id=permit_id + ) + result = await lookup.lookup_inbound("nsls-ii", "Method") + assert result == seeded + assert result is not None + assert result.direction == "Inbound" + + +@pytest.mark.asyncio +async def test_outbound_and_inbound_lookups_are_keyed_independently() -> None: + lookup = InMemoryPermitLookup() + lookup.register_outbound(peer_facility_id="aps-2bm", artifact_kind="Method", permit_id=uuid4()) + assert await lookup.lookup_outbound("aps-2bm", "Method") is not None + assert await lookup.lookup_inbound("aps-2bm", "Method") is None + + +@pytest.mark.asyncio +async def test_lookup_with_different_artifact_kind_returns_none() -> None: + lookup = InMemoryPermitLookup() + lookup.register_outbound( + peer_facility_id="aps-2bm", + artifact_kind="CalibrationRevision", + permit_id=uuid4(), + ) + assert await lookup.lookup_outbound("aps-2bm", "Method") is None + + +@pytest.mark.asyncio +async def test_register_with_custom_status_and_floor_propagates_to_result() -> None: + lookup = InMemoryPermitLookup() + seeded = lookup.register_outbound( + peer_facility_id="aps-2bm", + artifact_kind="CalibrationRevision", + permit_id=uuid4(), + status="Suspended", + abi_tier_floor="Obsolete", + current_version=7, + ) + result = await lookup.lookup_outbound("aps-2bm", "CalibrationRevision") + assert result == seeded + assert result is not None + assert result.status == "Suspended" + assert result.abi_tier_floor == "Obsolete" + assert result.current_version == 7 + + +@pytest.mark.asyncio +async def test_clear_removes_all_registered_permits() -> None: + lookup = InMemoryPermitLookup() + lookup.register_outbound( + peer_facility_id="aps-2bm", + artifact_kind="CalibrationRevision", + permit_id=uuid4(), + ) + lookup.register_inbound(peer_facility_id="nsls-ii", artifact_kind="Method", permit_id=uuid4()) + lookup.clear() + assert await lookup.lookup_outbound("aps-2bm", "CalibrationRevision") is None + assert await lookup.lookup_inbound("nsls-ii", "Method") is None + + +def test_permit_lookup_result_is_frozen_dataclass_carrying_locked_fields() -> None: + r = PermitLookupResult( + permit_id=uuid4(), + peer_facility_id="aps-2bm", + direction="Outbound", + status="Active", + abi_tier_floor="Stable", + current_version=3, + ) + assert r.peer_facility_id == "aps-2bm" + with pytest.raises(AttributeError): + r.status = "Revoked" # type: ignore[misc] diff --git a/apps/api/tests/unit/federation/test_in_memory_publish_port.py b/apps/api/tests/unit/federation/test_in_memory_publish_port.py new file mode 100644 index 000000000..d8127c583 --- /dev/null +++ b/apps/api/tests/unit/federation/test_in_memory_publish_port.py @@ -0,0 +1,87 @@ +"""Unit tests for InMemoryPublishPort.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.federation.adapters.in_memory_publish_port import InMemoryPublishPort +from cora.infrastructure.ports.federation import ( + DsseStaticJwksEnvelope, + FederationCredentialRevokedError, + PublishedArtifact, + PublishPort, + SignedOffBy, +) + + +def _artifact(content_hash: bytes = b"\x01" * 32) -> PublishedArtifact: + return PublishedArtifact( + content_hash=content_hash, + canonical_bytes=b"DSSEv1 ...", + payload_type="application/vnd.cora.test+json", + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + + +def test_in_memory_publish_port_satisfies_publish_port_protocol() -> None: + assert isinstance(InMemoryPublishPort(), PublishPort) + + +@pytest.mark.asyncio +async def test_publish_records_artifact_and_returns_in_memory_receipt() -> None: + port = InMemoryPublishPort() + artifact = _artifact() + receipt = await port.publish(artifact) + assert receipt.receipt_format_hint == "in-memory/v1" + assert receipt.receipt_bytes.startswith(b"in-memory-receipt-") + assert port.published_artifacts() == (artifact,) + + +@pytest.mark.asyncio +async def test_publish_increments_receipt_id_across_calls() -> None: + port = InMemoryPublishPort() + r1 = await port.publish(_artifact(b"\x01" * 32)) + r2 = await port.publish(_artifact(b"\x02" * 32)) + assert r1.receipt_bytes != r2.receipt_bytes + + +@pytest.mark.asyncio +async def test_simulate_credential_revoked_makes_next_publish_raise() -> None: + port = InMemoryPublishPort() + cid = uuid4() + ts = datetime(2026, 5, 31, tzinfo=UTC) + port.simulate_credential_revoked(cid, ts) + with pytest.raises(FederationCredentialRevokedError) as exc_info: + await port.publish(_artifact()) + assert exc_info.value.credential_id == cid + assert exc_info.value.revoked_at == ts + + +@pytest.mark.asyncio +async def test_clear_simulations_re_enables_publishing_after_revoked_sim() -> None: + port = InMemoryPublishPort() + port.simulate_credential_revoked(uuid4(), datetime(2026, 5, 31, tzinfo=UTC)) + with pytest.raises(FederationCredentialRevokedError): + await port.publish(_artifact()) + port.clear_simulations() + receipt = await port.publish(_artifact()) + assert receipt is not None + + +@pytest.mark.asyncio +async def test_aclose_clears_state_and_is_idempotent() -> None: + port = InMemoryPublishPort() + await port.publish(_artifact()) + await port.aclose() + assert port.published_artifacts() == () + await port.aclose() diff --git a/apps/api/tests/unit/federation/test_in_memory_pull_port.py b/apps/api/tests/unit/federation/test_in_memory_pull_port.py new file mode 100644 index 000000000..d8eed6112 --- /dev/null +++ b/apps/api/tests/unit/federation/test_in_memory_pull_port.py @@ -0,0 +1,127 @@ +"""Unit tests for InMemoryPullPort.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.federation.adapters.in_memory_pull_port import InMemoryPullPort +from cora.infrastructure.ports.federation import ( + ArtifactReference, + DsseStaticJwksEnvelope, + FederationCircuitOpenError, + FederationPublicationContentDriftError, + FetchProvenance, + PublishedArtifact, + PulledArtifact, + PullPort, + SignedOffBy, +) + + +def _ref(facility_id: object | None = None) -> ArtifactReference: + return ArtifactReference( + content_hash=b"\x01" * 32, + payload_type="application/vnd.cora.test+json", + source_facility_id=facility_id if facility_id is not None else uuid4(), # type: ignore[arg-type] + hint_locator="https://peer/x", + ) + + +def _pulled(reference: ArtifactReference) -> PulledArtifact: + artifact = PublishedArtifact( + content_hash=reference.content_hash, + canonical_bytes=b"DSSEv1 ...", + payload_type=reference.payload_type, + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=reference.source_facility_id, + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + return PulledArtifact( + artifact=artifact, + fetch_provenance=FetchProvenance( + locator_used="in-memory://x", + wire_content_type="application/dsse+json", + fetch_duration_ms=1, + byte_count=len(artifact.canonical_bytes), + ), + ) + + +def test_in_memory_pull_port_satisfies_pull_port_protocol() -> None: + assert isinstance(InMemoryPullPort(), PullPort) + + +@pytest.mark.asyncio +async def test_fetch_without_primed_response_raises_key_error_with_guidance() -> None: + port = InMemoryPullPort() + with pytest.raises(KeyError, match="set_pull_response"): + await port.fetch(_ref()) + + +@pytest.mark.asyncio +async def test_set_pull_response_then_fetch_returns_primed_artifact() -> None: + port = InMemoryPullPort() + ref = _ref() + pulled = _pulled(ref) + port.set_pull_response(ref, pulled) + got = await port.fetch(ref) + assert got is pulled + + +@pytest.mark.asyncio +async def test_simulate_registry_unreachable_makes_fetch_raise_circuit_open() -> None: + port = InMemoryPullPort() + facility_id = uuid4() + opened = datetime(2026, 5, 31, tzinfo=UTC) + port.simulate_registry_unreachable(facility_id, opened) + with pytest.raises(FederationCircuitOpenError) as exc_info: + await port.fetch(_ref(facility_id=facility_id)) + assert exc_info.value.source_facility_id == facility_id + assert exc_info.value.opened_at == opened + + +@pytest.mark.asyncio +async def test_simulate_content_drift_makes_fetch_raise_drift_with_mismatched_hashes() -> None: + port = InMemoryPullPort() + ref = _ref() + port.simulate_content_drift(ref) + with pytest.raises(FederationPublicationContentDriftError) as exc_info: + await port.fetch(ref) + assert exc_info.value.reference_content_hash == ref.content_hash + assert exc_info.value.fetched_content_hash != ref.content_hash + + +@pytest.mark.asyncio +async def test_clear_simulations_after_unreachable_sim_lets_fetch_return_response() -> None: + port = InMemoryPullPort() + facility_id = uuid4() + port.simulate_registry_unreachable(facility_id, datetime(2026, 5, 31, tzinfo=UTC)) + ref = _ref(facility_id=facility_id) + port.set_pull_response(ref, _pulled(ref)) + with pytest.raises(FederationCircuitOpenError): + await port.fetch(ref) + port.clear_simulations() + got = await port.fetch(ref) + assert got.artifact.content_hash == ref.content_hash + + +def test_make_provenance_helper_returns_well_formed_fetch_provenance() -> None: + p = InMemoryPullPort.make_provenance(byte_count=42) + assert p.byte_count == 42 + assert p.wire_content_type == "application/dsse+json" + + +@pytest.mark.asyncio +async def test_aclose_clears_state_and_is_idempotent() -> None: + port = InMemoryPullPort() + port.set_pull_response(_ref(), _pulled(_ref())) + await port.aclose() + await port.aclose() diff --git a/apps/api/tests/unit/federation/test_in_memory_signature_port.py b/apps/api/tests/unit/federation/test_in_memory_signature_port.py new file mode 100644 index 000000000..e768a6c0e --- /dev/null +++ b/apps/api/tests/unit/federation/test_in_memory_signature_port.py @@ -0,0 +1,127 @@ +"""Unit tests for InMemorySignaturePort.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.federation.adapters.in_memory_signature_port import InMemorySignaturePort +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.federation import ( + DsseStaticJwksEnvelope, + FederationTrustContext, + PublishedArtifact, + Rejected, + SignaturePort, + SignedOffBy, + UnverifiabilityReason, + Unverifiable, + Verified, +) + + +def _artifact(content_hash: bytes = b"\x01" * 32) -> PublishedArtifact: + return PublishedArtifact( + content_hash=content_hash, + canonical_bytes=b"DSSEv1 ...", + payload_type="application/vnd.cora.test+json", + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + + +def _trust_context() -> FederationTrustContext: + return FederationTrustContext( + permit_id=uuid4(), + allowed_credentials=frozenset(), + allowed_payload_types=frozenset({"application/vnd.cora.test+json"}), + abi_tier_floor="Stable", + ) + + +def _canonicalized(version: str = "cora/v1") -> CanonicalizedBytes: + return CanonicalizedBytes( + bytes_=b"DSSEv1 ...", + adapter_version=version, + payload_type="application/vnd.cora.test+json", + ) + + +def test_in_memory_signature_port_satisfies_signature_port_protocol() -> None: + assert isinstance(InMemorySignaturePort(), SignaturePort) + + +@pytest.mark.asyncio +async def test_verify_returns_default_verified_outcome_when_no_simulation_set() -> None: + port = InMemorySignaturePort() + outcome = await port.verify(_artifact(), _trust_context()) + assert isinstance(outcome, Verified) + assert all(r.outcome == "pass" for r in outcome.stage_results) + + +@pytest.mark.asyncio +async def test_simulate_signature_invalid_makes_verify_return_rejected_with_failed_stage() -> None: + port = InMemorySignaturePort() + port.simulate_signature_invalid(b"\xaa" * 32, failed_stage="signature") + outcome = await port.verify(_artifact(content_hash=b"\xaa" * 32), _trust_context()) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "signature" + + +@pytest.mark.asyncio +async def test_set_verification_outcome_overrides_default_per_content_hash() -> None: + port = InMemorySignaturePort() + primed = Unverifiable( + stage_results=(), + unverifiability=UnverifiabilityReason(failed_stage="key_resolution", reason="test"), + ) + port.set_verification_outcome(b"\xbb" * 32, primed) + outcome = await port.verify(_artifact(content_hash=b"\xbb" * 32), _trust_context()) + assert outcome is primed + + +@pytest.mark.asyncio +async def test_sign_returns_in_memory_dsse_static_jwks_envelope_with_matching_version() -> None: + port = InMemorySignaturePort() + canonicalized = _canonicalized(version="cora/v1") + envelope = await port.sign(canonicalized, _trust_context()) + assert isinstance(envelope, DsseStaticJwksEnvelope) + assert envelope.signing_version == "cora/v1" + assert envelope.payload_bytes.endswith(canonicalized.bytes_) + + +@pytest.mark.asyncio +async def test_set_sign_envelope_overrides_default_per_canonicalization_version() -> None: + port = InMemorySignaturePort() + primed = DsseStaticJwksEnvelope(signing_version="cora/v2-cose", payload_bytes=b"custom") + port.set_sign_envelope("cora/v2-cose", primed) + out = await port.sign(_canonicalized(version="cora/v2-cose"), _trust_context()) + assert out is primed + + +@pytest.mark.asyncio +async def test_clear_simulations_resets_both_verify_and_sign_overrides() -> None: + port = InMemorySignaturePort() + port.simulate_signature_invalid(b"\xcc" * 32, failed_stage="signature") + port.set_sign_envelope( + "cora/v1", DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"x") + ) + port.clear_simulations() + outcome = await port.verify(_artifact(content_hash=b"\xcc" * 32), _trust_context()) + assert isinstance(outcome, Verified) + + +@pytest.mark.asyncio +async def test_aclose_clears_state_and_is_idempotent() -> None: + port = InMemorySignaturePort() + port.simulate_signature_invalid(b"\xdd" * 32, failed_stage="signature") + await port.aclose() + await port.aclose() diff --git a/apps/api/tests/unit/infrastructure/federation/test_errors.py b/apps/api/tests/unit/infrastructure/federation/test_errors.py new file mode 100644 index 000000000..c7e274151 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/federation/test_errors.py @@ -0,0 +1,129 @@ +"""Unit tests for the federation port-tier error family.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from cora.infrastructure.ports.federation.errors import ( + FederationAdoptionWindowClosedError, + FederationCanonicalizationMismatchError, + FederationCircuitOpenError, + FederationCredentialRevokedError, + FederationPermitNotFoundError, + FederationPublicationContentDriftError, + FederationRateLimitExceededError, + FederationReceiptMissingError, + FederationRetryExhaustedError, + FederationSignatureInvalidError, + FederationSignerUntrustedError, + NoAdapterForFacilityError, +) +from cora.infrastructure.ports.federation.value_types import ArtifactReference + + +def test_federation_permit_not_found_error_carries_kwargs_and_renders_str() -> None: + pid = uuid4() + e = FederationPermitNotFoundError(permit_id=pid, lookup_kind="inbound") + assert e.permit_id == pid + assert e.lookup_kind == "inbound" + assert "inbound" in str(e) + + +def test_federation_signature_invalid_error_carries_failed_stage() -> None: + e = FederationSignatureInvalidError( + content_hash=b"\xaa" * 32, + envelope_kind="dsse_static_jwks", + failed_stage="signature", + ) + assert e.failed_stage == "signature" + assert e.envelope_kind == "dsse_static_jwks" + assert "dsse_static_jwks" in str(e) + + +def test_federation_signer_untrusted_error_distinct_from_signature_invalid() -> None: + e = FederationSignerUntrustedError( + content_hash=b"\xbb" * 32, + envelope_kind="cose_sign1_scitt", + attempted_key_ref="kid-A", + ) + assert e.attempted_key_ref == "kid-A" + + +def test_federation_publication_content_drift_error_carries_both_hashes() -> None: + e = FederationPublicationContentDriftError( + reference_content_hash=b"\x01" * 32, + fetched_content_hash=b"\x02" * 32, + ) + assert e.reference_content_hash != e.fetched_content_hash + assert e.reference_content_hash.hex() in str(e) + assert e.fetched_content_hash.hex() in str(e) + + +def test_federation_credential_revoked_error_carries_credential_id_and_timestamp() -> None: + cid = uuid4() + ts = datetime(2026, 5, 31, tzinfo=UTC) + e = FederationCredentialRevokedError(credential_id=cid, revoked_at=ts) + assert e.credential_id == cid + assert e.revoked_at == ts + + +def test_federation_retry_exhausted_error_carries_reference_and_attempt_count() -> None: + ref = ArtifactReference( + content_hash=b"\x03" * 32, + payload_type="application/vnd.cora.test+json", + source_facility_id=uuid4(), + hint_locator="https://peer/x", + ) + e = FederationRetryExhaustedError(reference=ref, attempts=5, last_error_class="ConnectionError") + assert e.attempts == 5 + assert e.last_error_class == "ConnectionError" + + +def test_federation_circuit_open_error_carries_facility_id_and_opened_at() -> None: + fid = uuid4() + opened = datetime(2026, 5, 31, tzinfo=UTC) + e = FederationCircuitOpenError(source_facility_id=fid, opened_at=opened) + assert e.source_facility_id == fid + assert e.opened_at == opened + + +def test_federation_rate_limit_exceeded_error_carries_seconds_as_opaque() -> None: + e = FederationRateLimitExceededError(source_facility_id=uuid4(), retry_after_seconds=120) + assert e.retry_after_seconds == 120 + + +def test_federation_adoption_window_closed_error_discriminates_via_status() -> None: + for status in ("Live", "Yanked", "Withdrawn", "Expired", "AbiTierObsoleteOrRemoved"): + e = FederationAdoptionWindowClosedError( + content_hash=b"\x04" * 32, + publication_status=status, # type: ignore[arg-type] + ) + assert e.publication_status == status + + +def test_federation_receipt_missing_error_normalizes_required_and_observed_to_sorted_tuple() -> ( + None +): + e = FederationReceiptMissingError( + content_hash=b"\x05" * 32, + envelope_kind="cose_sign1_scitt", + required_receipt_kinds={"scitt", "rekor_sct"}, + observed_receipt_kinds={"ts_authority"}, + ) + assert e.required_receipt_kinds == ("rekor_sct", "scitt") + assert e.observed_receipt_kinds == ("ts_authority",) + + +def test_no_adapter_for_facility_error_carries_facility_id() -> None: + fid = uuid4() + e = NoAdapterForFacilityError(source_facility_id=fid) + assert e.source_facility_id == fid + + +def test_federation_canonicalization_mismatch_error_bridges_memo_1_and_memo_3() -> None: + e = FederationCanonicalizationMismatchError( + content_hash=b"\x06" * 32, + expected_canonicalization_profile_id="cora/v1", + observed_profile_id="cora/v2-cose", + ) + assert e.expected_canonicalization_profile_id == "cora/v1" + assert e.observed_profile_id == "cora/v2-cose" diff --git a/apps/api/tests/unit/infrastructure/federation/test_ports.py b/apps/api/tests/unit/infrastructure/federation/test_ports.py new file mode 100644 index 000000000..c1564f1c0 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/federation/test_ports.py @@ -0,0 +1,127 @@ +"""Unit tests for the federation port Protocols. + +Pins the runtime_checkable Protocol surface for PublishPort, +PullPort, and SignaturePort against minimal duck-typed conformers. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.federation import ( + ArtifactReference, + DsseStaticJwksEnvelope, + FederationTrustContext, + FetchProvenance, + PublishedArtifact, + PublishPort, + PublishReceipt, + PulledArtifact, + PullPort, + SignatureEnvelope, + SignaturePort, + SignedOffBy, + StageResult, + VerificationOutcome, + Verified, +) + + +def _published() -> PublishedArtifact: + return PublishedArtifact( + content_hash=b"\x01" * 32, + canonical_bytes=b"DSSEv1 ...", + payload_type="application/vnd.cora.test+json", + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + + +class _FakePublishAdapter: + async def publish(self, artifact: PublishedArtifact) -> PublishReceipt: + _ = artifact + return PublishReceipt( + receipt_bytes=b"opaque", + receipt_format_hint="fake/v0", + transparency_log_hint="fake-log", + recorded_at=datetime(2026, 5, 31, tzinfo=UTC), + ) + + +class _FakePullAdapter: + async def fetch(self, reference: ArtifactReference) -> PulledArtifact: + _ = reference + return PulledArtifact( + artifact=_published(), + fetch_provenance=FetchProvenance( + locator_used="fake://x", + wire_content_type="application/dsse+json", + fetch_duration_ms=1, + byte_count=1, + ), + ) + + +class _FakeSignatureAdapter: + async def verify( + self, + artifact: PublishedArtifact, + trust_context: FederationTrustContext, + ) -> VerificationOutcome: + _ = artifact, trust_context + return Verified(stage_results=(StageResult(stage="content_hash", outcome="pass"),)) + + async def sign( + self, + canonicalized: CanonicalizedBytes, + trust_context: FederationTrustContext, + ) -> SignatureEnvelope: + _ = canonicalized, trust_context + return DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"opaque") + + +def test_publish_port_is_runtime_checkable_against_duck_typed_adapter() -> None: + assert isinstance(_FakePublishAdapter(), PublishPort) + + +def test_pull_port_is_runtime_checkable_against_duck_typed_adapter() -> None: + assert isinstance(_FakePullAdapter(), PullPort) + + +def test_signature_port_is_runtime_checkable_against_duck_typed_adapter() -> None: + assert isinstance(_FakeSignatureAdapter(), SignaturePort) + + +def test_object_without_publish_method_is_not_a_publish_port() -> None: + class _Empty: + pass + + assert not isinstance(_Empty(), PublishPort) + + +def test_object_without_fetch_method_is_not_a_pull_port() -> None: + class _Empty: + pass + + assert not isinstance(_Empty(), PullPort) + + +def test_object_without_verify_and_sign_methods_is_not_a_signature_port() -> None: + class _OnlyVerify: + async def verify( + self, + artifact: PublishedArtifact, + trust_context: FederationTrustContext, + ) -> VerificationOutcome: + _ = artifact, trust_context + return Verified(stage_results=()) + + assert not isinstance(_OnlyVerify(), SignaturePort) diff --git a/apps/api/tests/unit/infrastructure/federation/test_value_types.py b/apps/api/tests/unit/infrastructure/federation/test_value_types.py new file mode 100644 index 000000000..ca4c02d47 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/federation/test_value_types.py @@ -0,0 +1,261 @@ +"""Unit tests for federation port-tier value types.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +import pytest + +from cora.infrastructure.ports.federation.value_types import ( + ArtifactReference, + AssistedBy, + CoDevelopedBy, + CoseSign1ScittEnvelope, + CredentialRef, + DcoEntry, + DsseSigstoreKeylessEnvelope, + DsseStaticJwksEnvelope, + FederationTrustContext, + FetchProvenance, + PublishedArtifact, + PublishReceipt, + PulledArtifact, + Receipt, + Rejected, + RejectionReason, + SignatureEnvelope, + SignedOffBy, + StageResult, + UnverifiabilityReason, + Unverifiable, + VerificationOutcome, + Verified, + envelope_signing_version, + is_envelope_kind, + stage_results_outcome_counts, +) + + +def _envelope() -> SignatureEnvelope: + return DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"opaque") + + +def _published_artifact() -> PublishedArtifact: + return PublishedArtifact( + content_hash=b"\x01" * 32, + canonical_bytes=b"DSSEv1 ...", + payload_type="application/vnd.cora.test-event+json", + signature_envelope=_envelope(), + source_facility_id=UUID("00000000-0000-0000-0000-00000000aaaa"), + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),), + schema_version=1, + canonicalization_version="cora/v1", + ) + + +def test_credential_ref_carries_credential_id_and_is_frozen() -> None: + cid = uuid4() + ref = CredentialRef(credential_id=cid) + assert ref.credential_id == cid + with pytest.raises(AttributeError): + ref.credential_id = uuid4() # type: ignore[misc] + + +def test_receipt_carries_kind_and_opaque_bytes() -> None: + r = Receipt(kind="scitt", bytes_=b"opaque") + assert r.kind == "scitt" + assert r.bytes_ == b"opaque" + + +def test_dsse_static_jwks_envelope_carries_locked_kind_discriminator() -> None: + env = DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"x") + assert env.kind == "dsse_static_jwks" + assert env.signing_version == "cora/v1" + assert env.receipts == () + + +def test_dsse_sigstore_keyless_envelope_carries_locked_kind_discriminator() -> None: + env = DsseSigstoreKeylessEnvelope(signing_version="cora/v1", payload_bytes=b"x") + assert env.kind == "dsse_sigstore_keyless" + + +def test_cose_sign1_scitt_envelope_carries_locked_kind_discriminator() -> None: + env = CoseSign1ScittEnvelope(signing_version="cora/v2-cose", payload_bytes=b"x") + assert env.kind == "cose_sign1_scitt" + + +def test_signature_envelope_union_accepts_all_three_arms() -> None: + envs: list[SignatureEnvelope] = [ + DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b""), + DsseSigstoreKeylessEnvelope(signing_version="cora/v1", payload_bytes=b""), + CoseSign1ScittEnvelope(signing_version="cora/v2-cose", payload_bytes=b""), + ] + kinds = {e.kind for e in envs} + assert kinds == {"dsse_static_jwks", "dsse_sigstore_keyless", "cose_sign1_scitt"} + + +def test_signed_off_by_requires_actor_id_and_signed_at() -> None: + s = SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)) + assert s.actor_id is not None + + +def test_assisted_by_carries_model_ref_and_citation() -> None: + a = AssistedBy( + agent_id=uuid4(), + model_ref="claude-opus-4-7", + assisted_at=datetime(2026, 5, 31, tzinfo=UTC), + citation="decision-abc-123", + ) + assert a.model_ref == "claude-opus-4-7" + + +def test_co_developed_by_carries_two_actor_ids() -> None: + a, b = uuid4(), uuid4() + c = CoDevelopedBy(actor_id_a=a, actor_id_b=b, co_developed_at=datetime(2026, 5, 31, tzinfo=UTC)) + assert c.actor_id_a == a + assert c.actor_id_b == b + + +def test_dco_entry_union_accepts_all_three_arms() -> None: + entries: list[DcoEntry] = [ + SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)), + AssistedBy( + agent_id=uuid4(), + model_ref="m", + assisted_at=datetime(2026, 5, 31, tzinfo=UTC), + citation="c", + ), + CoDevelopedBy( + actor_id_a=uuid4(), + actor_id_b=uuid4(), + co_developed_at=datetime(2026, 5, 31, tzinfo=UTC), + ), + ] + assert len(entries) == 3 + + +def test_published_artifact_carries_locked_fields() -> None: + a = _published_artifact() + assert a.canonicalization_version == "cora/v1" + assert a.abi_tier == "Stable" + assert a.schema_version == 1 + + +def test_artifact_reference_security_equality_is_on_hash_and_payload_type() -> None: + a = ArtifactReference( + content_hash=b"\x01" * 32, + payload_type="application/vnd.cora.test+json", + source_facility_id=uuid4(), + hint_locator="https://peer/x", + ) + b = ArtifactReference( + content_hash=b"\x01" * 32, + payload_type="application/vnd.cora.test+json", + source_facility_id=a.source_facility_id, + hint_locator="ipfs://different", + ) + assert a.content_hash == b.content_hash + assert a.payload_type == b.payload_type + + +def test_publish_receipt_carries_opaque_bytes_and_hints() -> None: + r = PublishReceipt( + receipt_bytes=b"opaque", + receipt_format_hint="rekor-bundle/v1", + transparency_log_hint="rekor:prod", + recorded_at=datetime(2026, 5, 31, tzinfo=UTC), + ) + assert r.receipt_bytes == b"opaque" + + +def test_pulled_artifact_carries_both_artifact_and_fetch_provenance_fields() -> None: + p = PulledArtifact( + artifact=_published_artifact(), + fetch_provenance=FetchProvenance( + locator_used="https://peer/x", + wire_content_type="application/dsse+json", + fetch_duration_ms=42, + byte_count=1024, + ), + ) + assert p.fetch_provenance.byte_count == 1024 + + +def test_federation_trust_context_accept_yanked_is_structurally_false() -> None: + ctx = FederationTrustContext( + permit_id=uuid4(), + allowed_credentials=frozenset({CredentialRef(credential_id=uuid4())}), + allowed_payload_types=frozenset({"application/vnd.cora.test+json"}), + abi_tier_floor="Stable", + ) + assert ctx.accept_yanked is False + assert ctx.required_receipt_kinds == frozenset() + + +def test_federation_trust_context_required_receipt_kinds_accepts_known_kinds() -> None: + ctx = FederationTrustContext( + permit_id=uuid4(), + allowed_credentials=frozenset(), + allowed_payload_types=frozenset(), + abi_tier_floor="Stable", + required_receipt_kinds=frozenset({"scitt", "rekor_sct"}), + ) + assert ctx.required_receipt_kinds == frozenset({"scitt", "rekor_sct"}) + + +def test_verified_outcome_carries_stage_results_tuple() -> None: + v: VerificationOutcome = Verified( + stage_results=(StageResult(stage="content_hash", outcome="pass"),) + ) + assert isinstance(v, Verified) + assert v.stage_results[0].outcome == "pass" + + +def test_rejected_outcome_carries_failed_stage_in_rejection_reason() -> None: + r: VerificationOutcome = Rejected( + stage_results=(StageResult(stage="signature", outcome="fail", detail="Ed25519 rejected"),), + rejection=RejectionReason(failed_stage="signature", reason="Ed25519 verify returned False"), + ) + assert isinstance(r, Rejected) + assert r.rejection.failed_stage == "signature" + + +def test_unverifiable_outcome_distinct_from_rejected() -> None: + u: VerificationOutcome = Unverifiable( + stage_results=( + StageResult(stage="key_resolution", outcome="skip", detail="JWKS rotation in flight"), + ), + unverifiability=UnverifiabilityReason( + failed_stage="key_resolution", reason="JWKS not reachable" + ), + ) + assert isinstance(u, Unverifiable) + assert not isinstance(u, Rejected) + + +def test_is_envelope_kind_helper_matches_arm() -> None: + env = _envelope() + assert is_envelope_kind(env, "dsse_static_jwks") is True + assert is_envelope_kind(env, "cose_sign1_scitt") is False + + +def test_envelope_signing_version_helper_works_across_arms() -> None: + for arm_class, sv in ( + (DsseStaticJwksEnvelope, "cora/v1"), + (DsseSigstoreKeylessEnvelope, "cora/v1"), + (CoseSign1ScittEnvelope, "cora/v2-cose"), + ): + env = arm_class(signing_version=sv, payload_bytes=b"") + assert envelope_signing_version(env) == sv + + +def test_stage_results_outcome_counts_returns_counts_dict() -> None: + results = ( + StageResult(stage="content_hash", outcome="pass"), + StageResult(stage="signature", outcome="pass"), + StageResult(stage="dco_chain", outcome="skip"), + ) + counts = stage_results_outcome_counts(results) + assert counts == {"pass": 2, "fail": 0, "skip": 1} diff --git a/apps/api/tests/unit/infrastructure/published_artifact/test_orchestrator.py b/apps/api/tests/unit/infrastructure/published_artifact/test_orchestrator.py new file mode 100644 index 000000000..cab60341b --- /dev/null +++ b/apps/api/tests/unit/infrastructure/published_artifact/test_orchestrator.py @@ -0,0 +1,245 @@ +"""Unit tests for the verify-then-apply orchestrator.""" + +import hashlib +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from cora.federation.adapters.in_memory_signature_port import InMemorySignaturePort +from cora.infrastructure.adapters.canonicalization_registry import ( + CanonicalizationRegistry, +) +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) +from cora.infrastructure.ports.federation.value_types import ( + AssistedBy, + DsseStaticJwksEnvelope, + FederationTrustContext, + PublishedArtifact, + Rejected, + SignedOffBy, + UnverifiabilityReason, + Unverifiable, + Verified, +) +from cora.infrastructure.published_artifact import verify_then_apply + +_NOW = datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC) + + +def _registry() -> CanonicalizationRegistry: + r = CanonicalizationRegistry() + r.register("cora/v1", DefaultCanonicalizationAdapter()) + r.set_default("cora/v1") + return r + + +def _trust_context() -> FederationTrustContext: + return FederationTrustContext( + permit_id=uuid4(), + allowed_credentials=frozenset(), + allowed_payload_types=frozenset({"application/vnd.cora.test+json"}), + abi_tier_floor="Stable", + ) + + +def _artifact( + *, + canonical_bytes: bytes = b"DSSEv1 ...", + abi_tier: str = "Stable", + payload_type: str = "application/vnd.cora.test+json", + expires_at: datetime | None = None, + canonicalization_version: str = "cora/v1", + dco_chain: tuple[object, ...] | None = None, +) -> PublishedArtifact: + if dco_chain is None: + dco_chain = (SignedOffBy(actor_id=uuid4(), signed_at=_NOW),) + return PublishedArtifact( + content_hash=hashlib.sha256(canonical_bytes).digest(), + canonical_bytes=canonical_bytes, + payload_type=payload_type, + signature_envelope=DsseStaticJwksEnvelope( + signing_version=canonicalization_version, payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=_NOW, + expires_at=expires_at, + abi_tier=abi_tier, + dco_chain=dco_chain, # type: ignore[arg-type] + schema_version=1, + canonicalization_version=canonicalization_version, + ) + + +@pytest.mark.asyncio +async def test_verify_then_apply_returns_verified_for_well_formed_artifact() -> None: + outcome = await verify_then_apply( + _artifact(), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Verified) + assert any(r.stage == "content_hash" and r.outcome == "pass" for r in outcome.stage_results) + assert any(r.stage == "abi_tier" and r.outcome == "pass" for r in outcome.stage_results) + assert any(r.stage == "dco_chain" and r.outcome == "pass" for r in outcome.stage_results) + + +@pytest.mark.asyncio +async def test_verify_then_apply_short_circuits_rejected_on_untrusted_payload_type() -> None: + outcome = await verify_then_apply( + _artifact(payload_type="application/vnd.cora.unknown+json"), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "payload_type_trusted" + + +@pytest.mark.asyncio +async def test_verify_then_apply_returns_unverifiable_when_canon_version_unregistered() -> None: + outcome = await verify_then_apply( + _artifact(canonicalization_version="cora/v99-unknown"), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Unverifiable) + assert outcome.unverifiability.failed_stage == "content_hash" + + +@pytest.mark.asyncio +async def test_verify_then_apply_rejects_on_content_hash_mismatch() -> None: + canonical_bytes = b"DSSEv1 original" + artifact = PublishedArtifact( + content_hash=b"\xff" * 32, + canonical_bytes=canonical_bytes, + payload_type="application/vnd.cora.test+json", + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=_NOW, + expires_at=None, + abi_tier="Stable", + dco_chain=(SignedOffBy(actor_id=uuid4(), signed_at=_NOW),), + schema_version=1, + canonicalization_version="cora/v1", + ) + outcome = await verify_then_apply( + artifact, + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "content_hash" + + +@pytest.mark.asyncio +async def test_verify_then_apply_propagates_rejected_from_signature_port() -> None: + signature_port = InMemorySignaturePort() + artifact = _artifact() + signature_port.simulate_signature_invalid(artifact.content_hash, failed_stage="signature") + outcome = await verify_then_apply( + artifact, + trust_context=_trust_context(), + signature_port=signature_port, + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "signature" + + +@pytest.mark.asyncio +async def test_verify_then_apply_propagates_unverifiable_from_signature_port() -> None: + signature_port = InMemorySignaturePort() + artifact = _artifact() + signature_port.set_verification_outcome( + artifact.content_hash, + Unverifiable( + stage_results=(), + unverifiability=UnverifiabilityReason( + failed_stage="key_resolution", reason="JWKS unavailable" + ), + ), + ) + outcome = await verify_then_apply( + artifact, + trust_context=_trust_context(), + signature_port=signature_port, + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Unverifiable) + assert outcome.unverifiability.failed_stage == "key_resolution" + + +@pytest.mark.asyncio +async def test_verify_then_apply_rejects_when_abi_tier_below_floor() -> None: + outcome = await verify_then_apply( + _artifact(abi_tier="Testing"), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "abi_tier" + + +@pytest.mark.asyncio +async def test_verify_then_apply_rejects_when_artifact_is_expired() -> None: + outcome = await verify_then_apply( + _artifact(expires_at=datetime(2026, 1, 1, tzinfo=UTC)), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "expires_at" + + +@pytest.mark.asyncio +async def test_verify_then_apply_rejects_on_ai_only_dco_chain() -> None: + outcome = await verify_then_apply( + _artifact( + dco_chain=( + AssistedBy( + agent_id=uuid4(), + model_ref="m", + assisted_at=_NOW, + citation="x", + ), + ) + ), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Rejected) + assert outcome.rejection.failed_stage == "dco_chain" + + +@pytest.mark.asyncio +async def test_verify_then_apply_records_deferred_stages_as_skip_with_reason() -> None: + outcome = await verify_then_apply( + _artifact(), + trust_context=_trust_context(), + signature_port=InMemorySignaturePort(), + canonicalization_registry=_registry(), + now=_NOW, + ) + assert isinstance(outcome, Verified) + deferred_stages = {r.stage for r in outcome.stage_results if r.outcome == "skip"} + assert {"payload_type_known", "head_pointer_fresh", "replay_cache"}.issubset(deferred_stages) diff --git a/apps/api/tests/unit/infrastructure/published_artifact/test_stages.py b/apps/api/tests/unit/infrastructure/published_artifact/test_stages.py new file mode 100644 index 000000000..f5742b2b3 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/published_artifact/test_stages.py @@ -0,0 +1,268 @@ +"""Unit tests for individual verifier stage helpers.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) +from cora.infrastructure.ports.federation.value_types import ( + AssistedBy, + CoseSign1ScittEnvelope, + DsseStaticJwksEnvelope, + FederationTrustContext, + PublishedArtifact, + Receipt, + SignedOffBy, +) +from cora.infrastructure.published_artifact._stages import ( + check_abi_tier, + check_content_hash, + check_dco_chain, + check_expires_at, + check_payload_type_trusted, + check_required_receipts_present, + dco_chain_has_human_actor, + deferred_stage, + is_terminal_publication_status, +) + + +def _trust_context( + *, + allowed_payload_types: frozenset[str] | None = None, + abi_tier_floor: str = "Stable", + required_receipt_kinds: frozenset[str] | None = None, +) -> FederationTrustContext: + return FederationTrustContext( + permit_id=uuid4(), + allowed_credentials=frozenset(), + allowed_payload_types=( + allowed_payload_types + if allowed_payload_types is not None + else frozenset({"application/vnd.cora.test+json"}) + ), + abi_tier_floor=abi_tier_floor, + required_receipt_kinds=( # type: ignore[arg-type] + required_receipt_kinds if required_receipt_kinds is not None else frozenset() + ), + ) + + +def _artifact( + *, + payload_type: str = "application/vnd.cora.test+json", + abi_tier: str = "Stable", + expires_at: datetime | None = None, + canonical_bytes: bytes = b"", + content_hash: bytes | None = None, + canonicalization_version: str = "cora/v1", + dco_chain: tuple[object, ...] | None = None, +) -> PublishedArtifact: + if dco_chain is None: + dco_chain = (SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),) + return PublishedArtifact( + content_hash=content_hash if content_hash is not None else b"\x00" * 32, + canonical_bytes=canonical_bytes, + payload_type=payload_type, + signature_envelope=DsseStaticJwksEnvelope( + signing_version="cora/v1", payload_bytes=b"opaque" + ), + source_facility_id=uuid4(), + published_at=datetime(2026, 5, 31, tzinfo=UTC), + expires_at=expires_at, + abi_tier=abi_tier, + dco_chain=dco_chain, # type: ignore[arg-type] + schema_version=1, + canonicalization_version=canonicalization_version, + ) + + +def test_check_payload_type_trusted_passes_when_payload_type_in_allowlist() -> None: + result = check_payload_type_trusted(_artifact(), _trust_context()) + assert result.outcome == "pass" + + +def test_check_payload_type_trusted_fails_when_payload_type_outside_allowlist() -> None: + result = check_payload_type_trusted( + _artifact(payload_type="application/vnd.cora.other+json"), _trust_context() + ) + assert result.outcome == "fail" + assert "not in trust context" in result.detail + + +def test_check_payload_type_trusted_fails_when_allowlist_is_empty() -> None: + result = check_payload_type_trusted( + _artifact(), _trust_context(allowed_payload_types=frozenset()) + ) + assert result.outcome == "fail" + assert "empty" in result.detail + + +def test_check_content_hash_passes_when_recomputed_hex_matches_claimed() -> None: + canonical_bytes = b"DSSEv1 ..." + import hashlib + + h = hashlib.sha256(canonical_bytes).digest() + artifact = _artifact(canonical_bytes=canonical_bytes, content_hash=h) + result = check_content_hash(artifact, DefaultCanonicalizationAdapter()) + assert result.outcome == "pass" + + +def test_check_content_hash_fails_on_drift_between_canonical_bytes_and_claimed_hash() -> None: + artifact = _artifact(canonical_bytes=b"DSSEv1 ...", content_hash=b"\xff" * 32) + result = check_content_hash(artifact, DefaultCanonicalizationAdapter()) + assert result.outcome == "fail" + assert "claimed" in result.detail + + +def test_check_content_hash_skips_when_canonical_bytes_empty() -> None: + artifact = _artifact(canonical_bytes=b"") + result = check_content_hash(artifact, DefaultCanonicalizationAdapter()) + assert result.outcome == "skip" + + +def test_check_content_hash_skips_when_adapter_version_differs_from_artifact() -> None: + artifact = _artifact(canonical_bytes=b"DSSEv1 ...", canonicalization_version="cora/v2-cose") + result = check_content_hash(artifact, DefaultCanonicalizationAdapter()) + assert result.outcome == "skip" + assert "does not match" in result.detail + + +def test_check_required_receipts_present_skips_when_no_kinds_required() -> None: + envelope = DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"") + result = check_required_receipts_present(envelope, _trust_context()) + assert result.outcome == "skip" + + +def test_check_required_receipts_present_passes_when_all_required_kinds_present() -> None: + envelope = CoseSign1ScittEnvelope( + signing_version="cora/v2-cose", + payload_bytes=b"", + receipts=(Receipt(kind="scitt", bytes_=b"x"),), + ) + result = check_required_receipts_present( + envelope, + _trust_context(required_receipt_kinds=frozenset({"scitt"})), # type: ignore[arg-type] + ) + assert result.outcome == "pass" + + +def test_check_required_receipts_present_fails_when_required_kind_missing() -> None: + envelope = DsseStaticJwksEnvelope(signing_version="cora/v1", payload_bytes=b"") + result = check_required_receipts_present( + envelope, + _trust_context(required_receipt_kinds=frozenset({"scitt"})), # type: ignore[arg-type] + ) + assert result.outcome == "fail" + assert "missing" in result.detail + + +def test_check_abi_tier_passes_when_artifact_tier_equals_floor() -> None: + result = check_abi_tier(_artifact(abi_tier="Stable"), _trust_context(abi_tier_floor="Stable")) + assert result.outcome == "pass" + + +def test_check_abi_tier_passes_when_artifact_tier_above_floor() -> None: + result = check_abi_tier(_artifact(abi_tier="Obsolete"), _trust_context(abi_tier_floor="Stable")) + assert result.outcome == "pass" + + +def test_check_abi_tier_fails_when_artifact_tier_below_floor() -> None: + result = check_abi_tier(_artifact(abi_tier="Testing"), _trust_context(abi_tier_floor="Stable")) + assert result.outcome == "fail" + assert "below trust" in result.detail + + +def test_check_abi_tier_always_fails_when_artifact_tier_is_removed() -> None: + result = check_abi_tier(_artifact(abi_tier="Removed"), _trust_context(abi_tier_floor="Removed")) + assert result.outcome == "fail" + assert "withdrawn" in result.detail + + +def test_check_abi_tier_skips_on_unrecognized_tier_string() -> None: + result = check_abi_tier(_artifact(abi_tier="Bogus"), _trust_context(abi_tier_floor="Stable")) + assert result.outcome == "skip" + + +def test_check_expires_at_passes_when_artifact_has_no_expiry() -> None: + result = check_expires_at(_artifact(expires_at=None), now=datetime(2026, 5, 31, tzinfo=UTC)) + assert result.outcome == "pass" + + +def test_check_expires_at_passes_when_now_before_expires_at() -> None: + result = check_expires_at( + _artifact(expires_at=datetime(2027, 1, 1, tzinfo=UTC)), + now=datetime(2026, 5, 31, tzinfo=UTC), + ) + assert result.outcome == "pass" + + +def test_check_expires_at_fails_when_now_at_or_after_expires_at() -> None: + result = check_expires_at( + _artifact(expires_at=datetime(2026, 1, 1, tzinfo=UTC)), + now=datetime(2026, 5, 31, tzinfo=UTC), + ) + assert result.outcome == "fail" + assert "expired" in result.detail + + +def test_check_dco_chain_passes_when_signed_off_by_present() -> None: + artifact = _artifact() + result = check_dco_chain(artifact) + assert result.outcome == "pass" + + +def test_check_dco_chain_fails_when_chain_is_empty() -> None: + artifact = _artifact(dco_chain=()) + result = check_dco_chain(artifact) + assert result.outcome == "fail" + assert "empty" in result.detail + + +def test_check_dco_chain_fails_when_only_ai_entries_present_no_human_signoff() -> None: + artifact = _artifact( + dco_chain=( + AssistedBy( + agent_id=uuid4(), + model_ref="claude-opus-4-7", + assisted_at=datetime(2026, 5, 31, tzinfo=UTC), + citation="x", + ), + ) + ) + result = check_dco_chain(artifact) + assert result.outcome == "fail" + assert "AI-only" in result.detail + + +def test_deferred_stage_returns_skip_outcome_with_reason_detail() -> None: + result = deferred_stage("payload_type_known", "registry not wired yet") + assert result.outcome == "skip" + assert result.detail == "registry not wired yet" + + +def test_dco_chain_has_human_actor_detects_signed_off_by_entry() -> None: + chain = (SignedOffBy(actor_id=uuid4(), signed_at=datetime(2026, 5, 31, tzinfo=UTC)),) + assert dco_chain_has_human_actor(chain) is True # type: ignore[arg-type] + + +def test_dco_chain_has_human_actor_returns_false_for_ai_only_chain() -> None: + chain = ( + AssistedBy( + agent_id=uuid4(), + model_ref="m", + assisted_at=datetime(2026, 5, 31, tzinfo=UTC), + citation="x", + ), + ) + assert dco_chain_has_human_actor(chain) is False # type: ignore[arg-type] + + +def test_is_terminal_publication_status_returns_true_for_each_terminal_value() -> None: + for status in ("Yanked", "Withdrawn", "Expired", "AbiTierObsoleteOrRemoved"): + assert is_terminal_publication_status(status) is True # type: ignore[arg-type] + + +def test_is_terminal_publication_status_returns_false_for_live_status() -> None: + assert is_terminal_publication_status("Live") is False # type: ignore[arg-type] diff --git a/apps/api/tests/unit/infrastructure/test_canonicalization_port.py b/apps/api/tests/unit/infrastructure/test_canonicalization_port.py new file mode 100644 index 000000000..ab56f768a --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_canonicalization_port.py @@ -0,0 +1,90 @@ +"""Unit tests for the CanonicalizationPort Protocol surface and value types.""" + +from typing import Any + +import pytest + +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationFailedError, + CanonicalizationPort, + CanonicalizedBytes, + ContentHashMismatchError, + UnsupportedCanonicalizationVersionError, +) + + +class _FakeCanonicalizationAdapter: + """Minimal duck-typed conformer for runtime_checkable Protocol assertions.""" + + adapter_version = "fake/v0" + + def canonicalize(self, payload_type: str, payload: Any) -> CanonicalizedBytes: + return CanonicalizedBytes( + bytes_=b"", + adapter_version=self.adapter_version, + payload_type=payload_type, + ) + + def verify_content_hash(self, payload_type: str, payload: Any, claimed_hash: str) -> bool: + return False + + +def test_canonicalization_port_is_runtime_checkable_against_duck_typed_adapter() -> None: + assert isinstance(_FakeCanonicalizationAdapter(), CanonicalizationPort) + + +def test_canonicalized_bytes_is_frozen_dataclass_with_mandatory_adapter_version() -> None: + value = CanonicalizedBytes( + bytes_=b"abc", + adapter_version="cora/v1", + payload_type="application/vnd.cora.test-event+json", + ) + assert value.bytes_ == b"abc" + assert value.adapter_version == "cora/v1" + assert value.payload_type == "application/vnd.cora.test-event+json" + with pytest.raises(AttributeError): + value.bytes_ = b"tampered" # type: ignore[misc] + + +def test_canonicalized_bytes_construction_without_adapter_version_raises_type_error() -> None: + with pytest.raises(TypeError): + CanonicalizedBytes( # type: ignore[call-arg] + bytes_=b"abc", + payload_type="application/vnd.cora.test-event+json", + ) + + +def test_canonicalization_failed_error_carries_payload_type_adapter_version_reason() -> None: + error = CanonicalizationFailedError( + payload_type="application/vnd.cora.bad+json", + adapter_version="cora/v1", + reason="Decimal field encountered", + ) + assert error.payload_type == "application/vnd.cora.bad+json" + assert error.adapter_version == "cora/v1" + assert error.reason == "Decimal field encountered" + assert "application/vnd.cora.bad+json" in str(error) + assert "Decimal" in str(error) + + +def test_content_hash_mismatch_error_carries_claimed_and_recomputed_hashes() -> None: + error = ContentHashMismatchError( + payload_type="application/vnd.cora.test-event+json", + claimed_hash="a" * 64, + recomputed_hash="b" * 64, + adapter_version="cora/v1", + ) + assert error.claimed_hash == "a" * 64 + assert error.recomputed_hash == "b" * 64 + assert error.payload_type == "application/vnd.cora.test-event+json" + assert error.adapter_version == "cora/v1" + + +def test_unsupported_canonicalization_version_error_carries_registered_set_tuple() -> None: + error = UnsupportedCanonicalizationVersionError( + requested_version="cora/v99", + registered_versions=["cora/v1", "cora/v2-cose"], + ) + assert error.requested_version == "cora/v99" + assert error.registered_versions == ("cora/v1", "cora/v2-cose") + assert "cora/v99" in str(error) diff --git a/apps/api/tests/unit/infrastructure/test_canonicalization_registry.py b/apps/api/tests/unit/infrastructure/test_canonicalization_registry.py new file mode 100644 index 000000000..9e591f931 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_canonicalization_registry.py @@ -0,0 +1,107 @@ +"""Unit tests for CanonicalizationRegistry.""" + +import pytest + +from cora.infrastructure.adapters.canonicalization_registry import ( + CanonicalizationRegistry, +) +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationPort, + CanonicalizedBytes, + UnsupportedCanonicalizationVersionError, +) + + +class _FakeAdapter: + def __init__(self, version: str = "fake/v0") -> None: + self._version = version + + @property + def adapter_version(self) -> str: + return self._version + + def canonicalize(self, payload_type: str, payload: object) -> CanonicalizedBytes: + raise NotImplementedError + + def verify_content_hash(self, payload_type: str, payload: object, claimed_hash: str) -> bool: + raise NotImplementedError + + +def test_canonicalization_registry_register_and_resolve_returns_adapter() -> None: + registry = CanonicalizationRegistry() + adapter = DefaultCanonicalizationAdapter() + registry.register("cora/v1", adapter) + resolved = registry.resolve("cora/v1") + assert resolved is adapter + assert isinstance(resolved, CanonicalizationPort) + + +def test_canonicalization_registry_resolve_unregistered_raises_with_known_set() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + with pytest.raises(UnsupportedCanonicalizationVersionError) as exc_info: + registry.resolve("cora/v99") + assert exc_info.value.requested_version == "cora/v99" + assert "cora/v1" in exc_info.value.registered_versions + + +def test_canonicalization_registry_duplicate_register_raises_value_error() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + with pytest.raises(ValueError, match="already registered"): + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + + +def test_canonicalization_registry_set_default_requires_registered_version() -> None: + registry = CanonicalizationRegistry() + with pytest.raises(UnsupportedCanonicalizationVersionError): + registry.set_default("cora/v1") + + +def test_canonicalization_registry_default_version_returns_set_value() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + registry.set_default("cora/v1") + assert registry.default_version() == "cora/v1" + + +def test_canonicalization_registry_default_version_unset_raises() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", DefaultCanonicalizationAdapter()) + with pytest.raises(UnsupportedCanonicalizationVersionError): + registry.default_version() + + +def test_canonicalization_registry_registered_versions_preserves_registration_order() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", _FakeAdapter("cora/v1")) + registry.register("cora/v2-cose", _FakeAdapter("cora/v2-cose")) + assert registry.registered_versions() == ("cora/v1", "cora/v2-cose") + + +@pytest.mark.asyncio +async def test_canonicalization_registry_aclose_is_idempotent() -> None: + registry = CanonicalizationRegistry() + await registry.aclose() + await registry.aclose() + + +@pytest.mark.asyncio +async def test_canonicalization_registry_aclose_suppresses_adapter_exceptions() -> None: + class _FlakyAdapter(_FakeAdapter): + async def aclose(self) -> None: + raise RuntimeError("flaky teardown") + + registry = CanonicalizationRegistry() + registry.register("cora/v1", _FlakyAdapter("cora/v1")) + await registry.aclose() + + +@pytest.mark.asyncio +async def test_canonicalization_registry_aclose_skips_adapters_without_aclose() -> None: + registry = CanonicalizationRegistry() + registry.register("cora/v1", _FakeAdapter("cora/v1")) + await registry.aclose() diff --git a/apps/api/tests/unit/infrastructure/test_default_canonicalization_adapter.py b/apps/api/tests/unit/infrastructure/test_default_canonicalization_adapter.py new file mode 100644 index 000000000..8cc4b7b08 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_default_canonicalization_adapter.py @@ -0,0 +1,73 @@ +"""Unit tests for DefaultCanonicalizationAdapter.""" + +from cora.infrastructure.adapters.default_canonicalization_adapter import ( + DefaultCanonicalizationAdapter, +) +from cora.infrastructure.content_hash import compute_content_hash +from cora.infrastructure.ports.canonicalization import ( + CanonicalizationFailedError, + CanonicalizedBytes, +) + + +def test_default_canonicalization_adapter_version_is_cora_v1() -> None: + adapter = DefaultCanonicalizationAdapter() + assert adapter.adapter_version == "cora/v1" + + +def test_canonicalize_returns_canonicalized_bytes_with_locked_version() -> None: + adapter = DefaultCanonicalizationAdapter() + out = adapter.canonicalize( + "application/vnd.cora.test-event+json", {"name": "test", "value": 42} + ) + assert isinstance(out, CanonicalizedBytes) + assert out.adapter_version == "cora/v1" + assert out.payload_type == "application/vnd.cora.test-event+json" + assert out.bytes_.startswith(b"DSSEv1 ") + + +def test_canonicalize_byte_for_byte_matches_shipped_helper_pae_wrap() -> None: + adapter = DefaultCanonicalizationAdapter() + payload_type = "application/vnd.cora.test-event+json" + payload = {"name": "test", "value": 42} + via_adapter = adapter.canonicalize(payload_type, payload).bytes_ + expected_hash = compute_content_hash(payload_type, payload) + import hashlib + + assert hashlib.sha256(via_adapter).hexdigest() == expected_hash + + +def test_canonicalize_rejects_payload_type_outside_v1_uri_scheme() -> None: + adapter = DefaultCanonicalizationAdapter() + try: + adapter.canonicalize("application/json", {"x": 1}) + except CanonicalizationFailedError as exc: + assert exc.payload_type == "application/json" + assert exc.adapter_version == "cora/v1" + else: + raise AssertionError("expected CanonicalizationFailedError") + + +def test_verify_content_hash_returns_true_on_matching_recomputation() -> None: + adapter = DefaultCanonicalizationAdapter() + payload_type = "application/vnd.cora.test-event+json" + payload = {"name": "test", "value": 42} + claimed = compute_content_hash(payload_type, payload) + assert adapter.verify_content_hash(payload_type, payload, claimed) is True + + +def test_verify_content_hash_returns_false_on_mismatched_claimed_hash() -> None: + adapter = DefaultCanonicalizationAdapter() + payload_type = "application/vnd.cora.test-event+json" + payload = {"name": "test", "value": 42} + assert adapter.verify_content_hash(payload_type, payload, "0" * 64) is False + + +def test_verify_content_hash_rejects_payload_type_outside_v1_uri_scheme() -> None: + adapter = DefaultCanonicalizationAdapter() + try: + adapter.verify_content_hash("application/json", {"x": 1}, "0" * 64) + except CanonicalizationFailedError as exc: + assert exc.adapter_version == "cora/v1" + else: + raise AssertionError("expected CanonicalizationFailedError") diff --git a/apps/api/tests/unit/infrastructure/test_default_signing_adapter.py b/apps/api/tests/unit/infrastructure/test_default_signing_adapter.py new file mode 100644 index 000000000..4de4e56fa --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_default_signing_adapter.py @@ -0,0 +1,212 @@ +"""Unit tests for DefaultSigningAdapter.""" + +from datetime import UTC, datetime + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) + +from cora.infrastructure.adapters.default_signing_adapter import ( + DefaultSigningAdapter, + JwksKid, +) +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.signing import ( + CanonicalizationVersionMismatchError, + Signature, + SigningKeyNotFoundError, + SigningTrustContext, +) + + +def _fixed_clock() -> datetime: + return datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC) + + +def _build_adapter( + private_keys: dict[str, bytes], public_keys: dict[str, bytes] +) -> DefaultSigningAdapter: + async def loader(handle: JwksKid) -> bytes: + return private_keys[handle.kid] + + async def resolver(kid: str) -> bytes: + return public_keys[kid] + + return DefaultSigningAdapter( + private_key_loader=loader, + public_key_resolver=resolver, + clock=_fixed_clock, + ) + + +def _keypair() -> tuple[bytes, bytes]: + private = Ed25519PrivateKey.generate() + private_bytes = private.private_bytes( + encoding=Encoding.Raw, format=PrivateFormat.Raw, encryption_algorithm=NoEncryption() + ) + public_bytes = private.public_key().public_bytes(encoding=Encoding.Raw, format=PublicFormat.Raw) + return private_bytes, public_bytes + + +def _canon(bytes_: bytes = b"DSSEv1 0 0 ") -> CanonicalizedBytes: + return CanonicalizedBytes( + bytes_=bytes_, + adapter_version="cora/v1", + payload_type="application/vnd.cora.test-event+json", + ) + + +def test_default_signing_adapter_version_is_cora_v1() -> None: + adapter = _build_adapter({}, {}) + assert adapter.adapter_version == "cora/v1" + + +def test_jwks_kid_is_frozen_dataclass_with_kid_field() -> None: + handle = JwksKid(kid="some-kid") + assert handle.kid == "some-kid" + with pytest.raises(AttributeError): + handle.kid = "tampered" # type: ignore[misc] + + +def test_jwks_kid_is_hashable_for_trust_context_membership() -> None: + a = JwksKid(kid="kid-1") + b = JwksKid(kid="kid-1") + s: frozenset[JwksKid] = frozenset({a}) + assert b in s + + +@pytest.mark.asyncio +async def test_sign_returns_signature_with_locked_version_and_clock() -> None: + priv, _ = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {}) + canon = _canon(b"DSSEv1 35 application/vnd.cora.test-event+json 2 {}") + sig = await adapter.sign(canon, handle) + assert isinstance(sig, Signature) + assert sig.adapter_version == "cora/v1" + assert sig.key_handle == handle + assert sig.signed_at == _fixed_clock() + assert len(sig.bytes_) == 64 + + +@pytest.mark.asyncio +async def test_sign_rejects_canonicalized_bytes_from_non_v1_adapter() -> None: + priv, _ = _keypair() + adapter = _build_adapter({"kid-A": priv}, {}) + canon = CanonicalizedBytes( + bytes_=b"future", + adapter_version="cora/v2-cose", + payload_type="application/vnd.cora.test-event+json", + ) + with pytest.raises(CanonicalizationVersionMismatchError) as exc_info: + await adapter.sign(canon, JwksKid(kid="kid-A")) + assert exc_info.value.canonicalized_version == "cora/v2-cose" + assert exc_info.value.signing_version == "cora/v1" + + +@pytest.mark.asyncio +async def test_sign_raises_signing_key_not_found_when_loader_misses() -> None: + adapter = _build_adapter({}, {}) + with pytest.raises(SigningKeyNotFoundError) as exc_info: + await adapter.sign(_canon(), JwksKid(kid="lost-kid")) + assert exc_info.value.adapter_version == "cora/v1" + + +@pytest.mark.asyncio +async def test_verify_returns_valid_for_matching_signature() -> None: + priv, pub = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {"kid-A": pub}) + canon = _canon(b"DSSEv1 35 application/vnd.cora.test-event+json 2 {}") + sig = await adapter.sign(canon, handle) + trust = SigningTrustContext( + trusted_signing_keys=frozenset({handle}), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type=canon.payload_type, + validity_window=None, + ) + verdict = await adapter.verify(canon, sig, trust) + assert verdict.verdict == "Valid" + + +@pytest.mark.asyncio +async def test_verify_returns_invalid_on_tampered_bytes() -> None: + priv, pub = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {"kid-A": pub}) + canon = _canon(b"DSSEv1 35 application/vnd.cora.test-event+json 2 {}") + sig = await adapter.sign(canon, handle) + tampered = _canon(b"DSSEv1 35 application/vnd.cora.test-event+json 2 XX") + trust = SigningTrustContext( + trusted_signing_keys=frozenset({handle}), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type=canon.payload_type, + validity_window=None, + ) + verdict = await adapter.verify(tampered, sig, trust) + assert verdict.verdict == "Invalid" + assert "Ed25519" in verdict.detail + + +@pytest.mark.asyncio +async def test_verify_returns_unverifiable_when_key_not_in_trust_context() -> None: + priv, pub = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {"kid-A": pub}) + canon = _canon() + sig = await adapter.sign(canon, handle) + trust = SigningTrustContext( + trusted_signing_keys=frozenset(), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type=canon.payload_type, + validity_window=None, + ) + verdict = await adapter.verify(canon, sig, trust) + assert verdict.verdict == "Unverifiable" + assert "not in trust context" in verdict.detail + + +@pytest.mark.asyncio +async def test_verify_returns_unverifiable_when_resolver_misses() -> None: + priv, _ = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {}) + canon = _canon() + sig = await adapter.sign(canon, handle) + trust = SigningTrustContext( + trusted_signing_keys=frozenset({handle}), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type=canon.payload_type, + validity_window=None, + ) + verdict = await adapter.verify(canon, sig, trust) + assert verdict.verdict == "Unverifiable" + assert "not resolvable" in verdict.detail + + +@pytest.mark.asyncio +async def test_verify_raises_version_mismatch_when_signature_version_differs() -> None: + priv, pub = _keypair() + handle = JwksKid(kid="kid-A") + adapter = _build_adapter({"kid-A": priv}, {"kid-A": pub}) + canon = _canon() + sig = await adapter.sign(canon, handle) + foreign_sig = Signature( + bytes_=sig.bytes_, + adapter_version="cora/v2-cose", + key_handle=sig.key_handle, + signed_at=sig.signed_at, + ) + trust = SigningTrustContext( + trusted_signing_keys=frozenset({handle}), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type=canon.payload_type, + validity_window=None, + ) + with pytest.raises(CanonicalizationVersionMismatchError): + await adapter.verify(canon, foreign_sig, trust) diff --git a/apps/api/tests/unit/infrastructure/test_signing_port.py b/apps/api/tests/unit/infrastructure/test_signing_port.py new file mode 100644 index 000000000..9088b4bf3 --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_signing_port.py @@ -0,0 +1,141 @@ +"""Unit tests for the SigningPort Protocol surface and value types.""" + +from datetime import UTC, datetime + +import pytest + +from cora.infrastructure.ports.canonicalization import CanonicalizedBytes +from cora.infrastructure.ports.signing import ( + CanonicalizationVersionMismatchError, + Signature, + SignatureInvalidError, + SignatureVerification, + SigningKeyNotFoundError, + SigningPort, + SigningTrustContext, + UnsupportedSigningAlgorithmError, + algorithms_intersection, +) + + +class _FakeSigningAdapter: + """Minimal duck-typed conformer for runtime_checkable Protocol assertions.""" + + adapter_version = "fake/v0" + + async def sign(self, canonicalized: CanonicalizedBytes, key_handle: object) -> Signature: + return Signature( + bytes_=b"\x00" * 64, + adapter_version=self.adapter_version, + key_handle=key_handle, + signed_at=datetime(2026, 5, 31, tzinfo=UTC), + ) + + async def verify( + self, + canonicalized: CanonicalizedBytes, + signature: Signature, + signing_trust_context: SigningTrustContext, + ) -> SignatureVerification: + return SignatureVerification(verdict="Valid") + + +def test_signing_port_is_runtime_checkable_against_duck_typed_adapter() -> None: + assert isinstance(_FakeSigningAdapter(), SigningPort) + + +def test_signature_is_frozen_dataclass_with_locked_fields() -> None: + sig = Signature( + bytes_=b"\x01" * 64, + adapter_version="cora/v1", + key_handle="some-kid", + signed_at=datetime(2026, 5, 31, tzinfo=UTC), + ) + assert sig.bytes_ == b"\x01" * 64 + assert sig.adapter_version == "cora/v1" + assert sig.key_handle == "some-kid" + assert sig.signed_at == datetime(2026, 5, 31, tzinfo=UTC) + with pytest.raises(AttributeError): + sig.bytes_ = b"tampered" # type: ignore[misc] + + +def test_signing_trust_context_carries_locked_policy_fields() -> None: + ctx = SigningTrustContext( + trusted_signing_keys=frozenset({"kid-a", "kid-b"}), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type="application/vnd.cora.test-event+json", + validity_window=( + datetime(2026, 1, 1, tzinfo=UTC), + datetime(2027, 1, 1, tzinfo=UTC), + ), + ) + assert ctx.trusted_signing_keys == frozenset({"kid-a", "kid-b"}) + assert ctx.algorithm_allowlist == frozenset({"EdDSA"}) + assert ctx.expected_payload_type == "application/vnd.cora.test-event+json" + assert ctx.validity_window is not None + + +def test_signing_trust_context_accepts_none_validity_window() -> None: + ctx = SigningTrustContext( + trusted_signing_keys=frozenset(), + algorithm_allowlist=frozenset({"EdDSA"}), + expected_payload_type="application/vnd.cora.test-event+json", + validity_window=None, + ) + assert ctx.validity_window is None + + +def test_signature_verification_default_detail_is_empty_string() -> None: + verdict = SignatureVerification(verdict="Valid") + assert verdict.verdict == "Valid" + assert verdict.detail == "" + + +def test_signature_verification_carries_unverifiable_verdict_distinct_from_invalid() -> None: + unverifiable = SignatureVerification(verdict="Unverifiable", detail="JWKS rotation in flight") + invalid = SignatureVerification(verdict="Invalid", detail="Ed25519 verify rejected") + assert unverifiable.verdict == "Unverifiable" + assert invalid.verdict == "Invalid" + assert unverifiable.detail != invalid.detail + + +def test_signing_key_not_found_error_carries_key_handle_and_adapter_version() -> None: + error = SigningKeyNotFoundError(key_handle="lost-kid", adapter_version="cora/v1") + assert error.key_handle == "lost-kid" + assert error.adapter_version == "cora/v1" + assert "lost-kid" in str(error) + assert "cora/v1" in str(error) + + +def test_signature_invalid_error_carries_adapter_version_and_reason() -> None: + error = SignatureInvalidError(adapter_version="cora/v1", reason="Ed25519 verify returned False") + assert error.adapter_version == "cora/v1" + assert error.reason == "Ed25519 verify returned False" + + +def test_unsupported_signing_algorithm_error_carries_requested_algorithm() -> None: + error = UnsupportedSigningAlgorithmError(requested_algorithm="HS256", adapter_version="cora/v1") + assert error.requested_algorithm == "HS256" + assert error.adapter_version == "cora/v1" + + +def test_canonicalization_version_mismatch_error_carries_both_versions() -> None: + error = CanonicalizationVersionMismatchError( + canonicalized_version="cora/v1", signing_version="cora/v2-cose" + ) + assert error.canonicalized_version == "cora/v1" + assert error.signing_version == "cora/v2-cose" + assert "cora/v1" in str(error) + assert "cora/v2-cose" in str(error) + + +def test_algorithms_intersection_returns_allowed_subset() -> None: + allowlist = frozenset({"EdDSA", "ES256"}) + requested = ["EdDSA", "HS256"] + assert algorithms_intersection(requested, allowlist) == frozenset({"EdDSA"}) + + +def test_algorithms_intersection_returns_empty_when_no_overlap() -> None: + allowlist = frozenset({"EdDSA"}) + requested = ["HS256", "RS256"] + assert algorithms_intersection(requested, allowlist) == frozenset() diff --git a/apps/api/tests/unit/infrastructure/test_signing_registry.py b/apps/api/tests/unit/infrastructure/test_signing_registry.py new file mode 100644 index 000000000..d4e7a384b --- /dev/null +++ b/apps/api/tests/unit/infrastructure/test_signing_registry.py @@ -0,0 +1,95 @@ +"""Unit tests for SigningRegistry.""" + +from datetime import UTC, datetime + +import pytest + +from cora.infrastructure.adapters.default_signing_adapter import ( + DefaultSigningAdapter, + JwksKid, +) +from cora.infrastructure.adapters.signing_registry import SigningRegistry +from cora.infrastructure.ports.canonicalization import ( + UnsupportedCanonicalizationVersionError, +) +from cora.infrastructure.ports.signing import SigningPort + + +def _build_default_signing_adapter() -> DefaultSigningAdapter: + async def loader(_: JwksKid) -> bytes: + raise KeyError("no keys wired yet") + + async def resolver(_: str) -> bytes: + raise KeyError("no keys wired yet") + + return DefaultSigningAdapter( + private_key_loader=loader, + public_key_resolver=resolver, + clock=lambda: datetime(2026, 5, 31, tzinfo=UTC), + ) + + +def test_signing_registry_register_and_resolve_returns_adapter() -> None: + registry = SigningRegistry() + adapter = _build_default_signing_adapter() + registry.register("cora/v1", adapter) + resolved = registry.resolve("cora/v1") + assert resolved is adapter + assert isinstance(resolved, SigningPort) + + +def test_signing_registry_resolve_unregistered_raises_with_known_set() -> None: + registry = SigningRegistry() + registry.register("cora/v1", _build_default_signing_adapter()) + with pytest.raises(UnsupportedCanonicalizationVersionError) as exc_info: + registry.resolve("cora/v99") + assert exc_info.value.requested_version == "cora/v99" + assert "cora/v1" in exc_info.value.registered_versions + + +def test_signing_registry_empty_resolve_raises() -> None: + registry = SigningRegistry() + with pytest.raises(UnsupportedCanonicalizationVersionError): + registry.resolve("cora/v1") + + +def test_signing_registry_duplicate_register_raises_value_error() -> None: + registry = SigningRegistry() + registry.register("cora/v1", _build_default_signing_adapter()) + with pytest.raises(ValueError, match="already registered"): + registry.register("cora/v1", _build_default_signing_adapter()) + + +def test_signing_registry_registered_versions_preserves_registration_order() -> None: + registry = SigningRegistry() + registry.register("cora/v1", _build_default_signing_adapter()) + registry.register("cora/v2-cose", _build_default_signing_adapter()) + assert registry.registered_versions() == ("cora/v1", "cora/v2-cose") + + +@pytest.mark.asyncio +async def test_signing_registry_aclose_is_idempotent() -> None: + registry = SigningRegistry() + await registry.aclose() + await registry.aclose() + + +@pytest.mark.asyncio +async def test_signing_registry_aclose_suppresses_adapter_exceptions() -> None: + class _FlakyAdapter: + adapter_version = "cora/v1" + + async def sign(self, canonicalized: object, key_handle: object) -> object: + raise NotImplementedError + + async def verify( + self, canonicalized: object, signature: object, signing_trust_context: object + ) -> object: + raise NotImplementedError + + async def aclose(self) -> None: + raise RuntimeError("flaky teardown") + + registry = SigningRegistry() + registry.register("cora/v1", _FlakyAdapter()) # type: ignore[arg-type] + await registry.aclose() diff --git a/infra/atlas/migrations/20260601000000_add_events_signature_version.sql b/infra/atlas/migrations/20260601000000_add_events_signature_version.sql new file mode 100644 index 000000000..bdcc162ba --- /dev/null +++ b/infra/atlas/migrations/20260601000000_add_events_signature_version.sql @@ -0,0 +1,57 @@ +-- Add signature_version column to the events table. +-- +-- Iter-b Stage 3b of the federation port-tier work +-- (project_canonicalization_port_design.md + project_federation_port_design.md): +-- the verifier dispatches to the matching SigningPort adapter via the +-- SigningRegistry keyed on signature_version. Today the only registered +-- recipe is "cora/v1" (Ed25519 over DSSE-PAE bytes); future wire-tier +-- adapters (DSSE+Sigstore, COSE_Sign1+SCITT) will register additional +-- versions alongside without invalidating v1 per the +-- "v1 NEVER deprecated" invariant. +-- +-- Why nullable and undefaulted: +-- +-- - signature_version IS NULL is the legitimate marker for +-- pre-rollout events (immortal per project_immutability_guarantee) +-- and for unsigned events of any kind. Forward-only migration +-- policy forbids backfill; historical rows stay with NULL forever +-- and that is the correct semantic. +-- - Matches the existing nullable shape of `signature` and +-- `signature_kid` columns added in +-- 20260523214753_add_events_signature_columns.sql. +-- +-- The CHECK constraint enforces the matched-pair invariant: a +-- signature_version without a signature (or vice versa) is a write- +-- side bug the database catches at INSERT, before the row lands. +-- Distinct from "no signature at all" (all three NULL) which is +-- legitimate per the design lock. +-- +-- text for signature_version: +-- +-- The version identifier is namespaced like "cora/v1" or +-- "cora/v2-cose"; the namespace prefix permits facility-specific +-- extensions ("aps/v1-internal") without colliding with CORA- +-- shipped adapter ids. Length is bounded but variable; text is +-- the unconstrained choice. +-- +-- No index: +-- +-- Verification is per-row; the verifier resolves the SigningPort +-- from the registry by exact-match string lookup. No "find all +-- events signed under version X" filter query in the design. +-- +-- Length constraint (defense-in-depth): +-- +-- 1 to 64 chars when non-NULL. 64 accommodates +-- "/-" namespaced +-- identifiers with headroom; bigger values are almost certainly +-- a misuse of the field for unrelated metadata. + +ALTER TABLE events + ADD COLUMN signature_version TEXT; + +ALTER TABLE events + ADD CONSTRAINT events_signature_version_consistency + CHECK ((signature IS NULL) = (signature_version IS NULL)), + ADD CONSTRAINT events_signature_version_length + CHECK (signature_version IS NULL OR octet_length(signature_version) BETWEEN 1 AND 64); diff --git a/infra/atlas/migrations/atlas.sum b/infra/atlas/migrations/atlas.sum index a8fe46873..783ff4d57 100644 --- a/infra/atlas/migrations/atlas.sum +++ b/infra/atlas/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:1NLrqPW/bFYVIPXRz5sWJ6oPhsU98drZ9Ttrsv7rlzg= +h1:ZLbSwX4Eq11iMLHdojxEHjySp5v/wswRSGm5Lg86WUo= 20260509120000_init_events.sql h1:GmgCZKfaqXu1m96/cKAks2vhaLWTdEaHTLkFtUo9FXg= 20260509170000_init_idempotency.sql h1:Nbu8DIE4Sv1WiHw3G22+tYffPhKc5Jryw3PMK8wB2zY= 20260510010000_add_event_id.sql h1:RbtYP6uMnOB20zhJ9dNXUi4YVqbmlEzf562pmygnRW8= @@ -86,12 +86,13 @@ h1:1NLrqPW/bFYVIPXRz5sWJ6oPhsU98drZ9Ttrsv7rlzg= 20260530210100_init_proj_federation_credential_summary.sql h1:sM4H8YgdPxfLr59LFpZTWgMXKQv1SL7F6+GbB5Ej2DY= 20260530210200_init_proj_federation_seal_summary.sql h1:MvsiqI/iT+TACw+Ifx2maAC8AINRk31feux+uNNpQjI= 20260530300000_add_asset_summary_drawing.sql h1:oyrzh7SPshKEGpC6X0X/JI+FrUctV6CK13vOtb4lRUo= -20260601100000_rename_seal_key_ref_to_credential_id.sql h1:xFecq2lgvE3U4v65DNPwgcKtXaSzQ8+y9zJnKUHUh6U= -20260601100100_rename_frame_summary_placement_column.sql h1:aDObXIGv3jTavyX0n2XbApJAxjD+UHOovdOqKPjCabo= -20260601100200_add_proj_federation_seal_summary_stream_id.sql h1:/sgFuocyP63WPyKSk8wrZq1r8AZ9wfQV6iqt5eyhfPI= -20260601110000_init_proj_equipment_model_summary.sql h1:QJCanmiewUXP1knkN62ajcTon0dskClItX8pNOUwCzw= -20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:3zElIH2cC7y2mOyOmRgU+Asf3bsvfLtekrVH9mYnBqM= -20260602110000_add_asset_summary_model.sql h1:6JNrSeL/whEo/ZQ6IlZs21RJ79BGcl0bR35nH0IhTGc= -20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:AGBAc0XP1TXK04PIFKavYe0buGgonSFMbL9MxYyr7bk= -20260602120000_rename_model_summary_declared_families_column.sql h1:NI5sDXMPXmI7p+azimD6S6LzWaaP0N06+JcU7tbj+6Y= -20260603100000_add_asset_summary_alternate_identifiers.sql h1:Nc5kSxfv6zEUvJBD+ZZULyqlrboCLFD42gMoTEQxRcI= +20260601000000_add_events_signature_version.sql h1:HJZAfmUyiTZM3Nlo7PaT7XBSeh3flCF4U+9gwB/f0fs= +20260601100000_rename_seal_key_ref_to_credential_id.sql h1:Y9VHJeSAlDwWrgc8WN8Cz8zNKVON0QcChI/IMO2ATX4= +20260601100100_rename_frame_summary_placement_column.sql h1:Yj8zs1mvGgMdraYliDFnh0NnshfGNfb6/nnKArzbI2E= +20260601100200_add_proj_federation_seal_summary_stream_id.sql h1:FKi5KDJkxtEPHdn/CyE6nvHloRa+7Y7D/MjDWOGMUuE= +20260601110000_init_proj_equipment_model_summary.sql h1:YbnFFCxMDUyAW9/AI9QAk0jTnGZ16knS9FPyHSNYuuQ= +20260602100000_drop_proj_equipment_model_summary_vendor_key_unique.sql h1:AvfNrU4lPKk5uTNG8Gi/VJmJWneP5OQffYYjVPE3f0U= +20260602110000_add_asset_summary_model.sql h1:TlyLShhTlnytpUcU7MBvDY7rS68iNeLILcbaG8/g9Vc= +20260602110000_rename_proj_equipment_mount_lookup_to_mount_slot_code.sql h1:MVzeYv6D4idZRzA1tcI8Saz6zz8Mfe9d5psWw3Ua9ys= +20260602120000_rename_model_summary_declared_families_column.sql h1:9dO8AZjPEHt9JAixMrXgFAU11cFe3Dzs8uDkTc8JeDU= +20260603100000_add_asset_summary_alternate_identifiers.sql h1:gUZAoJQg+vdYL4FwoitngF24hBaYwyFvSB1yY04CqjI=