Trust Anchor Delivery — Activation Saga Payload Contract¶
Status: Active — 2026-06-24
Owner: trustmint (services/thingmake activation saga; services/thingmint intermediate-CA endpoint)
Implements: REQ-TRUST-ANCHOR-DELIVERY-01.
Audience: Factory integrators implementing the provision-role HTTP backend; bazaar OTA cascade implementers (bazaar #5); FGAI platform engineers reasoning about CA rotation cliffs.
ADR refs: ADR-0042 D-4 (Option B — platform-controlled trust); ADR-0026 D-4 extension (activation saga grows a trust-anchor-delivery leg); Phase 33 D-10 (cert.pem endpoint) + D-11 (JWKS endpoint) + D-12 (kid format).
This document is the canonical cross-repo contract for the trust-anchor envelope that ships from the trustmint platform to a factory-registered device trust store. Two distinct delivery paths reuse the envelope byte-for-byte:
First-time + re-activation delivery — via the thingmake activation saga’s Leg 1 RoleProvision dispatch (Phase 34, this document).
Steady-state rotation cascade — via bazaar’s hawkBit OTA payload (bazaar #5; cross-repo, partner work).
Any drift between the production envelope and this document is a wire-format incident. The struct of record on the trustmint side is services/thingmake/internal/trustanchor/envelope.go::Envelope.
1. Wire envelope (D-04)¶
The trust_anchor JSON object embedded in the Leg 1 dispatch body has EXACTLY these fields. No surplus keys, no renamed keys, no reordered field semantics. Wire compatibility with bazaar #5’s hawkBit OTA payload depends on byte-equivalence.
Field |
Type |
Example |
Meaning |
|---|---|---|---|
|
string |
|
CA-generation alias; matches the JWT |
|
int |
|
The integer |
|
string |
|
Lowercase hex of |
|
string |
|
RFC3339Nano timestamp of the cert’s expiry ( |
|
string |
|
Raw PEM block (~2-4 KB). Factory backends MUST write this verbatim into the device trust store; thingmake does NOT pre-parse the cert for the backend beyond fingerprinting. |
The Go struct of record (services/thingmake/internal/trustanchor/envelope.go):
type Envelope struct {
Kid string `json:"kid"`
Generation int `json:"generation"`
FingerprintSHA256 string `json:"fingerprint_sha256"`
NotAfter string `json:"not_after"`
PEM string `json:"pem"`
}
Full wire example¶
The full Leg 1 RoleProvision dispatch body on an ACTIVATE saga (D-02):
{
"op": "activate-cert",
"trust_anchor": {
"kid": "thingmint-intermediate-ca-g7",
"generation": 7,
"fingerprint_sha256": "3c8a9f2b1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a",
"not_after": "2027-06-24T12:34:56.123456789Z",
"pem": "-----BEGIN CERTIFICATE-----\nMIIEazCCA1OgAwIBAgIUExampleCertificateDoNotUse...\n-----END CERTIFICATE-----\n"
}
}
The outer object is the leg payload byte-signed via the existing AK/SK signer (see services/thingmake/internal/saga/aksk.go); the Authorization headers + body bytes carry the signature. Factory backends verify the AK/SK signature against the registered access-key + secret per ADR-0026 Amendment H.
2. When the envelope is delivered (D-02, D-10, D-11)¶
Trust-anchor delivery is NOT carried on every saga leg. The Phase 34 D-10/D-11 invariant pins the verb / leg matrix:
Verb |
Leg 1 op |
Carries |
Rationale |
|---|---|---|---|
|
|
NO |
First-time identity-cert issuance; device has no identity yet. Trust anchor lands on the subsequent ACTIVATE. |
|
|
YES |
First-time activation; trust anchor delivered alongside identity-cert activation. |
|
|
YES |
Re-activation backstop (D-10). Devices that lost their trust store during a deactivation window recover here. Every ACTIVATE ships the trust anchor; no thingmake-side bookkeeping of “last-delivered fingerprint per thing.” |
|
|
NO |
Revocation flow; no anchor change. |
|
|
NO |
Permanent revocation; no anchor change. |
Invariant for factory backends. “When you see a provision-role activate-cert payload with a trust_anchor envelope, write it (or no-op when fingerprint_sha256 already matches a record in the trust store).” No further state needs tracking on the factory side.
3. Freshness guarantee (D-07)¶
The trust_anchor envelope is built at leg-dispatch time inside services/thingmake/internal/saga/drain.go. When the drain claims a saga-outbox row whose (verb, leg_id, role) == (ACTIVATE, 1, provision), it:
Calls
GET /api/v1/ca/current/cert.pemagainst thingmint (Phase 33 D-10 — unauthenticated, public-readable;Content-Type: application/x-pem-file).Calls
GET /.well-known/jwks.jsonagainst thingmint (Phase 33 D-11). Used to resolve the cert’skidby SubjectPublicKeyInfo-fingerprint join: for each JWK, the client reconstructsSHA-256(MarshalPKIXPublicKey(jwk.PublicKey))and matches againstSHA-256(MarshalPKIXPublicKey(cert.PublicKey)). Why a join is needed: the cert’sSubject.CommonNameis hardcoded"trustmint-thingmint-ca"cross-generation byBuildSyntheticIssuerCert, so the per-generation alias lives only in the JWKkidfield. Two-endpoint cross-check is also a security property (T-34-05): an attacker who swaps just the cert OR just the JWKS fails the join.Computes the fingerprint (
hex.EncodeToString(sha256.Sum256(cert.Raw))), readscert.NotAfter, and assembles the envelope per §1.Merges the envelope into the persisted base payload (
{"op":"activate-cert"}→{"op":"activate-cert", "trust_anchor": {...}}) and signs the merged body via the existing AK/SK signer.
Always-fresh. If a CA rotation lands mid-saga between handler-time and dispatch-time, the in-flight saga ships the new generation. There is no thingmake-side cert cache; there is no in-handler pre-fetch. The persisted saga-outbox row carries only the base payload — the envelope is spliced in transient memory at dispatch time and never written back.
Per-fetch timeout: configurable via THINGMAKE_THINGMINT_FETCH_TIMEOUT (default 3s).
Retry budget: covered by drain.go’s existing MaxAttempts (default 5).
4. Idempotency¶
4.1 Factory-side dedup invariant¶
Factory backends MUST be idempotent on fingerprint_sha256: if the device trust store already contains a cert with the same fingerprint, the write is a no-op. The fingerprint is the stable handle bazaar #5’s overlap-window logic also pivots on; mismatched dedup keys between thingmake-saga delivery and bazaar-OTA delivery would create duplicate trust-store entries on devices.
4.2 Request-side idempotency¶
The thingmake activation saga inherits the existing RFC 9529 Idempotency-Key header at the request boundary (see services/thingmake/internal/api/idempotency.go). A re-POST of the same key against the same (thing, ACTIVATE) returns the existing saga handle — NOT a new cert fetch.
4.3 Fresh-saga semantics across rotation¶
A fresh saga (new Idempotency-Key) re-fetches the cert at dispatch time. This means a deliberate operator-driven retry across an in-flight CA rotation picks up the new generation. This is intentional, per the §3 freshness guarantee.
4.4 Cross-attempt audit¶
Cross-attempt fingerprint variance is observable in the factorystub capture (services/thingmake/internal/testharness/factorystub — GET /__test/trust-anchors) and in the saga’s outbox-leg audit rows. Each dispatch attempt’s outbound payload is logged at INFO level by drain.go per Phase 34 D-07.
5. Failure semantics (D-12, D-13)¶
5.1 Error catalog¶
The 3 trust-anchor problem codes registered in services/thingmake/internal/api/errors.go:
Catalog code (Phase 36) |
In-code class |
HTTP status |
When emitted |
Retry shape |
|---|---|---|---|---|
|
|
503 |
Thingmake’s |
Standard |
|
|
502 |
PEM parsed but the cert is unusable (no kid match on JWKS-join, expired before delivery, missing fields). Signals upstream-talking-gibberish. |
Standard |
|
|
422 |
Factory backend returned 4xx on the provision-leg dispatch when the payload included |
Standard |
The in-code constants use lowercase-hyphen form per the existing Problem* convention; the THM-NNNN catalog-form names land in Phase 36 (REQ-THINGMAKE-ERROR-CATALOG-01) cross-service normalization.
5.2 Source-state rollback (D-13)¶
On terminal leg failure (retry budget exhausted), the saga rolls back to its source state via drain.go’s existing rollback machinery:
PROVISIONEDon a first-time activation (ACTIVATE from PROVISIONED).DEACTIVATEDon a re-activation (ACTIVATE from DEACTIVATED).
The DEACTIVATED-rollback invariant is locked by services/thingmake/internal/api/trust_anchor_integration_test.go::TestActivate_FromDeactivated_FailureRollsBackToDeactivated (Phase 34 Plan 05).
The failed leg’s last_error column contains the lowercase-hyphen code (e.g. trust-anchor-fetch-failed: GET cert.pem: status 503), inspectable via the saga’s audit trail. The thing’s state-change feed surfaces the rollback as a normal state transition.
6. Rotation cascade reference (bazaar #5)¶
Steady-state CA rotation does NOT happen through this saga path — it happens via bazaar’s hawkBit OTA cascade (ADR-0042 D-4). This contract document is the source-of-truth schema bazaar #5 will reuse:
The §1 wire envelope is byte-identical between thingmake activation and bazaar OTA rollouts. The factory-side parser SHOULD be the same code path.
The §4 idempotency-on-
fingerprint_sha256invariant is byte-identical. A trust store that already holds the fingerprint is a no-op write regardless of which delivery path delivered it.The §5 failure semantics differ — bazaar OTA has its own retry budget + rollout-group abort thresholds; thingmake activation uses
drain.go’s saga-leg retry. The on-wireTHM-TRUST-ANCHOR-*codes are thingmake-specific; bazaar emits its own catalog codes.
bazaar #5 issue: https://github.com/SiriusVoyager/bazaar/issues/5
If bazaar #5 diverges from §1 + §4 invariants, that is a bazaar-side bug — this doc is the source of truth (Phase 38 conformance suite will enforce on the trustmint side).
7. Out of scope for v2.10¶
Multi-CA overlap-window logic. Thingmake ships the single-current-CA case;
IssuerState.Current()is the source. The “trust BOTH old and new CAs during rotation overlap” logic lives on the device side via bazaar #5’s OTA payload schema and is deferred to ADR-0042 Phase F.Per-thing audit table of “last-delivered fingerprint” in thingmake. Forensics use factory-side delivery records + the saga’s outbox audit trail. An explicit thingmake-side audit table is a future hardening phase.
DDI-1 contract integration. Whether the trust-anchor envelope folds into the DDI-1 contract (
./DDI-1-CONTRACT.md, a redirect stub now that the DDI gateway has landed — the canonical contract lives in thefgai/ddi-gatewaysibling repo (SiriusVoyager/ddi-gateway) per ADR-0034 G-9) later is a future-doc-shaping decision; the wire contract here is the source of truth in the interim. The envelope is factory-backend-facing, not device-facing — DDI-1 covers the device-facing wire contract.Doc-vs-struct drift CI gate. A CI test that JSON-marshals a sample
trustanchor.Envelopeand asserts the §1.full-wire-example block is byte-equivalent is a v2.11 follow-up (T-34-25). Today the §1 field-name table is the human-enforced source of truth.
Change log¶
Date |
Phase |
Change |
|---|---|---|
2026-06-24 |
Phase 34 (Plan 06) |
Initial publication (Active). Cross-repo contract surface for bazaar #5 hawkBit OTA cascade + factory-integrator provision-role backends. |