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:

  1. First-time + re-activation delivery — via the thingmake activation saga’s Leg 1 RoleProvision dispatch (Phase 34, this document).

  2. 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

kid

string

"thingmint-intermediate-ca-g7"

CA-generation alias; matches the JWT kid published by POST /api/v1/license-tokens/sign and the JWK kid published at /.well-known/jwks.json. Form: thingmint-intermediate-ca-g{N} per Phase 32 D-09 / Phase 33 D-12. Resolved server-side by JWKS-join (see §3).

generation

int

7

The integer N from kid. Surfaced as a separate int so factory backends can compare without re-parsing the kid string. MUST equal N parsed from kid’s g{N} suffix.

fingerprint_sha256

string

"3c8a9f...<64 lowercase hex chars>"

Lowercase hex of SHA-256(cert.Raw) (the cert’s DER encoding). Stable handle for “this cert” across bazaar #5’s rotation overlap window. Factory backends MUST use this as the dedup key (see §4).

not_after

string

"2027-06-24T12:34:56.123456789Z"

RFC3339Nano timestamp of the cert’s expiry (cert.NotAfter.UTC().Format(time.RFC3339Nano)). Factory backends can pre-warn ahead of the rotation cliff.

pem

string

"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"

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 trust_anchor?

Rationale

PROVISION

{"op":"provision"}

NO

First-time identity-cert issuance; device has no identity yet. Trust anchor lands on the subsequent ACTIVATE.

ACTIVATE (from PROVISIONED)

{"op":"activate-cert", ...}

YES

First-time activation; trust anchor delivered alongside identity-cert activation.

ACTIVATE (from DEACTIVATED)

{"op":"activate-cert", ...}

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.”

DEACTIVATE

{"op":"revoke-cert"}

NO

Revocation flow; no anchor change.

ANNIHILATE

{"op":"revoke-cert-permanent"}

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:

  1. Calls GET /api/v1/ca/current/cert.pem against thingmint (Phase 33 D-10 — unauthenticated, public-readable; Content-Type: application/x-pem-file).

  2. Calls GET /.well-known/jwks.json against thingmint (Phase 33 D-11). Used to resolve the cert’s kid by SubjectPublicKeyInfo-fingerprint join: for each JWK, the client reconstructs SHA-256(MarshalPKIXPublicKey(jwk.PublicKey)) and matches against SHA-256(MarshalPKIXPublicKey(cert.PublicKey)). Why a join is needed: the cert’s Subject.CommonName is hardcoded "trustmint-thingmint-ca" cross-generation by BuildSyntheticIssuerCert, so the per-generation alias lives only in the JWK kid field. 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.

  3. Computes the fingerprint (hex.EncodeToString(sha256.Sum256(cert.Raw))), reads cert.NotAfter, and assembles the envelope per §1.

  4. 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/factorystubGET /__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

THM-TRUST-ANCHOR-FETCH-FAILED

trust-anchor-fetch-failed

503

Thingmake’s GET /api/v1/ca/current/cert.pem or GET /.well-known/jwks.json returned non-2xx, timed out, or the response body was unreadable. Signals upstream-unavailable.

Standard drain.go retry per MaxAttempts; rolls back to source state on exhaustion.

THM-TRUST-ANCHOR-MALFORMED

trust-anchor-malformed

502

PEM parsed but the cert is unusable (no kid match on JWKS-join, expired before delivery, missing fields). Signals upstream-talking-gibberish.

Standard drain.go retry; more likely a thingmint bug than transient.

THM-TRUST-ANCHOR-REJECTED

trust-anchor-rejected

422

Factory backend returned 4xx on the provision-leg dispatch when the payload included trust_anchor. Distinguishable from generic provision-cert-rejection by inspecting the factory’s problem+json code field.

Standard drain.go retry; rolls back to source state on exhaustion.

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:

  • PROVISIONED on a first-time activation (ACTIVATE from PROVISIONED).

  • DEACTIVATED on 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_sha256 invariant 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-wire THM-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 the fgai/ddi-gateway sibling 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.Envelope and 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.