feat(federation): Stage 1b through 3d (canonicalization + signing + publish-revision)#27
Open
xmap wants to merge 13 commits into
Open
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Signer.signwidened to return(signature, kid, signing_version);signature_versioncolumn on events with matched-pair invariant.publish_revisionslice on the Calibration BC. PermitLookup port + Kernel widening; cross-BCCalibrationRevisionPublished+PublicationReceiptRecordedevents + 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:
calibration/aggregates/calibration/events.pyandfederation/aggregates/permit/events.py— origin/main migrated thosefrom_storedcases to thedeserialize_or_raisehelper pattern. The newCalibrationRevisionPublishedandPublicationReceiptRecordedcases were ported to that pattern for style consistency.calibration/routes.pyandcalibration/wire.py— origin/main hasappend_calibration_revision(the post-rename name) while local hadappend_revision(pre-rename). The PR keeps the post-rename name and addspublish_revisionalongside.publish_revisiondecider — Stage-3d2 introduced the decider; thetest_decider_has_paired_property_based_testarch fitness requires a sibling PBT. Authoredtests/unit/calibration/test_publish_revision_decider_properties.pywith 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.revision(Calibration: publish_revision) — folded into Stage-3d2.Test plan
test_publish_revision_endpoint.py,test_publish_revision_mcp_tool.py).🤖 Generated with Claude Code