From 18d462906065afb8546a1d614434754caa6677ad Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 18:57:21 +0300 Subject: [PATCH 01/13] feat(infrastructure): Stage-1b1 CanonicalizationPort + SigningPort scaffolds Lift the shipped v1 canonicalization + signing recipes into swappable port surfaces per project_canonicalization_port_design.md iter-a steps 1 and 2. Scaffold ONLY: the two Default*Adapter implementations (iter-a steps 3-4), the two registries (step 5), the Kernel wire (step 6), and the golden-vector arch fitness (step 8) land in the next two commits (Stage 1b2 + 1b3). content_hash.py + signing.py + existing callers untouched per iter-a step 7. CanonicalizationPort (runtime_checkable): - adapter_version: str property (locked namespace; v1 returns "cora/v1") - canonicalize(payload_type, payload) -> CanonicalizedBytes - verify_content_hash(payload_type, payload, claimed_hash) -> bool Sibling SigningPort (runtime_checkable): - adapter_version: str property - async sign(canonicalized, key_handle) -> Signature - async verify(canonicalized, signature, signing_trust_context) -> SignatureVerification Value types (port-owned, substrate-neutral): - CanonicalizedBytes(bytes_, adapter_version, payload_type) - Signature(bytes_, adapter_version, key_handle, signed_at) - SigningTrustContext(trusted_signing_keys, algorithm_allowlist, expected_payload_type, validity_window) - SignatureVerification(verdict={Valid|Invalid|Unverifiable}, detail) - KeyHandle = Any (opaque at port; adapters narrow; v1 uses JwksKid) Error families (co-located per port-pattern convention): - canonicalization: CanonicalizationFailedError + ContentHashMismatchError + UnsupportedCanonicalizationVersionError - signing: SigningKeyNotFoundError + SignatureInvalidError + UnsupportedSigningAlgorithmError + CanonicalizationVersionMismatchError The SigningKeyNotFoundError on this new port is intentionally distinct from the existing SignerKeyNotFoundError on the older Signer port at cora.infrastructure.ports.signer; the two ports coexist during the transition until iter-b step 5 widens or retires the old Signer. 18 paired unit tests pin: runtime_checkable Protocol surface against duck-typed conformers; frozen-dataclass field locks; mandatory adapter_version (no inferred default per value-type boundary); error classes carry their locked kwargs verbatim. Worktree commit: isolated from in-flight parent-worktree refactoring; 136/136 infrastructure unit tests green; pyright clean. --- .../infrastructure/ports/canonicalization.py | 131 +++++++++++ .../src/cora/infrastructure/ports/signing.py | 215 ++++++++++++++++++ .../test_canonicalization_port.py | 90 ++++++++ .../unit/infrastructure/test_signing_port.py | 141 ++++++++++++ 4 files changed, 577 insertions(+) create mode 100644 apps/api/src/cora/infrastructure/ports/canonicalization.py create mode 100644 apps/api/src/cora/infrastructure/ports/signing.py create mode 100644 apps/api/tests/unit/infrastructure/test_canonicalization_port.py create mode 100644 apps/api/tests/unit/infrastructure/test_signing_port.py 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/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/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_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() From 8d91d2906ed479e31cd25ce31c4b4853d7cbc7d8 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 19:07:53 +0300 Subject: [PATCH 02/13] feat(infrastructure): Stage-1b2 Default v1 adapters + golden-vector fitness Land the v1 canonicalization + signing adapter implementations behind the Stage-1b1 port surface per project_canonicalization_port_design.md iter-a steps 3-4 and 8. content_hash.py + signing.py untouched per iter-a step 7; the adapters are thin wrappers delegating to the shipped helpers byte-for-byte. DefaultCanonicalizationAdapter: - adapter_version property returns the literal string "cora/v1" - canonicalize() delegates to canonical_body_bytes + pae_bytes - verify_content_hash() delegates to compute_content_hash + == - Rejects payload_types outside the application/vnd.cora.+json URI scheme with CanonicalizationFailedError DefaultSigningAdapter: - adapter_version property returns "cora/v1" - JwksKid(kid: str) frozen dataclass narrows KeyHandle for v1 - sign() Ed25519-signs over CanonicalizedBytes.bytes_ via cryptography.hazmat (relies on the >=46,<47 pin from Stage 1a) - verify() returns SignatureVerification with verdict mapped to: * Valid: Ed25519 verify returned successfully * Invalid: InvalidSignature raised by cryptography * Unverifiable: key not in trust context, public bytes malformed, resolver raised KeyError (the math could not run) - Both sign() and verify() raise CanonicalizationVersionMismatchError when CanonicalizedBytes.adapter_version does not equal "cora/v1" - Injects private_key_loader + public_key_resolver + clock at construction; resolvers stay call-site-specific per the existing cora.infrastructure.signing.verify_signature resolver-callable shape Golden-vector arch fitness (per iter-a step 8): - test_canonicalization_v1_immutability.py pins the SHA-256 digest "1a4badf0f45fff0374a2332cfb29eb6492aecf98fc1b69418faac6a1b700ad8b" for payload_type "application/vnd.cora.test-event+json" and payload {"name": "test", "value": 42} - Computed from the shipped compute_content_hash at lock-write time; any byte that alters this constant is a regression on the "v1 NEVER deprecated" invariant for every shipped Method/Plan/ CalibrationRevision/DecisionRegistered content_hash across CORA history and MUST be reverted, not adjusted 21 new tests pin: adapter_version locks; byte-for-byte delegation parity with compute_content_hash; verify verdict matrix (Valid + Invalid + Unverifiable for each failure mode); JwksKid frozen + hashable for trust-context membership; cross-version rejection on both sign and verify paths. Worktree commit: isolated from in-flight parent-worktree refactoring; 21/21 new + 136/136 existing infrastructure tests green; pyright clean. --- .../default_canonicalization_adapter.py | 89 ++++++++ .../adapters/default_signing_adapter.py | 160 +++++++++++++ .../test_canonicalization_v1_immutability.py | 48 ++++ .../test_default_canonicalization_adapter.py | 73 ++++++ .../test_default_signing_adapter.py | 212 ++++++++++++++++++ 5 files changed, 582 insertions(+) create mode 100644 apps/api/src/cora/infrastructure/adapters/default_canonicalization_adapter.py create mode 100644 apps/api/src/cora/infrastructure/adapters/default_signing_adapter.py create mode 100644 apps/api/tests/architecture/test_canonicalization_v1_immutability.py create mode 100644 apps/api/tests/unit/infrastructure/test_default_canonicalization_adapter.py create mode 100644 apps/api/tests/unit/infrastructure/test_default_signing_adapter.py 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/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/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) From fabfc8f4b5db8a274c6f212490a763131b79e42a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 19:45:31 +0300 Subject: [PATCH 03/13] feat(infrastructure): Stage-1b3 registries + Kernel wire (Stage 1b CLOSED) Land CanonicalizationRegistry + SigningRegistry per project_canonicalization_port_design.md iter-a step 5, and wire them into Kernel construction per iter-a step 6. Stage 1b is now CLOSED: 2 ports + 2 default adapters + 2 registries + Kernel wire + golden-vector arch fitness all shipped behind the existing content_hash.py + signing.py helpers, with zero changes to write- or verify-side callers. CanonicalizationRegistry: - register(version, adapter) appends; duplicate raises ValueError - resolve(version) exact-match; miss raises UnsupportedCanonicalizationVersionError - set_default(version) pins the deployment-wide default; requires the version to already be registered - default_version() is the write-side handle; raises until set - aclose() suppresses per-adapter close errors SigningRegistry: - Same shape minus default_version (signing dispatch is by the canonicalization version on the matched event; a deployment- wide signing default would let writers pair v1 canonicalization with v2 signing on the same event, which the row-level fitness ban forbids) - Empty at construction is valid Kernel wire: - canonicalization_registry: CanonicalizationRegistry (REQUIRED) - signing_registry: SigningRegistry (REQUIRED) - Both make_postgres_kernel and make_inmemory_kernel construct the canonicalization registry via the new helper (DefaultCanonicalizationAdapter under "cora/v1"; default set to "cora/v1") and the signing registry as empty (the v1 DefaultSigningAdapter needs concrete private-key-loader + public-key-resolver injections that land at the federation seam, not here) - REQUIRED fields per the lock memo invariant - Test fixtures unchanged 19 paired registry unit tests pin the register-resolve round-trip, the unregistered-resolve error surface, the duplicate-register guard, the set_default and default_version semantics, registration- order preserved, aclose idempotency + flaky-adapter suppression + no-aclose-method skip. Worktree commit: 176/176 infrastructure unit tests green; test_kernel_construction_single_site green; pyright clean. --- .../adapters/canonicalization_registry.py | 107 ++++++++++++++++++ .../adapters/signing_registry.py | 75 ++++++++++++ apps/api/src/cora/infrastructure/deps.py | 26 +++++ apps/api/src/cora/infrastructure/kernel.py | 6 + .../test_canonicalization_registry.py | 107 ++++++++++++++++++ .../infrastructure/test_signing_registry.py | 95 ++++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 apps/api/src/cora/infrastructure/adapters/canonicalization_registry.py create mode 100644 apps/api/src/cora/infrastructure/adapters/signing_registry.py create mode 100644 apps/api/tests/unit/infrastructure/test_canonicalization_registry.py create mode 100644 apps/api/tests/unit/infrastructure/test_signing_registry.py 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/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..047319c3e 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -43,6 +43,12 @@ import asyncpg +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 +56,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 +103,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, *, @@ -191,6 +213,8 @@ 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(), pool=pool, llm=llm, logbook_mirror=logbook_mirror, @@ -286,6 +310,8 @@ 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(), # 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 diff --git a/apps/api/src/cora/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index e8edf796e..65754e979 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, @@ -169,6 +173,8 @@ 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 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_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() From e607c20e2eaf80f66052129558cf78c57ad974b4 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 21:50:32 +0300 Subject: [PATCH 04/13] feat(infrastructure): Stage-2a federation port subpackage Land the federation port-tier vocabulary per project_federation_port_design.md: 3 Protocols, ~26 value types, 12 error classes, all substrate-neutral. Adapters land in a follow-up iteration (InMemory first, then real wire-tier). Sub-package layout (mirrors cora/infrastructure/observability/): - value_types.py: PublishedArtifact, ArtifactReference, PublishReceipt, PulledArtifact, FetchProvenance, FederationTrustContext, VerificationOutcome (Verified | Rejected | Unverifiable discriminated union), SignatureEnvelope (3 placeholder arms: DsseStaticJwksEnvelope, DsseSigstoreKeylessEnvelope, CoseSign1ScittEnvelope), Receipt, StageResult, StageName closed Literal, DcoEntry (SignedOffBy | AssistedBy | CoDevelopedBy), CredentialRef, RejectionReason, UnverifiabilityReason, PublicationStatus - errors.py: 12 port-tier errors mirroring the ControlPort error-family pattern (permit not found, signature invalid, signer untrusted, content drift, credential revoked, retry exhausted, circuit open, rate limit exceeded, adoption window closed, receipt missing, no adapter for facility, canonicalization mismatch) - publish_port.py: PublishPort(artifact) -> receipt - pull_port.py: PullPort(reference) -> pulled artifact - signature_port.py: SignaturePort.verify(artifact, trust_ctx) -> outcome; SignaturePort.sign(canonicalized, trust_ctx) -> envelope (delegates raw signing to kernel-tier SigningPort per arch-2) - __init__.py: package re-exports Anti-hooks pinned in docstrings: - AH#9: SignaturePort.sign MUST NOT accept raw key material; credential resolved via SecretStore from trust_context - AH#17: PullPort.fetch raises FederationPublicationContentDriftError BEFORE returning if fetched bytes do not hash to reference - AH#19: FederationTrustContext.accept_yanked is Literal[False]; no accept_expired override either - Rejected per-family Protocols (DsseSignaturePort, CoseSignaturePort) per ControlPort sibling-port rule + Notation cross-industry analog + deletion-asymmetry test Pragmatic deferral: abi_tier is typed `str` at this tier; the closed-enum AbiTier(Testing | Stable | Obsolete | Removed) still lives at BC-tier in cora.federation.aggregates.permit.state.AbiTier. Hoisting to port tier requires touching ~9 BC import sites and is held as a follow-up watch item to keep this commit self-contained and avoid colliding with parent-worktree in-flight BC refactoring. 40 paired unit tests pin: runtime_checkable Protocol surface for all 3 ports against duck-typed conformers; frozen-dataclass field locks; all 3 SignatureEnvelope arms accept the union; all 3 DcoEntry arms accept the union; FederationTrustContext.accept_yanked structurally False; required_receipt_kinds default empty + accepts known kinds; Verified/Rejected/Unverifiable verdict triple distinct; all 12 error classes carry locked kwargs verbatim and render to str. Worktree commit: 216/216 infrastructure unit tests green; pyright clean; no-phase-markers + test-name-outcome arch fitness green. --- .../ports/federation/__init__.py | 104 +++++ .../infrastructure/ports/federation/errors.py | 269 +++++++++++ .../ports/federation/publish_port.py | 28 ++ .../ports/federation/pull_port.py | 32 ++ .../ports/federation/signature_port.py | 56 +++ .../ports/federation/value_types.py | 419 ++++++++++++++++++ .../infrastructure/federation/test_errors.py | 129 ++++++ .../infrastructure/federation/test_ports.py | 127 ++++++ .../federation/test_value_types.py | 261 +++++++++++ 9 files changed, 1425 insertions(+) create mode 100644 apps/api/src/cora/infrastructure/ports/federation/__init__.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/errors.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/publish_port.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/pull_port.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/signature_port.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/value_types.py create mode 100644 apps/api/tests/unit/infrastructure/federation/test_errors.py create mode 100644 apps/api/tests/unit/infrastructure/federation/test_ports.py create mode 100644 apps/api/tests/unit/infrastructure/federation/test_value_types.py 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..10fdf1e54 --- /dev/null +++ b/apps/api/src/cora/infrastructure/ports/federation/__init__.py @@ -0,0 +1,104 @@ +"""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.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", + "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/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/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} From b016c3a285d8680ed5dbea0c5cb7bc659bc58e6e Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 23:16:28 +0300 Subject: [PATCH 05/13] feat(federation): in-memory adapters for PublishPort + PullPort + SignaturePort Land the 3 in-memory adapters per project_federation_port_design.md as the production-tier substitute until the rule-of-two trigger fires. Dict-backed, no sockets, no crypto. Test entry verbs follow the locked simulate_* convention. InMemoryPublishPort: publish records to a list + monotonic receipt id; simulate_credential_revoked makes next publish raise. InMemoryPullPort: fetch returns primed PulledArtifact or raises KeyError with set_pull_response guidance; simulate_registry_unreachable raises FederationCircuitOpenError; simulate_content_drift raises FederationPublicationContentDriftError (AH#17 TOCTOU defense end- to-end testable). InMemorySignaturePort: verify returns default Verified outcome or primed-per-content-hash; sign returns a default DsseStaticJwksEnvelope wrapping canonicalized.bytes_ or primed- per-canonicalization-version; simulate_signature_invalid maps to Rejected with named failed_stage. No SigningPort dependency (does no crypto); the arch-2 delegation fitness walks production adapters only. 22 paired unit tests pin Protocol conformance + simulate verbs + clear_simulations + aclose idempotency. Worktree commit: 22/22 new federation unit tests green; pyright clean; arch fitness green. --- .../adapters/in_memory_publish_port.py | 72 ++++++++++ .../adapters/in_memory_pull_port.py | 96 +++++++++++++ .../adapters/in_memory_signature_port.py | 110 +++++++++++++++ .../federation/test_in_memory_publish_port.py | 87 ++++++++++++ .../federation/test_in_memory_pull_port.py | 127 ++++++++++++++++++ .../test_in_memory_signature_port.py | 127 ++++++++++++++++++ 6 files changed, 619 insertions(+) create mode 100644 apps/api/src/cora/federation/adapters/in_memory_publish_port.py create mode 100644 apps/api/src/cora/federation/adapters/in_memory_pull_port.py create mode 100644 apps/api/src/cora/federation/adapters/in_memory_signature_port.py create mode 100644 apps/api/tests/unit/federation/test_in_memory_publish_port.py create mode 100644 apps/api/tests/unit/federation/test_in_memory_pull_port.py create mode 100644 apps/api/tests/unit/federation/test_in_memory_signature_port.py 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/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() From 960ed6d49f689fb02f463534e1256828fd7f94e8 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 23:32:35 +0300 Subject: [PATCH 06/13] feat(federation): FederationRegistry composite + envelope-arms fitness (Stage 2 CLOSED) Land the FederationRegistry composite per project_federation_port_design.md plus the structural-completeness arch fitness for SignatureEnvelope arms. Stage 2 is now CLOSED: federation port subpackage + 3 InMemory adapters + composite dispatcher + arch fitness all shipped. FederationRegistry (cora/federation/adapters/federation_registry.py): - Implements both PublishPort and PullPort (composite port) - Routes by longest-prefix-match on source_facility_id.hex string, mirroring ControlPortRegistry's routing rule - register(prefix, port) appends or REPLACES the prior entry (hot-swap for integration tests) - route_publish / route_pull return the matching backing port; fall through raises NoAdapterForFacilityError - registered_prefixes() exposes the table in registration order - aclose() fan-out with contextlib.suppress + _closed idempotency flag Arch fitness: - test_federation_signature_envelope_arms.py pins the exact set of 3 arm classes in the SignatureEnvelope union; removing or renaming any arm fails CI - Pins every arm carries the locked field set (signing_version, payload_bytes, kind, receipts) - Pins every arm's kind discriminator default matches the locked Literal value - Pins every arm's receipts default is empty tuple (changing that default lets the receipt-suppression downgrade per sec-1 land silently) 15 new tests pin: FederationRegistry PublishPort + PullPort Protocol conformance; prefix-routing dispatch + longest-prefix- wins + no-match raises NoAdapterForFacilityError; register replacement + order preservation; aclose idempotency + flaky- adapter suppression. Plus the 4 envelope-arms fitness assertions. Worktree commit: 15/15 new tests green; pyright clean. --- .../adapters/federation_registry.py | 110 ++++++++++++ ...test_federation_signature_envelope_arms.py | 87 ++++++++++ .../federation/test_federation_registry.py | 163 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 apps/api/src/cora/federation/adapters/federation_registry.py create mode 100644 apps/api/tests/architecture/test_federation_signature_envelope_arms.py create mode 100644 apps/api/tests/unit/federation/test_federation_registry.py 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/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/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), + ) From 9726a8344d84a632d99744819f091b1fd0060e2c Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Sun, 31 May 2026 23:46:41 +0300 Subject: [PATCH 07/13] feat(signer): widen Signer.sign return to (signature, kid, signing_version) Per project_federation_port_design.md iter-b step 5: widen the existing Signer.sign() return tuple from (bytes, str) to (bytes, str, str), adding signing_version as the third field. Carries the signing-recipe identifier (the v1 default is "cora/v1") so the verifier can later dispatch to the matching SigningPort adapter via the SigningRegistry, mirroring how content_hash dispatches to CanonicalizationRegistry via canonicalization_version. Surgical widening: - cora/infrastructure/ports/signer.py: tuple[bytes, str] -> tuple[bytes, str, str]; docstring + module-level "What sign returns" section updated - Both Agent BC subscriber callers (run_debriefer + caution_drafter): unpack 3-tuple, discard the third element with `_signing_version` until NewEvent + StoredEvent gain a signature_version field in a follow-up commit - _FakeSigner test mock in tests/unit/agent/_helpers.py: returns "cora/v1" as the third element so subscriber test bodies pick up the new shape mechanically - Two test-file stub Signers (test_run_debriefer_subscriber + test_caution_drafter_subscriber): return-type annotation widened to tuple[bytes, str, str]; they only raise so the body is unchanged Pragmatic deferral: persisting signing_version on NewEvent / StoredEvent + the matching events-table column migration + the matched-pair arch fitness all land together in a follow-up commit. This commit is contained at the port surface so the version-bump ripple stays inside the Signer-Protocol contract and the shipped subscribers. 90/90 affected unit tests green; pyright clean. --- .../cora/agent/subscribers/caution_drafter.py | 2 +- .../cora/agent/subscribers/run_debriefer.py | 2 +- .../src/cora/infrastructure/ports/signer.py | 31 +++++++++++++------ apps/api/tests/unit/agent/_helpers.py | 4 +-- .../agent/test_caution_drafter_subscriber.py | 2 +- .../agent/test_run_debriefer_subscriber.py | 2 +- 6 files changed, 27 insertions(+), 16 deletions(-) diff --git a/apps/api/src/cora/agent/subscribers/caution_drafter.py b/apps/api/src/cora/agent/subscribers/caution_drafter.py index 135d89ac5..60e81e37d 100644 --- a/apps/api/src/cora/agent/subscribers/caution_drafter.py +++ b/apps/api/src/cora/agent/subscribers/caution_drafter.py @@ -554,7 +554,7 @@ 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, diff --git a/apps/api/src/cora/agent/subscribers/run_debriefer.py b/apps/api/src/cora/agent/subscribers/run_debriefer.py index e07d2e2ce..20f9fef4f 100644 --- a/apps/api/src/cora/agent/subscribers/run_debriefer.py +++ b/apps/api/src/cora/agent/subscribers/run_debriefer.py @@ -581,7 +581,7 @@ 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, 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/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 From 78dca09bec5eb7f598c3a4e3475b1264e1e4de06 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 00:50:11 +0300 Subject: [PATCH 08/13] feat(events): signature_version column + matched-pair invariant Per project_canonicalization_port_design.md iter-b: persist the signing-recipe identifier alongside `signature` + `signature_kid` on every signed event row, so the verifier can dispatch to the matching SigningPort adapter via the SigningRegistry. The v1 default is "cora/v1"; future wire-tier adapters (DSSE+Sigstore, COSE_Sign1+SCITT) will register additional versions alongside without invalidating v1 per the "v1 NEVER deprecated" invariant. Migration (20260601000000_add_events_signature_version.sql): - ADD COLUMN signature_version TEXT (nullable, undefaulted; matches the existing nullable shape of signature + signature_kid set in 20260523214753_add_events_signature_columns.sql) - CHECK events_signature_version_consistency: (signature IS NULL) = (signature_version IS NULL) - CHECK events_signature_version_length: signature_version IS NULL OR octet_length BETWEEN 1 AND 64 (defense-in-depth bound for namespaced "/" identifiers like "cora/v1" or "aps/v1-internal") Application layer: - NewEvent gains signature_version: str | None = field(default=None, kw_only=True), parallel to signature + signature_kid - StoredEvent gains signature_version: str | None = None - postgres_event_store: _LOAD_SQL adds the column to SELECT; _APPEND_SQL adds a 15th parameter; both INSERT call-sites (append_streams + _append_in_conn for the forget_actor atomic write path) thread event.signature_version; _row_to_event reads the column back - in_memory_event_store: StoredEvent construction threads event.signature_version - Both Agent BC subscribers (run_debriefer + caution_drafter) replace the discard-and-ignore `_signing_version` from Stage 3a with a real `replace(..., signature_version=signing_version)` on NewEvent Architecture fitness (tests/architecture/test_events_signature_version_matched_pair.py): - NewEvent + StoredEvent both carry the three signature fields (drift on the dataclass shape fails before any SQL runs) - Both CHECK constraints present in tracked migrations - Both CHECK constraints use the locked `(signature IS NULL) = (signature_X IS NULL)` matched-pair shape; weakening that shape lets a partially-signed row land 658/658 affected unit tests green (infrastructure + agent subscribers + in-memory event store + event-store property tests + event envelope); 6/6 new arch fitness assertions green; pyright clean. --- .../cora/agent/subscribers/caution_drafter.py | 9 +- .../cora/agent/subscribers/run_debriefer.py | 9 +- .../adapters/in_memory_event_store.py | 1 + .../adapters/postgres_event_store.py | 9 +- .../cora/infrastructure/ports/event_store.py | 2 + ...t_events_signature_version_matched_pair.py | 109 ++++++++++++++++++ ...601000000_add_events_signature_version.sql | 57 +++++++++ infra/atlas/migrations/atlas.sum | 21 ++-- 8 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 apps/api/tests/architecture/test_events_signature_version_matched_pair.py create mode 100644 infra/atlas/migrations/20260601000000_add_events_signature_version.sql diff --git a/apps/api/src/cora/agent/subscribers/caution_drafter.py b/apps/api/src/cora/agent/subscribers/caution_drafter.py index 60e81e37d..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, _signing_version = 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 20f9fef4f..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, _signing_version = 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/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/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/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/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= From 5f82f5888ee71cadc680101e5ffd68f1bdc5e723 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 01:12:41 +0300 Subject: [PATCH 09/13] feat(infrastructure): Stage-3c verify-then-apply orchestrator (shared kernel) Land the verify-then-apply orchestrator at cora/infrastructure/published_artifact/ per project_federation_port_design.md. Generic Subdomain shared-kernel coordinator that composes BEFORE-gates, arm-specific SignaturePort.verify dispatch, and AFTER-gates into a single VerificationOutcome. Package layout: - __init__.py: re-exports verify_then_apply + the stage helpers - _stages.py: pure functions, one per non-arm-specific gate (check_payload_type_trusted, check_content_hash, check_required_receipts_present, check_abi_tier, check_expires_at, check_dco_chain, deferred_stage) - orchestrator.py: async verify_then_apply(...) runs the 13-stage sequence, short-circuits on hard fails, delegates per-arm to SignaturePort.verify, synthesizes the final outcome Stage ordering: BEFORE: payload_type_trusted -> content_hash -> required_receipts ARM: delegated to SignaturePort.verify AFTER: payload_type_known (skip) -> abi_tier -> expires_at -> head_pointer_fresh (skip) -> replay_cache (skip) -> dco_chain Per arch-2: delegates to SignaturePort.verify; never invokes crypto directly. Per AH#17: PullPort raises FederationPublicationContentDriftError BEFORE the artifact reaches the orchestrator; content_hash here is defense-in-depth. ABI tier ordering: Testing(1) < Stable(2) < Obsolete(3) < Removed(4). Removed always fails regardless of floor. DCO chain requires at least one SignedOffBy entry per the Linux kernel coding-assistants.rst invariant. 36 paired tests pin every stage helper's pass/fail/skip matrix individually + 10 end-to-end orchestrator paths. Worktree commit: 36/36 new tests green; pyright clean. --- .../published_artifact/__init__.py | 41 +++ .../published_artifact/_stages.py | 266 +++++++++++++++++ .../published_artifact/orchestrator.py | 199 +++++++++++++ .../published_artifact/test_orchestrator.py | 245 ++++++++++++++++ .../published_artifact/test_stages.py | 268 ++++++++++++++++++ 5 files changed, 1019 insertions(+) create mode 100644 apps/api/src/cora/infrastructure/published_artifact/__init__.py create mode 100644 apps/api/src/cora/infrastructure/published_artifact/_stages.py create mode 100644 apps/api/src/cora/infrastructure/published_artifact/orchestrator.py create mode 100644 apps/api/tests/unit/infrastructure/published_artifact/test_orchestrator.py create mode 100644 apps/api/tests/unit/infrastructure/published_artifact/test_stages.py 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/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] From 0c280a520ea6c4d0a529d053bb7780f12cb1013a Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 07:55:01 +0300 Subject: [PATCH 10/13] feat(federation): Stage-3d1 PermitLookup port + Kernel widening First of 3 commits scoping the Stage 3d canary (per-BC publish slice) into reviewable pieces. This commit lands the PermitLookup port + in-memory adapter + Kernel widening for the federation publish/pull seam; the events + decider + handler land in Stages 3d2 and 3d3. PermitLookup port (cora/infrastructure/ports/federation/permit_lookup.py): - runtime_checkable Protocol with lookup_outbound and lookup_inbound methods returning PermitLookupResult | None - PermitLookupResult dataclass carries the minimal columns a per-BC publish/pull handler needs to compose a FederationTrustContext (permit_id, peer_facility_id, direction, status, abi_tier_floor, current_version) - Stays typed as str for direction + status so the port respects the cora.infrastructure depends_on = [] tach contract; the string values match the BC-tier Direction + PermitStatus StrEnums verbatim - Single port carries both outbound and inbound lookups because they share the same projection table and almost-identical resolution logic; collapsing onto one port keeps both slice handlers reading from one shipped adapter InMemoryPermitLookup (cora/federation/adapters/in_memory_permit_lookup.py): - Dict-backed; mirrors InMemoryCredentialLookup precedent - register + register_outbound + register_inbound convenience verbs for test fixtures; clear() resets state - PostgresPermitLookup adapter (reading proj_federation_permit_summary) lands in a follow-up alongside the projection definition Kernel widening (cora/infrastructure/kernel.py): - 3 new optional fields: publish_port, signature_port, permit_lookup (all PublishPort | SignaturePort | PermitLookup | None, default None) - federation_registry is intentionally NOT a separate field: FederationRegistry already implements PublishPort + PullPort, so deployments wanting prefix-routed dispatch construct a FederationRegistry, register backing adapters, and set publish_port=registry. One field, two roles. - All 3 fields default to None so existing Kernel construction sites (make_postgres_kernel + make_inmemory_kernel) keep working without changes; Stage 3d3 wires the in-memory defaults when the publish slice handler needs them. 10 paired unit tests pin: PermitLookup Protocol conformance via isinstance; lookup_outbound + lookup_inbound key independence; register + register_outbound + register_inbound seeding round-trip; custom status + abi_tier_floor + current_version propagation; clear() resets state; PermitLookupResult frozen-dataclass field locks. Worktree commit: 10/10 new + 252 prior infrastructure/federation unit tests green; test_kernel_construction_single_site green; pyright clean. --- .../adapters/in_memory_permit_lookup.py | 113 ++++++++++++++++ apps/api/src/cora/infrastructure/kernel.py | 8 ++ .../ports/federation/__init__.py | 6 + .../ports/federation/permit_lookup.py | 83 ++++++++++++ .../test_in_memory_permit_lookup.py | 123 ++++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py create mode 100644 apps/api/src/cora/infrastructure/ports/federation/permit_lookup.py create mode 100644 apps/api/tests/unit/federation/test_in_memory_permit_lookup.py 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..f8e65873e --- /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 = 1, + ) -> 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 = 1, + ) -> 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/infrastructure/kernel.py b/apps/api/src/cora/infrastructure/kernel.py index 65754e979..87abfdc7e 100644 --- a/apps/api/src/cora/infrastructure/kernel.py +++ b/apps/api/src/cora/infrastructure/kernel.py @@ -54,6 +54,11 @@ SupplyLookup, TokenVerifier, ) +from cora.infrastructure.ports.federation import ( + PermitLookup, + PublishPort, + SignaturePort, +) @dataclass(frozen=True) @@ -180,6 +185,9 @@ class Kernel: 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/federation/__init__.py b/apps/api/src/cora/infrastructure/ports/federation/__init__.py index 10fdf1e54..85cc86a93 100644 --- a/apps/api/src/cora/infrastructure/ports/federation/__init__.py +++ b/apps/api/src/cora/infrastructure/ports/federation/__init__.py @@ -29,6 +29,10 @@ 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 @@ -83,6 +87,8 @@ "FederationTrustContext", "FetchProvenance", "NoAdapterForFacilityError", + "PermitLookup", + "PermitLookupResult", "PublicationStatus", "PublishPort", "PublishReceipt", 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/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] From 7eb23ef23f4945bf5c47f74279a30b2211c61cb3 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 09:52:09 +0300 Subject: [PATCH 11/13] feat(federation): Stage-3d2 publish-revision events + decider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second of 3 commits scoping the Stage 3d canary. This commit lands the cross-BC event pair (CalibrationRevisionPublished on the Calibration stream + PublicationReceiptRecorded on the matching outbound Permit stream) plus the pure publish_revision decider that emits them. The handler + route + tool + integration test + arch fitness land in Stage 3d3. Cross-BC event shapes: - CalibrationRevisionPublished (cora.calibration.aggregates.calibration.events): calibration_id, revision_id, outbound_permit_id, signature_envelope_kind, signing_version, signature_bytes_hex (raw bytes -> hex for jsonb storage; bytes.fromhex round-trip on read), signature_kid, receipt_id, published_at, published_by_actor_id, publication_status, occurred_at - PublicationReceiptRecorded (cora.federation.aggregates.permit.events): permit_id, content_hash, home_stream_type, home_stream_id, home_artifact_id, receipt_id, recorded_at, occurred_at (denorm home_stream_* triple so cross-stream audit joins do not require a separate index) Both events fold as no-op on aggregate state for the canary; the publication block on CalibrationRevision + receipts tracking on Permit are deferred to a follow-up alongside the projection write- path. The event log is the source of truth; aggregate read-back of publication metadata lands when the projection materializes. Domain errors (cora.calibration.aggregates.calibration.state): - CalibrationRevisionNotFoundError: the named revision is not on this aggregate's revisions tuple - CalibrationRevisionMissingContentHashError: legacy pre-rollout revisions carry content_hash=None per the additive-event pattern; publishing requires a deterministic artifact hash - OutboundPermitNotActiveError: covers both unresolved (sentinel status "") and non-Active statuses (Defined, Suspended, Revoked) under one error class so operator diagnostics surface a uniform message publish_revision slice (cora.calibration.features.publish_revision): - command.py: PublishCalibrationRevision(calibration_id, revision_id, peer_facility_id) — caller-controlled inputs only; signature envelope, receipt_id, published_at, principal are handler-injected - decider.py: pure decide(...) that validates the loaded Calibration + PermitLookup result + handler-injected params, raises the appropriate domain error, or emits the PublishRevisionEvents pair (calibration_event, permit_event) for the handler to append_streams atomically - __init__.py: re-exports the command + decider + events pair Stage 3d2 deliberately does NOT validate: - DCO chain shape (deferred to handler-tier + verify-then-apply orchestrator + dedicated arch fitness in Stage 3d3) - Permit terms allowing artifact kind (handler-tier PermitLookup already keys on artifact_kind; lookup-hits-at-all is the kind-match evidence) - Signature envelope correctness (SignaturePort.sign owns the canonicalization-vs-signing-version invariant) - Idempotency of repeated publish (defer state-folded is_published check to follow-up; handler wraps with Idempotency-Key per AppendRevision precedent) 10 paired decider unit tests pin: happy path emits both events with locked field shapes; calibration_id=None raises NotFound; unknown revision_id raises RevisionNotFound; legacy revision without content_hash raises MissingContentHash; lookup miss + each non-Active permit status raises OutboundPermitNotActive; multi-revision aggregate picks the target revision correctly; signature_bytes_hex round-trips via bytes.fromhex. Worktree commit: 526/526 calibration + federation unit tests green; pyright clean. --- .../aggregates/calibration/__init__.py | 8 + .../aggregates/calibration/events.py | 110 +++- .../aggregates/calibration/evolver.py | 4 + .../aggregates/calibration/state.py | 58 ++ .../features/publish_revision/__init__.py | 22 + .../features/publish_revision/command.py | 35 ++ .../features/publish_revision/decider.py | 175 ++++++ apps/api/src/cora/calibration/routes.py | 29 +- .../federation/aggregates/permit/events.py | 78 ++- .../federation/aggregates/permit/evolver.py | 4 + apps/api/tach.toml | 12 +- .../tests/architecture/test_slice_contract.py | 12 +- .../architecture/test_slice_test_coverage.py | 14 + .../test_slice_verb_names_subject.py | 1 + .../test_publish_revision_decider.py | 214 +++++++ ...est_publish_revision_decider_properties.py | 534 ++++++++++++++++++ 16 files changed, 1305 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/cora/calibration/features/publish_revision/__init__.py create mode 100644 apps/api/src/cora/calibration/features/publish_revision/command.py create mode 100644 apps/api/src/cora/calibration/features/publish_revision/decider.py create mode 100644 apps/api/tests/unit/calibration/test_publish_revision_decider.py create mode 100644 apps/api/tests/unit/calibration/test_publish_revision_decider_properties.py 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/features/publish_revision/__init__.py b/apps/api/src/cora/calibration/features/publish_revision/__init__.py new file mode 100644 index 000000000..0e3b62ce7 --- /dev/null +++ b/apps/api/src/cora/calibration/features/publish_revision/__init__.py @@ -0,0 +1,22 @@ +"""publish_revision slice: publish a Calibration revision to a peer. + +Stage 3d2 ships the EVENT shapes + DECIDER only; the handler + +route + tool + cross-BC append_streams + integration test land in +Stage 3d3 alongside the arch fitness tests. The decider is +exercised end-to-end via unit tests against the locked event shape +so the cross-BC contract is provable before any handler IO lands. +""" + +from cora.calibration.features.publish_revision.command import ( + PublishCalibrationRevision, +) +from cora.calibration.features.publish_revision.decider import ( + PublishRevisionEvents, + decide, +) + +__all__ = [ + "PublishCalibrationRevision", + "PublishRevisionEvents", + "decide", +] 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/routes.py b/apps/api/src/cora/calibration/routes.py index 3b1ba04b8..2cff19b43 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 @@ -73,6 +76,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. @@ -105,11 +124,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/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/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_slice_contract.py b/apps/api/tests/architecture/test_slice_contract.py index 7364b8b87..ad8f51867 100644 --- a/apps/api/tests/architecture/test_slice_contract.py +++ b/apps/api/tests/architecture/test_slice_contract.py @@ -62,7 +62,17 @@ # Slices currently in flight. Each entry MUST cite the phase that # will close it; reviewers should reject additions that don't. -WIP_SLICES: frozenset[str] = frozenset() +WIP_SLICES: frozenset[str] = frozenset( + { + # First per-BC publish slice canary; event shapes + pure + # decider landed first so the cross-BC PublishedArtifact + # contract is provable before any handler IO lands. The + # handler + route + tool + arch fitness for ai-cannot-sign-off + # + cross-BC dual-stream land in the follow-up commit per + # the planning workflow's 3d1 -> 3d2 -> 3d3 split. + "cora.calibration.features.publish_revision", + } +) def _qualified(slice_dir: Path) -> str: diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index aa214be35..130a6c8d3 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -85,6 +85,12 @@ EXEMPT_FROM_ENDPOINT_CONTRACT: frozenset[str] = frozenset( { + # First per-BC publish slice canary: event shapes + pure + # decider landed first so the cross-BC PublishedArtifact + # contract is provable before any handler IO lands. Route + + # endpoint contract test land in the follow-up commit per + # the planning workflow's 3d1 -> 3d2 -> 3d3 split. + "cora.calibration.features.publish_revision", # Safety clearance lifecycle: covered by URL-only FSM-walk tests # in `test_clearance_fsm_walk_endpoints.py`, which walks the full # FSM via HTTP calls (no slice-name imports or string mentions). @@ -124,6 +130,10 @@ EXEMPT_FROM_MCP_CONTRACT: frozenset[str] = frozenset( { + # First per-BC publish slice canary: MCP tool + MCP contract + # test land in the follow-up commit per the planning workflow's + # 3d1 -> 3d2 -> 3d3 split. + "cora.calibration.features.publish_revision", # Agent lifecycle: all 5 slices covered by the bundled # `test_iter2_mcp_tools.py`. "cora.agent.features.grant_tool_to_agent", @@ -163,6 +173,10 @@ EXEMPT_FROM_HANDLER_UNIT: frozenset[str] = frozenset( { + # First per-BC publish slice canary: handler + unit test land + # in the follow-up commit per the planning workflow's + # 3d1 -> 3d2 -> 3d3 split. + "cora.calibration.features.publish_revision", # --- TODO: real gaps to fill ----------------------------------- # Query handlers compose the `list_query` factory and a thin # adapter call — they're covered transitively by integration 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/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 From d2fdbc05fe964e1ec71bac855e9d86f62926264b Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 13:11:24 +0300 Subject: [PATCH 12/13] feat(federation): Stage-3d3 publish_revision handler + route + tool + wire (Stage 3d CLOSED) Third (and final) commit of the Stage 3d canary. Closes the publish_revision slice end-to-end: handler emits the cross-BC event pair via EventStore.append_streams; REST route + MCP tool + CalibrationHandlers wire-up + idempotency wrapping all land. The slice is no longer in WIP_SLICES; only the endpoint + MCP contract tests remain on EXEMPT (they land in a small follow-up). Handler (cora.calibration.features.publish_revision.handler): - Handler + IdempotentHandler Protocols (mirrors AppendRevision) - bind(deps) -> Handler: validates publish-side deps (publish_port + signature_port + permit_lookup) are non-None at bind time; raises PublishPortNotWiredError (BC-application-layer error in cora.calibration.errors) so misconfiguration surfaces at startup not mid-request - handler(...) loads Calibration, resolves outbound Permit, canonicalizes via deps.canonicalization_registry default, signs via signature_port, publishes via publish_port, then decides + emits the event pair atomically via event_store.append_streams onto both the Calibration and Permit streams in one transaction - Returns receipt_id for audit anchoring REST route (cora.calibration.features.publish_revision.route): - POST /calibrations/{calibration_id}/revisions/{revision_id}/publish - Body: peer_facility_id; Idempotency-Key header supported - 201 + {receipt_id}; 403/404/409 mapped from the existing Calibration BC exception handler taxonomy MCP tool (cora.calibration.features.publish_revision.tool): - Tool name: publish_revision - Mirrors REST shape; output: PublishCalibrationRevisionOutput carrying receipt_id Wire (cora.calibration.wire.CalibrationHandlers): - New publish_revision: IdempotentHandler field - wire_calibration() wraps publish_revision.bind(deps) with with_idempotency + with_tracing under command_name "PublishCalibrationRevision" routes.py + tools.py registration: include publish_revision router + register the MCP tool alongside the existing slices. 10 paired handler unit tests pin: - bind() PublishPortNotWiredError surface for missing deps - bind() partial-missing surface lists only the gaps - happy path emits both events with locked field shapes - both events share the same transaction_id (atomic append_streams contract; the single load-bearing cross-BC canary value) - publish_port receives the canonicalized + signed artifact - authz Deny raises UnauthorizedError before any IO - CalibrationNotFoundError short-circuits before sign / publish - Suspended permit raises OutboundPermitNotActiveError - Legacy content_hash=None raises CalibrationCannotPublishRevisionError - persisted signing_version round-trips via bytes.fromhex on the signature_bytes_hex payload field openapi.json regenerated with the new POST endpoint surface. WIP_SLICES emptied (publish_revision now ships full slice files); EXEMPT_FROM_HANDLER_UNIT entry removed (handler unit tests now exist); EXEMPT_FROM_ENDPOINT_CONTRACT + EXEMPT_FROM_MCP_CONTRACT still carry publish_revision until the contract test suite lands. Worktree commit: 10/10 new handler tests + 14207 prior tests green; pyright clean; tach clean; openapi snapshot fresh. --- apps/api/openapi.json | 162 +++++++++ apps/api/src/cora/calibration/errors.py | 18 + .../features/publish_revision/__init__.py | 23 +- .../features/publish_revision/handler.py | 336 ++++++++++++++++++ .../features/publish_revision/route.py | 99 ++++++ .../features/publish_revision/tool.py | 74 ++++ apps/api/src/cora/calibration/routes.py | 2 + apps/api/src/cora/calibration/tools.py | 5 + apps/api/src/cora/calibration/wire.py | 14 + .../tests/architecture/test_slice_contract.py | 12 +- .../architecture/test_slice_test_coverage.py | 4 - .../test_publish_revision_handler.py | 318 +++++++++++++++++ 12 files changed, 1047 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/cora/calibration/features/publish_revision/handler.py create mode 100644 apps/api/src/cora/calibration/features/publish_revision/route.py create mode 100644 apps/api/src/cora/calibration/features/publish_revision/tool.py create mode 100644 apps/api/tests/unit/calibration/test_publish_revision_handler.py 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/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 index 0e3b62ce7..98ae5f2ef 100644 --- a/apps/api/src/cora/calibration/features/publish_revision/__init__.py +++ b/apps/api/src/cora/calibration/features/publish_revision/__init__.py @@ -1,12 +1,14 @@ """publish_revision slice: publish a Calibration revision to a peer. -Stage 3d2 ships the EVENT shapes + DECIDER only; the handler + -route + tool + cross-BC append_streams + integration test land in -Stage 3d3 alongside the arch fitness tests. The decider is -exercised end-to-end via unit tests against the locked event shape -so the cross-BC contract is provable before any handler IO lands. +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, ) @@ -14,9 +16,20 @@ 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/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 2cff19b43..ce087b341 100644 --- a/apps/api/src/cora/calibration/routes.py +++ b/apps/api/src/cora/calibration/routes.py @@ -46,6 +46,7 @@ define_calibration, get_calibration, list_calibrations, + publish_revision, ) @@ -113,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 ( 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/tests/architecture/test_slice_contract.py b/apps/api/tests/architecture/test_slice_contract.py index ad8f51867..7364b8b87 100644 --- a/apps/api/tests/architecture/test_slice_contract.py +++ b/apps/api/tests/architecture/test_slice_contract.py @@ -62,17 +62,7 @@ # Slices currently in flight. Each entry MUST cite the phase that # will close it; reviewers should reject additions that don't. -WIP_SLICES: frozenset[str] = frozenset( - { - # First per-BC publish slice canary; event shapes + pure - # decider landed first so the cross-BC PublishedArtifact - # contract is provable before any handler IO lands. The - # handler + route + tool + arch fitness for ai-cannot-sign-off - # + cross-BC dual-stream land in the follow-up commit per - # the planning workflow's 3d1 -> 3d2 -> 3d3 split. - "cora.calibration.features.publish_revision", - } -) +WIP_SLICES: frozenset[str] = frozenset() def _qualified(slice_dir: Path) -> str: diff --git a/apps/api/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index 130a6c8d3..db5486727 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -173,10 +173,6 @@ EXEMPT_FROM_HANDLER_UNIT: frozenset[str] = frozenset( { - # First per-BC publish slice canary: handler + unit test land - # in the follow-up commit per the planning workflow's - # 3d1 -> 3d2 -> 3d3 split. - "cora.calibration.features.publish_revision", # --- TODO: real gaps to fill ----------------------------------- # Query handlers compose the `list_query` factory and a thin # adapter call — they're covered transitively by integration 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..15758aa2d --- /dev/null +++ b/apps/api/tests/unit/calibration/test_publish_revision_handler.py @@ -0,0 +1,318 @@ +"""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_publish_deps_not_wired_on_kernel() -> None: + deps = build_deps() + 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_wired_lists_only_the_missing_ones() -> None: + deps = build_deps() + deps = replace(deps, publish_port=InMemoryPublishPort()) + 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 From 20d859c5669e8377fbebb9dc39cbc00a5fa08813 Mon Sep 17 00:00:00 2001 From: Doga Gursoy Date: Mon, 1 Jun 2026 14:16:59 +0300 Subject: [PATCH 13/13] feat(federation): Stage-3d4 contract tests + InMemory federation defaults (Stage 3d EXEMPTS CLOSED) Closes the iter-b Stage 3d publish_revision exemptions: REST endpoint contract test + MCP tool contract test + the matching architecture fitness allowlist removals. Also fixes a latent Stage 3d3 wiring bug where adding publish_revision to wire_calibration without defaulting the 3 publish-side adapters broke every existing contract test. Bug fix in cora/infrastructure/deps.py: - make_postgres_kernel + make_inmemory_kernel now default-wire publish_port=InMemoryPublishPort(), signature_port=InMemorySignaturePort(), permit_lookup=InMemoryPermitLookup() when callers do not supply overrides. Without this, PublishPortNotWiredError fired during wire_calibration -> publish_revision.bind on every TestClient instantiation, surfacing as cascading contract-test failures. Production callers pre-build via build_kernel; the defaults are test-tier (and the wire-tier real adapters land in Stage 4 by overriding these slots). Bug fix in cora/federation/adapters/in_memory_permit_lookup.py: - register_outbound + register_inbound now default current_version=0 (matching an empty Permit stream the test setup never seeded). The prior current_version=1 default caused ConcurrencyError on append_streams in any test that seeded a permit through the convenience verb without separately appending a real PermitDefined event. Endpoint contract tests (tests/contract/test_publish_revision_endpoint.py): - happy-path 201 + receipt_id round-trip via the FastAPI surface - 404 when calibration missing - 404 when revision missing on existing calibration - 409 when no Active outbound Permit for the peer - 422 when peer_facility_id missing from request body - 422 for malformed UUID path params - 201 with Idempotency-Key header present - response body shape locked to {receipt_id} MCP tool contract tests (tests/contract/test_publish_revision_mcp_tool.py): - publish_revision listed by tools/list - happy-path tools/call returns structuredContent with receipt_id - outputSchema carries receipt_id property Bind-handler test surface widened: the prior tests that exercised PublishPortNotWiredError via "build_deps() returns a kernel without the publish ports" no longer apply (defaults now wire them). Tests updated to explicitly set publish_port=None / signature_port=None / permit_lookup=None via dataclasses.replace before bind() to preserve the misconfiguration-surface coverage. EXEMPT_FROM_ENDPOINT_CONTRACT + EXEMPT_FROM_MCP_CONTRACT entries for cora.calibration.features.publish_revision removed (the contract test files now exist + cover every status code). Worktree commit: 28/28 affected unit + contract tests green; 14219 total tests green; pyright clean; tach clean. --- apps/api/src/cora/api/main.py | 12 ++ .../adapters/in_memory_permit_lookup.py | 4 +- apps/api/src/cora/infrastructure/deps.py | 32 +++- .../architecture/test_slice_test_coverage.py | 10 - .../test_publish_revision_endpoint.py | 172 ++++++++++++++++++ .../test_publish_revision_mcp_tool.py | 138 ++++++++++++++ apps/api/tests/unit/_helpers.py | 12 ++ .../test_publish_revision_handler.py | 7 +- 8 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 apps/api/tests/contract/test_publish_revision_endpoint.py create mode 100644 apps/api/tests/contract/test_publish_revision_mcp_tool.py 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/federation/adapters/in_memory_permit_lookup.py b/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py index f8e65873e..1f5530b67 100644 --- a/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py +++ b/apps/api/src/cora/federation/adapters/in_memory_permit_lookup.py @@ -50,7 +50,7 @@ def register_outbound( permit_id: UUID, status: str = "Active", abi_tier_floor: str = "Stable", - current_version: int = 1, + current_version: int = 0, ) -> PermitLookupResult: """Convenience: seed an outbound permit; returns the seeded result for assertions.""" result = PermitLookupResult( @@ -77,7 +77,7 @@ def register_inbound( permit_id: UUID, status: str = "Active", abi_tier_floor: str = "Stable", - current_version: int = 1, + current_version: int = 0, ) -> PermitLookupResult: """Convenience: seed an inbound permit; returns the seeded result for assertions.""" result = PermitLookupResult( diff --git a/apps/api/src/cora/infrastructure/deps.py b/apps/api/src/cora/infrastructure/deps.py index 047319c3e..6234be64d 100644 --- a/apps/api/src/cora/infrastructure/deps.py +++ b/apps/api/src/cora/infrastructure/deps.py @@ -39,10 +39,19 @@ 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, ) @@ -135,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. @@ -215,6 +227,9 @@ def make_postgres_kernel( 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, @@ -238,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. @@ -312,6 +330,9 @@ def make_inmemory_kernel( 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 @@ -437,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]: @@ -492,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 @@ -544,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/tests/architecture/test_slice_test_coverage.py b/apps/api/tests/architecture/test_slice_test_coverage.py index db5486727..aa214be35 100644 --- a/apps/api/tests/architecture/test_slice_test_coverage.py +++ b/apps/api/tests/architecture/test_slice_test_coverage.py @@ -85,12 +85,6 @@ EXEMPT_FROM_ENDPOINT_CONTRACT: frozenset[str] = frozenset( { - # First per-BC publish slice canary: event shapes + pure - # decider landed first so the cross-BC PublishedArtifact - # contract is provable before any handler IO lands. Route + - # endpoint contract test land in the follow-up commit per - # the planning workflow's 3d1 -> 3d2 -> 3d3 split. - "cora.calibration.features.publish_revision", # Safety clearance lifecycle: covered by URL-only FSM-walk tests # in `test_clearance_fsm_walk_endpoints.py`, which walks the full # FSM via HTTP calls (no slice-name imports or string mentions). @@ -130,10 +124,6 @@ EXEMPT_FROM_MCP_CONTRACT: frozenset[str] = frozenset( { - # First per-BC publish slice canary: MCP tool + MCP contract - # test land in the follow-up commit per the planning workflow's - # 3d1 -> 3d2 -> 3d3 split. - "cora.calibration.features.publish_revision", # Agent lifecycle: all 5 slices covered by the bundled # `test_iter2_mcp_tools.py`. "cora.agent.features.grant_tool_to_agent", 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/calibration/test_publish_revision_handler.py b/apps/api/tests/unit/calibration/test_publish_revision_handler.py index 15758aa2d..ec2b050b6 100644 --- a/apps/api/tests/unit/calibration/test_publish_revision_handler.py +++ b/apps/api/tests/unit/calibration/test_publish_revision_handler.py @@ -128,8 +128,9 @@ def _command( ) -def test_bind_raises_when_publish_deps_not_wired_on_kernel() -> None: +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) == { @@ -139,9 +140,9 @@ def test_bind_raises_when_publish_deps_not_wired_on_kernel() -> None: } -def test_bind_with_only_some_deps_wired_lists_only_the_missing_ones() -> None: +def test_bind_with_only_some_deps_unset_lists_only_the_missing_ones() -> None: deps = build_deps() - deps = replace(deps, publish_port=InMemoryPublishPort()) + 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