Skip to content

feat(federation): Stage 1b through 3d (canonicalization + signing + publish-revision)#27

Open
xmap wants to merge 13 commits into
hotfix-slice-verb-allowlist-event-reactionfrom
pr-federation-stage-1b-through-3d
Open

feat(federation): Stage 1b through 3d (canonicalization + signing + publish-revision)#27
xmap wants to merge 13 commits into
hotfix-slice-verb-allowlist-event-reactionfrom
pr-federation-stage-1b-through-3d

Conversation

@xmap
Copy link
Copy Markdown
Owner

@xmap xmap commented Jun 2, 2026

Summary

Ships the iter-b federation chain — Stage 1b (canonicalization + signing scaffolds) through Stage 3d (publish-revision cross-BC slice). 14 commits total (1 hotfix base + 13 federation).

Stacked on #25 (hotfix-slice-verb-allowlist-event-reaction). When #25 merges, GitHub auto-rebases this to target main.

Why these stages

The chain delivers the verify-then-apply federation primitive end-to-end:

  1. Stage 1b1-1b3 — CanonicalizationPort + SigningPort scaffolds, Default v1 adapters with golden-vector fitness, registries + Kernel wire-up.
  2. Stage 2a / 2b — federation port subpackage, in-memory PublishPort + PullPort + SignaturePort adapters, FederationRegistry composite + envelope-arms fitness.
  3. Stage 2 closeoutSigner.sign widened to return (signature, kid, signing_version); signature_version column on events with matched-pair invariant.
  4. Stage 3c — shared kernel verify-then-apply orchestrator.
  5. Stage 3d1-3d4 — first home-BC consumer: publish_revision slice on the Calibration BC. PermitLookup port + Kernel widening; cross-BC CalibrationRevisionPublished + PublicationReceiptRecorded events + decider; handler + route + tool + wire; contract tests + InMemory federation defaults.

What was needed to port to origin/main

These 13 commits were authored on local main before the recent origin-side work landed. Three classes of porting adjustments folded into the cherry-pick:

  • Conflict resolution in calibration/aggregates/calibration/events.py and federation/aggregates/permit/events.py — origin/main migrated those from_stored cases to the deserialize_or_raise helper pattern. The new CalibrationRevisionPublished and PublicationReceiptRecorded cases were ported to that pattern for style consistency.
  • Conflict resolution in calibration/routes.py and calibration/wire.py — origin/main has append_calibration_revision (the post-rename name) while local had append_revision (pre-rename). The PR keeps the post-rename name and adds publish_revision alongside.
  • New PBT for publish_revision decider — Stage-3d2 introduced the decider; the test_decider_has_paired_property_based_test arch fitness requires a sibling PBT. Authored tests/unit/calibration/test_publish_revision_decider_properties.py with 7 Hypothesis tests covering: genesis-as-error, revision-not-found precedence, legacy-revision guard, non-Active permit, permit=None branch, event-shape stability (both events), purity. Folded into the Stage-3d2 commit.
  • Slice-verb fitness allowlist extended for revision (Calibration: publish_revision) — folded into Stage-3d2.

Test plan

  • CI runs full unit + arch + contract suites. Locally: 21724 unit + arch tests pass.
  • Verify Stage 1b1-1b3 canonicalization + signing scaffolds compose correctly.
  • Verify Stage 3d publish_revision end-to-end via contract tests (test_publish_revision_endpoint.py, test_publish_revision_mcp_tool.py).
  • After fix(arch): extend slice-verb fitness allowlist for event + reaction #25 merges, GitHub re-targets this to main automatically.

🤖 Generated with Claude Code

xmap added 13 commits June 2, 2026 23:55
…affolds

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.
…itness

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.<kebab>+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.
…OSED)

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.
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.
…naturePort

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.
…s (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.
…rsion)

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.
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 "<vendor>/<version>"
    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.
… 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.
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.
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 "<unresolved>") 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.
… 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.
…ults (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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant