Version: 0.4.6 (2026-08-18, close SPEC-015 requirement-ID migration; LOCKED settlement-capable receipt profile for SPEC-022 otherwise unchanged)
Depends on: SPEC-001 v1.6, SPEC-002 v1.4 (v1.5 candidate GET /v1/receipt-keys/<provider_id> buyer-safe pubkey resolver; v1.6 candidate /poolz catalog fields + /catalog/<catalog_id> + /catalog/pubkey per §M.4), SPEC-005 v0.3 (settlement/accounting semantics; v0.4+ chargeability successor expected for terminal-state rows), SPEC-006 v0.9, SPEC-008 v0.3 (hard — §5.3-5.6 model-hash semantics; §5.5 hash_status enum), SPEC-010 v1.5, SPEC-011 v0.5 (hard — §3.3.1 heartbeat model_hash; §3.2 warm-swap state machine; §3.3.0 opt-in gating), SPEC-013 v0.3, SPEC-022 v0.1.4 (hard — settlement-capable receipt profile consumer)
Change log v0.4.6 (2026-08-18, issue #1023 — close requirement-ID migration):
- Accepts
SPEC-015-R001..SPEC-015-R005as the exhaustive evidencable conformance units for this spec.requirement_id_migrationiscomplete. R002–R005 stay pending until a signed journey-result promotes them. Buyer retrieval remains SPEC-022-R006. No receipt tuple, wire, or verifier behavior change. Do not promote from this close.
Change log v0.4.5 (2026-08-18, issue #1023 — remaining conformance unit IDs):
- Registers
SPEC-015-R002..SPEC-015-R005inspecs/CONFORMANCE.jsonas pending anchors for historical v0.1–v0.3 verification, receipt-key lifecycle, pubkey trust root, and ingest/storage redaction. Buyer retrieval remains SPEC-022-R006. No receipt tuple, wire, or verifier behavior change.requirement_id_migrationremainspending. Do not promote.
Change log v0.4.4 (2026-08-17, issue #1010 — compute-integrity digest binding decision):
- Closes the SPEC-015 side of #1010. Request-start compute-integrity state
digests remain outside the v0.4 signed tuple and strict
usageobject. If externally reviewable compute-integrity binding is needed, it belongs in a separate SPEC-036 audit artifact keyed to the same request attempt and receipt tuple. A future SPEC-015 successor may reference that artifact only by defining a newreceipt_version; v0.4 MUST NOT gain optional digest fields.
Change log v0.4.3 (2026-08-17, issue #614 — preliminary paid-path conformance unit ID):
- Registers
SPEC-015-R001inspecs/CONFORMANCE.jsonas a pending preliminary conformance anchor for the paid buyer-path #614 slice. The ID groups the existing v0.4 settlement-capable receipt issuance/ingestion obligations without changing them. Remaining SPEC-015 clause migration is issue #1023. No receipt tuple, wire, or verifier behavior change.
Change log v0.4.2 (2026-06-30):
- Round-2 code audit fix pass: pins v0.4 non-streaming and streaming
output_hashto the samesettlement_output_v1JCS object, and alignsattempt_nwith the existing zero-based SPEC-002/SPEC-005 request-attempt identity.
Change log v0.4.1-draft (2026-06-30):
- Round-1 SPEC audit fix pass: pins canonical
usageschema, canonical route-snapshot object/digest input, receipt-key fingerprint algorithm, streaming output-prefix hashing/range rules, deterministic terminal-state chargeability, exact terminal timestamp authority, and v0.4 redaction compatibility for receipt-key rotation audit rows.
Change log v0.4.0-draft (2026-06-30):
- Adds §N, a settlement-capable receipt profile with
receipt_version: "4". v0.4 is the first SPEC-015 profile eligible to unblock SPEC-022 enforce mode. - Extends the signed tuple from v0.3's nine-field model-hash tuple to a strict settlement tuple that binds account scope, request id, monotonic route-attempt id, provider id, provider receipt-key identity, non-null model hash, terminal state, terminal-state timestamp, route-time verification snapshot, catalog identity/body digest, expected catalog hash, prompt/output hashes, and canonical usage.
- Defines streaming receipt issuance through coordinator-internal
channels that preserve OpenAI-compatible SSE framing: no extra
non-standard
data:receipt event is required for clients. - Defines terminal states and chargeability mapping for
normal_done,provider_error,buyer_cancel,gateway_timeout, andupstream_transport_disconnect. - Adds settlement-verifier outcome mapping into SPEC-022
pending,verified,quarantined, andzero_settled. - Adds coordinator ingestion/storage rules for raw receipt segregation, audit redaction, idempotency, replay rejection, first-terminal selection, late receipt quarantine, and internal verification APIs.
- Keeps v0.1/v0.2/v0.3 verification semantics unchanged for
historical receipts. v0.3 verifiers continue to classify
receipt_version: "4"asinconclusive: unknown_receipt_version.
Lock state v0.4: Round-3 code audit returned READY on
2026-06-30 after round-2 closure fixes. Security round 2, architect
round 1, Claude adversarial round 1, and Claude product round 1 were
already READY. The required SPEC-015 v0.4 audit loop reached
0 CRITICAL / 0 HIGH / 0 MEDIUM across all required lanes; see
specs/SPEC-015-v0-4-audit.md. SPEC-022 enforce-mode buyer debit and
provider-positive settlement MUST NOT be wired against SPEC-015 v0.3
receipts.
Change log v0.3.4 (2026-06-26, additive — issue #128):
- §10.4.2
warnings[]enum gainsnon_default_tls_trustkind withca_file_path(string) field. Verifiers MUST emit this warning when theMACPROVIDER_VERIFY_TLS_CA_FILEenv var is honored and successfully augments the TLS trust pool — surfaces silent trust widening that previously produced avalidresult with no visible indicator. Schema enum updated atphase7-verify/schemas/output.schema.json(all three result contexts). Preserves wire shape: pre-v0.3.4 consumers that ignore unknownkindvalues are unaffected; this is strictly additive.
Lock state v0.3: Round-3 codex audit returned READY TO LOCK across all three lenses (code, security, architect) on 2026-06-24 — see specs/SPEC-015-v0-3-audit.md. Three-round audit history captured 3 CRITICAL + 11 MAJOR + 4 MINOR + 1 QUESTION findings; all CRITICAL / MAJOR resolved across v0.3.1 (round-1 fix pass) and v0.3.2 (round-2 fix pass). One round-3 MINOR (stale "four new flags" wording in staged IMPL prompt) fixed in v0.3.3. v0.3 changes the wire shape (7-field tuple → 9-field tuple, adding model_hash and receipt_version) and per [[feedback-bundle-spec-impl-one-pr]] EXCEPTION rule ships SPEC-only (no bundled IMPL) because it is a major version bump with a downstream implementer; the BUILD prompt for IMPL is staged at specs/BUILD_SPEC_015_v0_3_MODELHASH_IMPL_PROMPT.md for the next session.
Lock state v0.2 (preserved for history): Round-5 codex audit returned READY TO LOCK 0/0/0/0 across all three lenses (code, security, architect) on 2026-06-23 — see specs/SPEC-015-v0-2-audit.md. Five-round audit history captured 5 CRITICAL + 11 MAJOR + 6 MINOR findings, all resolved. CF6 confirmed round-1 CRITICALs (CF1 trust-root architecture, CF2 time-window validity, CF3 schema strictness) structurally closed, not papered over.
Change log v0.3.3:
- Round-3 codex audit returned
READY TO LOCKacross all three lenses on 2026-06-24 (CODE 0/0/0/0; SECURITY 0/0/0/0; ARCHITECT 0/0/1/0). One round-3 MINOR (A6-R3 — staged IMPL prompt had two stale "four new flags" mentions on top-level summary lines after round-2 promoted the verifier flag set to five) fixed inspecs/BUILD_SPEC_015_v0_3_MODELHASH_IMPL_PROMPT.mdlines 29 and 286. SPEC text itself unchanged; v0.3.3 is the lock entry. - Three-round audit history rollup:
- Round 1: 3 CRITICAL + 9 MAJOR + 4 MINOR + 1 QUESTION (v0.3.0 → v0.3.1 fix pass)
- Round 2: 0 CRITICAL + 2 MAJOR + 0 MINOR + 0 QUESTION (v0.3.1 → v0.3.2 fix pass)
- Round 3: 0 CRITICAL + 0 MAJOR + 1 MINOR + 0 QUESTION (v0.3.2 → v0.3.3 lock)
- v0.3.3 LOCKED. Ships as the SPEC-only PR per the [[feedback-bundle-spec-impl-one-pr]] EXCEPTION rule for major version bumps with downstream implementers.
Change log v0.3.2:
- Round-2 codex audit fix pass against
specs/SPEC-015-v0-3-audit.mdround-2 sections. Round 2 returned 0 CRITICAL + 2 MAJOR + 0 MINOR across the three lenses (SECURITY: READY TO LOCK 0/0/0/0; CODE: READY WITH FIX PASS, single MAJOR C9; ARCHITECT: READY WITH FIX PASS, single MAJOR A6). Both fixes applied:- C9 (MAJOR — §M.4
catalog_pubkey_urlfield paragraph carried stale 44-char / lowercase wording): the §M.4catalog_pubkey_urlbullet was rewritten to match the detailed endpoint block —{"pubkey": "<43-char base64url-unpadded ed25519 pubkey>", "alg": "Ed25519"}— closing the interop split a coordinator implementer reading §M.4 top-down could have introduced. - A6 (MAJOR — staged IMPL prompt still instructed pre-fix
behaviour):
specs/BUILD_SPEC_015_v0_3_MODELHASH_IMPL_PROMPT.mdrefreshed in three locations to match v0.3.1 controlling clauses: Step 2GET /catalog/pubkeyresponse shape now pins 43-char base64url-unpadded +"Ed25519"(closing C2); Step 4 catalogLookupnow appliescatalogModelKeycase-fold + trim transform (closing A1); Step 4 catalogsignature.algtest now expects"Ed25519"capital E (closing C1); Step 5 CLI surface promoted to FIVE flags including--require-model-hashper §M.3.1.2 (closing S2); Step 5 result-schema fields point at §M.3.2.1 (closing C4). The IMPL prompt now reflects the round-1 + round-2 controlling clauses end-to-end.
- C9 (MAJOR — §M.4
v0.3.2 is the round-3 LOCK candidate. If round 3 returns 0 CRITICAL / 0 MAJOR across all three lenses, v0.3.x ships into the SPEC-only PR per [[feedback-bundle-spec-impl-one-pr]] major- version-bump exception.
Change log v0.3.1:
- Round-1 codex audit fix pass against
specs/SPEC-015-v0-3-audit.mdround-1 sections. Round 1 returned 3 CRITICAL + 9 MAJOR + 4 MINOR + 1 QUESTION; all three lenses verdict READY WITH FIX PASS. Findings resolved below.- C1 (CRITICAL —
signature.algcasing): §M.3.2 step 4 and §M.5 AC-35 now requiresignature.alg = "Ed25519"(capital E, matching the existingscripts/sign-catalog.go:142-145emitter andphase4-coordinator/internal/tier2/catalog.go:470validator). The v0.3.0 lowercase"ed25519"would have rejected every catalog the existing signer produces. - C2 (CRITICAL — catalog pubkey encoding): §M.3.1 flag
table, §M.3.2 step 4, §M.4
GET /catalog/pubkeyresponse, and §M.5 AC-32/AC-33/AC-37 all now requirebase64.RawURLEncoding(base64url-unpadded), exactly 43 ASCII characters, NOT standard padded base64. This matchesscripts/sign-catalog.go:90,316-328and SPEC-008 §5.2.1's locked Ed25519-pubkey encoding. v0.3.0's 44-char standard- padded encoding would have rejected every catalog pubkey produced by the existing tool. - S1 (CRITICAL — AC-40 routing semantics): AC-40
rewritten so that with
RequireHashVerified: false, the coordinator continues routing to providers whose hash status isuncataloguedORcatalog_unavailableonly — NOThash_mismatchorhash_invalid. SPEC-008 §5.6 (lines 746-760) andphase4-coordinator/internal/tier2/catalog.go:599-604(IsHashPredicateFailure) make mismatch / invalid fail-closed at both flag settings; v0.3.0 AC-40's broader claim would have silently demanded a SPEC-008 amendment and contradicted Entry 80. v0.3 receipts still BIND whatever the provider reports; the AC-40 normative change is only about routing. - C3 (MAJOR — §10.4.4 flag matrix not extended): New
§M.3.1.1 "v0.3 catalog flag matrix" sub-table covers every
combination of
{--catalog, --catalog-url, --catalog-pubkey, --catalog-pubkey-url}against{--offline, --coordinator, --pubkey, --json, header+hashes mode, bundle mode, stdin mode}. The matrix names the expected exit code or behavior for each combination. Pattern follows the v0.2.4 §10.4.4--provider-idmatrix. - C4 (MAJOR — JSON schema amendment for v0.3): New
§M.3.2.1 "v0.3 result schema amendment" pins:
model_hash_verifiedis a tri-state JSON field —true(catalog check ran AND hash matched),false(catalog check ran AND hash mismatched — this path also setsresult: "invalid"), ornull(catalog check did not run for any reason: no catalog flags, null hash, legacy receipt, unknown version, catalog fetch failed). The field is REQUIRED in all v0.3 JSON output (always present, never absent).detailsbecomes legal oninconclusiveresults when the named §M reasons fire (unknown_receipt_versioncarriesdetails.receipt_version;model_id_not_in_catalogcarriesdetails.model_id;catalog_expiredcarriesdetails.catalog_idanddetails.expires_at). The v0.2.4 §10.4.2 "details only on invalid" rule is SUPERSEDED for these v0.3-named inconclusive cases ONLY; otherwise §10.4.2 remains authoritative. - C5 (MAJOR — wrong
/poolztop-level key): §M.4 example response key changed from the v0.3.0 fictitious"providers": [...]to the SPEC-002 v1.4 §FR-O2 actual"pool": [...](and"summary": {...}preserved). The SPEC-002 v1.6 candidate annotation now adds ONLY the three catalog fields at top level, leaving every existing key byte-identical. - C6 (MAJOR — AC-29 introspection command): AC-29
rewritten to use the heartbeat-reported hash route (the
existing observability surface visible at Pearl journald
model_hash_verifiedevents; SPEC-011 §3.3.1 wire) rather than a non-existentmacprovider-cli models inspectsubcommand. v0.3 does NOT demand a new introspection CLI; if implementations want one for ergonomics, that's a future SPEC-001 extension, not a v0.3 prereq. - S2 (MAJOR — null-hash buyer policy knob): New
§M.3.1.2 "Null-hash policy flag" introduces a v0.3 OPTIONAL
CLI flag
--require-model-hash(boolean) that lets a buyer fail-closed when the receipt'smodel_hashis JSON null AND catalog arguments were supplied. With the flag SET and a null-hash receipt: result becomesinvalidwithreason: "model_hash_required"; without the flag (default): result isvalidper §M.2.3 + AC-32 with thecatalog_skipped_null_hashwarning. This preserves the "default warm-swap-disabled providers still verify clean" posture for backward compatibility but gives buyers a first-class fail-closed knob, eliminating the deployment-specific wrapper anti-pattern. AC-32a NEW captures the flag-set path. - S3 (MAJOR — AC-42 mid-swap clause vs. §M.2.2 construction):
AC-42 rewritten to align with §M.2.2's construction proof.
A normal in-flight request that began on the old container
and finishes during
loading/drainingMUST still emit a receipt with the request-start hash per §M.2.2 (not areceipt_omitted). Thereceipt_omitted: model_swap_violationrow fires ONLY in the defence-in- depth case where the runtime CANNOT identify the request-start container/hash — which under SPEC-011 R-3.4.1 + R-3.2.2 is unreachable by construction in correct implementations. The AC test becomes "synthesized state where the request-start container is genuinely unknown" (a defensive harness, not a normal swap). - A1 (MAJOR — catalog model_id case-folding divergence):
§M.3.2 step 6 rewritten. Catalog model_id lookup is
case-folded (lowercase + trim whitespace) to match
phase4-coordinator/internal/tier2/catalog.go:559-560catalogModelKeysemantics. The buyer-side verifier MUST mirror the coordinator-side check, NOT diverge from it. v0.3.0's "case-sensitive" rule would have let a coordinator accept a model the verifier rejected, and vice versa. - A2 + S4 (MAJOR + MINOR —
/poolzcatalog-field presence consistency): §M.4 prose now matches AC-39: catalog fields appear iff (a)Tier2Config.CatalogPathis set AND (b) the catalog loaded cleanly AND (c) its signature verified. The "configured" wording is removed; all three presence conditions are pinned.GET /catalog/<id>andGET /catalog/pubkeyreturn 404 when ANY of the three conditions fails. This is the single source of truth for the SPEC-002 v1.6 candidate absorption. - A3 (MAJOR — staged IMPL prompt now exists):
specs/BUILD_SPEC_015_v0_3_MODELHASH_IMPL_PROMPT.mdwas written in the same audit-loop session (v0.3.0 had it as a forward reference, v0.3.1 confirms it is staged). Lock- state language updated to reflect this. - A4 (QUESTION — SPEC-011 header drift): Acknowledged but out of scope for v0.3. SPEC-011 v0.5 status header polish is tracked separately; v0.3 cites SPEC-011 v0.5 per DECISION_CRITERIA Entry 55 which is authoritative.
- A5 (MINOR — stale §16.2 SPEC-011 reference): Updated the inherited §16 SPEC-011 v0.5 §3.3.1 + §3.8 bullet to cite §3.2 (state machine), §3.3 (heartbeat), §3.4 (drain) — the sections v0.3 §M.2 actually rests on.
- C7 (MINOR — §3.4 v0.3 size projection): v0.3 envelope
recomputed including the signature segment:
<base64(JCS(T))>.<base64(SIG)>= ~936 bytes (700-byte JCS, 4/3 base64) + 1 (.) + 88 (base64 of 64-byte sig) = ≤ ~1025 bytes. The 4096-byte nginx headroom requirement is unchanged. - C8 (MINOR — cache TTL boundary language): §M.3.4 now
uses explicit interval notation for the three TTL bands:
[6h+1s, ∞)→ 6h cache;[60s+1s, 6h]→expires_at - now() - 60scache;(-∞, 60s](including catalogs accepted only via the §M.3.2 step 5 60s skew grace) → no cache.
- C1 (CRITICAL —
v0.3.1 is the round-2 LOCK candidate. If round 2 returns 0 CRITICAL / 0 MAJOR across all three lenses, v0.3.x ships into the SPEC-only PR per [[feedback-bundle-spec-impl-one-pr]] major- version-bump exception.
Change log v0.3.0:
- Receipt tuple wire shape changes: 7 fields → 9 fields. v0.3
extends the receipt tuple per the new §M.0 by adding two NEW
fields. JCS canonical order (UTF-16 code-unit lexicographic per
RFC 8785 §3.2.3) places them as:
model_hash,model_id,output_hash,prompt_hash,provider_pubkey,receipt_version,tokens_out,ttft_ms,unix_ts— so the new fields land at index 0 and index 5 in the emitted byte order, not at "the end."model_hash(string of 64 lowercase hex chars OR JSON null) — the provider's SHA-256 of the loaded MLX container at receipt-generation time, sourced from the SPEC-011 v0.5 R-3.3.1 heartbeat state. Per §M.2 provenance, this is the post-swap in-memory container — what the buyer actually consumed.nullis permitted for providers running default--enable-warm-swap=false(SPEC-011 R-3.3.0) and required in that mode per §M.2.3.receipt_version(string, value"3"in this revision) — explicit wire-shape discriminant. v0.1/v0.2 receipts had noreceipt_versionfield; v0.3 verifiers detect those asreceipt_version="1"by absence per §M.1.1. String-typed (not int-typed) to avoid JSON-number canonicalization edge cases.
- Forward/backward compatibility (NORMATIVE per §M.1). v0.3
verifiers MUST accept v0.1/v0.2 receipts (back-compat: report
validper the legacy §3.1 7-key path, skip catalog check). v0.1/v0.2 verifiers reading a v0.3 receipt MUST reportinvalidper their existing "EXACTLY these seven keys" rule — the locked v0.1.3 / v0.2.4 releases are not amended. v0.4+ verifiers SHOULD report unknownreceipt_versionvalues asinconclusive: unknown_receipt_versionper §M.1.4 so a future v0.4 receipt against a v0.3 verifier degrades toinconclusiverather thaninvalid. - New §M (top-level) "Model-hash binding (v0.3 NORMATIVE)"
inserted between §10 and §11, with six subsections:
- §M.0 — v0.3 receipt tuple (the normative 9-key shape and JCS byte order)
- §M.1 — Wire and version compatibility (back-compat,
forward-incompat, unknown-version semantics, no
RFC8785JCS.swiftamendments) - §M.2 —
model_hashprovenance (heartbeat lag, mid-response swap REFUSED, absent-hash semantics for warm-swap-disabled providers) - §M.3 — Catalog-based verification (verify CLI extension: four new flags, the algorithm, trust-boundary update, cache TTL)
- §M.4 — Coordinator
/poolzextension (SPEC-002 v1.6 candidate annotation —catalog_id,catalog_url,catalog_pubkey_url,GET /catalog/<catalog_id>,GET /catalog/pubkey) - §M.5 — Acceptance criteria (AC-28 through AC-42, extending the v0.1/v0.2 AC-1 through AC-27 set)
- §M.6 — What v0.3 explicitly DOES NOT change (records
RequireHashVerifiedEntry-80 orthogonality, streaming deferral, multi-hash receipts deferred to v0.4+, federation, on-chain anchoring, quantization, soft model identity, JCS amendments)
- §3.1 update — wire shape pointer. The v0.1.3 sentence "A receipt object MUST contain EXACTLY these seven keys" is RETAINED for v0.1/v0.2 receipts (in-the-wild back-compat) and is SUPERSEDED for v0.3 receipts by §M.0's normative nine-key shape. §3.1's field-table heading carries a pointer to §M.0.
- §3.4 update — wire size envelope. The v0.1.3 ≤ ~830-byte
projection for the
X-MacProvider-Receiptheader is updated to account for the two new fields. v0.3 budget:model_hashadds ≤ 80 bytes ("model_hash":"<64 hex>"),receipt_versionadds ≤ 22 bytes ("receipt_version":"3"). NewJCS(T)ceiling is ≤ ~700 bytes; the header value after base64 expansion is ≤ ~960 ASCII bytes. The 4096-byte nginx headroom requirement is unchanged — v0.3 still fits comfortably. - §7.6 update — null-usage / error receipts. Error receipts
(e.g.
error_model_not_loaded) MUST setmodel_hashper §M.2 — i.e. the heartbeat-reported hash for the loaded container at the time the error fired. The error did not invalidate the in-memory container; the buyer is still entitled to know which weights the provider had warm. v0.1.3 AC-12 is preserved unchanged for the v0.1/v0.2 wire shape; new AC-31 pins the v0.3 wire shape. - §10 update — verify CLI extension. §10.4 gains four CLI
flags (
--catalog <path>,--catalog-pubkey <base64>,--catalog-pubkey-url <url>,--catalog-url <url>); §10.4.2 result schema gainsmodel_hash_verified(bool, present iff catalog was provided AND receiptmodel_hashwas non-null) and thereasonenum gainsmodel_hash_mismatch,model_id_not_in_catalog,catalog_signature_invalid,catalog_unreachable,catalog_expired,catalog_format_invalid,unknown_receipt_version,extra_field(for v0.1/v0.2 verifiers rejecting v0.3 receipts and for v0.3 verifiers rejecting receipts whosereceipt_version: "3"tuple has missing/extra keys); §10.4.3 exit-code table is UNCHANGED (0/1/2/64/65). §10.4.4 flag-interaction matrix gains rows for the four new flags and their mutual-exclusivity rules per §M.3.1. - §10.6 trust boundary update — supersession. The v0.2 §10.6
"DOES NOT prove that the response was generated by the model
named in
model_id" bullet is SUPERSEDED for v0.3validresults that carry non-nullmodel_hashAND were verified with a fresh, signature-valid, non-expired catalog. The remaining v0.2 §10.6 disclaimers (timestamp honesty, no-other-observer, pubkey-trust-root operator-mutability, no-buyer-correlator, no-uniqueness) are PRESERVED. §M.3.3 is the v0.3 trust-boundary authority for valid-with-catalog; §10.6 remains authoritative for valid-without-catalog (v0.1/v0.2 receipts; v0.3 receipts with null hash or no-catalog invocation). - §11 update — audit categories. The
receipt_omittedreasonenum gainsmodel_swap_violationas a v0.3-specific defined case (a swap was in progress at receipt-time per §M.2.2; the receipt was refused). v0.1/v0.2 §11 already listed this string as a placeholder; v0.3 promotes it from placeholder to defined semantics. - §15 Q6 update — RESOLVED. v0.1.3 / v0.2 §15 Q6 (model-hash
binding as a SPEC-011 cross-cut, gated on catalog-signing
readiness) is closed by §M. The Q6 paragraph is REWRITTEN to
point at §M and to note explicitly that
RequireHashVerifiedenforcement at the coordinator is ORTHOGONAL to v0.3 and unchanged perbeta/DECISION_CRITERIA.mdEntry 80 (2026-06-22). New §15 Q7 captures the deferred question of multi-hash receipts (i.e. a single receipt binding two model hashes for a swap-spanning streaming response); v0.3 §M.2.2 forbids the shape, v0.4+ may design it. - §16 update — references. New cites for
scripts/sign-catalog.go,phase4-coordinator/internal/tier2/catalog.go,phase4-coordinator/internal/config/config.go:142,335(RequireHashVerifieddefault flag),beta/DECISION_CRITERIA.mdEntry 80, SPEC-008 v0.3 §5.3-5.6 Pillar A semantics, SPEC-010 v1.5supported_models[]/publishes_supported_models, SPEC-011 v0.5 §3.3 heartbeat extension + §3.2 warm-swap state machine. - Depends-on line 4 update — SPEC-011 v0.5 promotion to HARD
dependency. v0.1/v0.2 referenced SPEC-011 v0.5 only for §7.4
(drain semantics during reconnect-based rotation). v0.3
promotes SPEC-011 v0.5 to a HARD dependency for receipt
issuance because the receipt now reads
model_hashfrom the provider's local SPEC-011 R-3.3.1 hash-tracking state. SPEC-008 v0.3 §5.3-5.6 is similarly promoted to HARD because the verifier's catalog-check path consumes a SPEC-008-compatible signed catalog. SPEC-010 v1.5 (supported_models[]) is added as a SOFT dependency (informs which providers participate in hash attestation; orthogonal to receipt issuance per §M.2.3). - Live infrastructure citation. As of 2026-06-24 the
coordinator at
coordinator.malibu.techruns SPEC-011 v0.5 observation mode against catalogmacprovider-tier2-model-catalog-2026-05-31; Pearl journald shows 342+model_hash_verifiedevents over the last 7 days for air5, alldecision:"allow", reason:"hash_match". v0.3 composes on that production infrastructure rather than introducing it. Reproduction command inspecs/BUILD_SPEC_015_RECEIPTS_v0_3_MODELHASH_PROMPT.md§"Files you should read".
v0.3.0 is the LOCK CANDIDATE. Codex audit rounds pending. On a clean 0-CRITICAL / 0-MAJOR round across all three lenses, v0.3.x ships as the SPEC-only PR; IMPL follows in a separate PR per the [[feedback-bundle-spec-impl-one-pr]] EXCEPTION rule for major version bumps with downstream implementers.
Change log v0.2.4:
- Round-4 codex audit fix pass (round 4 verdicts: security
READY TO LOCK maintained; code + architect READY WITH FIX
PASS, single convergent finding CF8 flagging two
stale-wording spots that v0.2.3 missed when adopting the
strict-CLI contract):
- CF8 / C13 / A10 (stale provider-id wording in §10.4.1
bundle field description and §10.1 HTTP 404 reason):
- §10.4.1
provider_idfield description rewritten to align with the §10.4 "Provider-id requirements" strict contract — absent bundleprovider_idfalls back to--provider-idthen single-match cache; if neither yields a value AND no--pubkeyis supplied, exit64(notinconclusive). The "MAY produceinconclusiveif no other identification path applies" phrasing is REMOVED. - §10.1 HTTP 404 paragraph updated to use
reason: "provider_id_not_in_pool"per the §10.4.2 enum, not the now-warning-onlyprovider_id_unresolvable.
- §10.4.1
- CF8 / C13 / A10 (stale provider-id wording in §10.4.1
bundle field description and §10.1 HTTP 404 reason):
- All three codex lenses now project to READY TO LOCK on round 5. v0.2.4 ships into the combined SPEC + IMPL PR per the [[feedback-bundle-spec-impl-one-pr]] convention if round 5 confirms 0 CRITICAL / 0 MAJOR across all lenses.
Change log v0.2.3:
- Round-3 codex audit fix pass against
specs/SPEC-015-v0-2-audit.mdround-3 sections (round 3: security lens READY TO LOCK 0/0/0/0; code + architect READY WITH FIX PASS with 3 MAJOR + 1 MINOR converging on a single root cause CF7). Findings resolved:- CF7 / C10 / A9 (provider-id absence is BOTH a usage error
AND an inconclusive result — internal contradiction): §10.4
normalized around the strict CLI contract (Option A from the
round-3 reading). Missing
--provider-idin header+hashes mode without--pubkeyis now exit code64(usage error) everywhere — at §10.4 input shapes, §10.4 "Provider-id requirements", §10.4.4 flag matrix, and the §10.4.3 exit-code table. Theinconclusivematrix row for that combination is replaced with USAGE ERROR. Rationale: the verifier was misinvoked (missing essential argument), not failed at runtime; this matches the convention for missing--receipt/--bundle.inconclusiveremains reserved for trust-root failures the verifier discovered during execution. - C11 (
live_check_skipped.reasonenum incomplete): the enum in §10.4.2 is extended withprovider_id_unresolvable— emitted when explicit--pubkeywas supplied AND no provider id is recoverable (the verifier can producevalidagainst the explicit key but the live divergence check is skipped because the resolver cannot be addressed). The enum is nowoffline_flag/network_unreachable/provider_id_unresolvable. - C12 (§10.0 algorithm step 5 still pubkey-byte-oriented):
§10.0 step 5 rewritten to read "Resolve the trusted pubkey
for the resolved
provider_idper §10.2" instead of "for the receipt's provider_pubkey bytes." Aligns the algorithm summary with the §10.2 no-scan rule.
- CF7 / C10 / A9 (provider-id absence is BOTH a usage error
AND an inconclusive result — internal contradiction): §10.4
normalized around the strict CLI contract (Option A from the
round-3 reading). Missing
v0.2.3 is the LOCK candidate. Round 4 pending — target READY TO LOCK across all three lenses (security is already there). On clean round 4, v0.2 locks and bundles into the combined SPEC + IMPL PR per [[feedback-bundle-spec-impl-one-pr]].
Change log v0.2.2:
- Round-2 codex audit fix pass against
specs/SPEC-015-v0-2-audit.mdround-2 sections (round 2 = 0 CRITICAL, 4 MAJOR, 3 MINOR across code/security/architect lenses; verdict READY WITH FIX PASS on every lens). Findings resolved:- CF4 / C7 / S6 (stale
/poolzwording in §10.1 + AC-18; §10.1 ↔ §10.2.1 no-match semantics conflict): §10.1 rewritten to (a) eliminate/poolzreferences, (b) reserveinconclusivefor fetch failure / provider_id unresolvable / no authoritative resolver answer, and (c) requireinvalidwhen the resolver returns an authoritative provider record whose current/previous keys do not match the receipt'sprovider_pubkey. AC-18 rewritten to reference/v1/receipt-keys/<provider_id>and the §10.2.1 grace-window semantics. - CF5 / C8 / A7 (
--provider-idis load-bearing but not first-class CLI input): §10.4 expanded to make--provider-id <id>a first-class CLI input across all three input modes (header+hashes, bundle, stdin). §10.4.4 flag matrix gains explicit--provider-idrows covering required- vs-optional disposition per mode. §10.2 rule on no-provider-idinconclusivereframed as a normative escape hatch rather than an under-specified edge case. - C9 (timestamp format split between Unix seconds and
RFC3339): §10.2 cache fields normalized —
fetched_at,rotated_at,expires_atare stored as RFC3339 UTC strings in the cache to match the §10.7 wire shape. The receiptunix_tsremains Unix seconds (v0.1 wire contract — locked). Conversion happens once at the cache-write boundary. - S7 (positive trust-boundary sentence reads like timestamp
attestation): §10.6 opening sentence reworded from "signed
this tuple at the claimed
unix_ts" to "signed a tuple containing the claimedunix_ts" to eliminate the quotability-out-of-context risk. - A8 (
validdoes not disclaim receipt uniqueness): §10.6 "DOES NOT prove" list extended with a sixth bullet —validdoes not prove that no other receipt was issued for the same response, or that this was the only provider-side attestation. Locks the surface against the same "narrow proof" misreading the §10.6 audit surfaces in round 1.
- CF4 / C7 / S6 (stale
v0.2.2 is the LOCK candidate. If round 3 returns 0 CRITICAL / 0 MAJOR across all three lenses, v0.2.2 ships into the combined SPEC + IMPL PR per the [[feedback-bundle-spec-impl-one-pr]] convention.
Change log v0.2.1:
- Round-1 codex audit fix pass against
specs/SPEC-015-v0-2-audit.md(round 1 = 6 CRITICAL, 8 MAJOR, 3 MINOR across code/security/architect lenses; verdict DESIGN ROUND NEEDED). Findings resolved:- CF1 / S1 / A1 / C2 (live
/poolzis operator-only — buyer cannot use it as default trust root): SPEC-015 v0.2.1 introduces a SPEC-002 v1.5 candidate annotation forGET /v1/receipt-keys/<provider_id>— a public, unauthenticated, rate-limited endpoint exposing ONLY the receipt-key tuple(provider_id, receipt_pubkey, receipt_pubkey_prev, rotated_at, expires_at). §10.2 rewritten to make this the default live source instead of operator-only/poolz. The new endpoint is pinned in §10.7 as a candidate annotation following the same parser-optional / additive / non-breaking pattern v0.1's three candidates used. - CF2 / S2 (grace-window check missing on
receipt_pubkey_prev): §10.2.1 rewritten to require the receiptunix_tsto fall within[rotated_at - 60s, expires_at]— matching v0.1 AC-11's pre-existing invariant. A previous-key match outside the grace window is nowinvalid, notvalid. - CF2 / S3 (stale-cache fallback validates retired keys via
provider-reported
unix_ts): §10.2 stale-cache rule rewritten. A stale entry (older than the 7-day TTL) MUST NOT producevalid— the result isinconclusiveregardless of receiptunix_ts. The provider-reported timestamp is no longer load-bearing for trust-root validity per §10.6's existing posture that timestamp honesty is not proven. - CF3 / C1 / A2 (bundle mode rejects ordinary OpenAI
captures): §10.4.1 rewritten to require
requestas the raw OpenAI request body as captured by the buyer. Absent §4.2 optional fields canonicalize as JSONnullper the locked v0.1 §4.2 rule. The "16-field minimum" requirement is REMOVED. - C4 (
bundle_versionexit-code contradiction): §10.4.1 + §10.4.3 + AC-25 now agree: unsupportedbundle_version→ exit65(input format error). §10.4.1 wording corrected. - C3 (JSON output schema is examples, not contract):
§10.4.2 now pins a normative field table covering
valid,invalid, andinconclusive, with required/optional disposition, enum values forresult,reason,details.field, andtrust_source, and a normativewarnings[]array for explicit-vs-live divergence and non-default-coordinator signals. - C5 (flag interaction matrix under-specified): new §10.4.4
pins a flag-interaction matrix covering
--offline,--quiet,--pubkey,--coordinator,--json,--explain,MACPROVIDER_COORDINATOR. - S4 (non-default coordinator trust hidden): §10.4.2
trust_sourceenum now carries acoordinator_hostcompanion field whenever the source is live or cache-derived. JSON output includes the host explicitly. - S5 (divergence warnings can disappear under
--quiet): §10.2 + §10.4.2 now require the explicit-vs-live divergence check to happen in ALL modes (including--quiet) and to be recorded in the JSONwarnings[]array;--quietsuppresses only stderr emission, not the warning record itself. - C6 (bundle
receiptplaceholder mislabeled): §10.4.1 example string corrected to reflect the<base64(JCS(T))>.<base64(SIG)>wire shape. - A3 (per-provider
/poolzvariant undefined): removed from §10.2; the new/v1/receipt-keys/<provider_id>endpoint replaces it. - A4 (cache keys lose provider identity): §10.2 cache now
keyed by
(coordinator_host, provider_id, receipt_pubkey), not bare pubkey bytes. - A5 (dep header candidate-only wording): line 4 deps updated to reflect SPEC-002 v1.4 and SPEC-006 v0.9 absorbed locked status; new SPEC-002 v1.5 candidate annotation called out explicitly.
- A6 (AC-24 leaks "IMPL repo" boundary): AC-24 rephrased to name the verifier implementation test suite and release artifacts, leaving repository layout to the BUILD prompt.
- CF1 / S1 / A1 / C2 (live
Change log v0.2.0:
- Promotes §10 from "informative; v0.2 normative" to NORMATIVE and
expands it into six subsections covering the buyer-side
macprovider-verifyCLI contract.- §10.1 Result semantics: pins a three-valued result
(
valid/invalid/inconclusive).inconclusiveis a first-class result; a verifier that collapses it into either of the others is non-conforming. - §10.2 Pubkey resolution: priority-ordered sources
(explicit
--pubkey→ local cache →/poolz), 7-day cache TTL matching §7.5.2 rotation grace, explicit-vs-live divergence warning, and §10.2.1 rotation-grace behavior coveringreceipt_pubkey_prev. - §10.3 Canonicalization parity: bit-identical to the §3.2 / §4 / §5 provider-side rules; explicitly forbids a "lenient" verifier mode; mandates a Swift↔Go JCS parity CI gate.
- §10.4 Inputs, outputs, exit codes: header+hashes / bundle /
stdin input modes; bundle JSON shape pinned in §10.4.1
(strict-mode rejection of unknown keys,
bundle_versionfor future evolution); JSON-mode output schema in §10.4.2; exit codes 0/1/2/64/65 in §10.4.3 (persysexits.h). - §10.5 Network behavior: verifier MUST NOT make any network
call beyond
/poolz; no telemetry / no analytics / no version-check beacon; single GET, 5-second timeout, no retries, no redirects beyond operator-named coordinator host. - §10.6 Trust boundary: uncompromising statement of what
validdoes and does not prove. Specifically: NOT model attestation (SPEC-011 / v0.3+), NOT timestamp honesty (Q4 / v0.3+), NOT privacy properties (SPEC-008), NOT pubkey trustworthiness (Q1 / v0.3+). Recommends--explainflag that prints §10.6 verbatim after avalidresult.
- §10.1 Result semantics: pins a three-valued result
(
- Extends §14 acceptance criteria with AC-18 through AC-27
covering:
validpath on fresh receipts, three tamper-detectioninvalidpaths (output / prompt / timestamp),inconclusiveon/poolzunreachable, offline--pubkeypath with zero network, JSON-mode schema conformance, exit-code reachability, cache-TTL refresh, and rotation-gracereceipt_pubkey_prevacceptance. - Updates §15 Q4 (timestamp trust): partially addressed by §10.6
(out of scope for
validresult); full normative skew-check remains v0.3+ candidate. - v0.1.x §§1-9, §11-13, §14 AC-1..AC-17, §16 README compatibility table are UNCHANGED. v0.1.3 issuance contract is preserved bit-identically. v0.2 adds the verifier contract on top.
Change log v0.1.3:
- Round-3 codex audit fix pass against
specs/SPEC-015-audit.md(round 3 = 0 CRITICAL, 1 MAJOR, 3 MINOR; verdict READY WITH FIX PASS). Findings resolved:- M1 (residual streaming normative clauses): §5.2, §5.3, §5.4
streaming/cancellation paragraphs and §12 streaming rows are
now explicitly informative forward-compatibility guidance for
v0.2+; v0.1.x emits NO receipt on any streaming path
(regardless of finish_reason). Buyer-disconnect post-completion
on a non-streaming response continues to receive a receipt
with normal
finish_reason=stopsemantics — that is not a streaming case. - m1 (§8.1 "one new field"): corrected to "two new fields" matching §1.3.
- m2 (AC-11 stale "control frame" wording): rewritten to reference reconnect-based rotation acceptance.
- m3 (v0.1.1 labels in v0.1.2 prose): replaced with v0.1.3 where the clause describes the current contract; v0.1 / v0.1.1 / v0.1.2 retained only inside changelog and historical-design discussion.
- M1 (residual streaming normative clauses): §5.2, §5.3, §5.4
streaming/cancellation paragraphs and §12 streaming rows are
now explicitly informative forward-compatibility guidance for
v0.2+; v0.1.x emits NO receipt on any streaming path
(regardless of finish_reason). Buyer-disconnect post-completion
on a non-streaming response continues to receive a receipt
with normal
Change log v0.1.2:
- Round-2 codex audit fix pass against
specs/SPEC-015-audit.md(4 CRITICAL, 4 MAJOR, 2 MINOR; verdict DESIGN ROUND NEEDED). Findings resolved:- C1 (
X-MacProvider-Receipt-Pendingunauthorized 2nd X-MacProvider- header):* The pending correlator header is REMOVED. v0.1.2 adds exactly ONE buyer-visible response header (X-MacProvider-Receipt) as the only SPEC-006 v0.9 candidate allowlist addition. §6.3 rewritten to be silent on the wire side for streaming. - C2 (rotation control frame outside SPEC-001 candidate):
The
provider_receipt_public_key_rotateWS control frame is REMOVED. v0.1.2 rotation is via reconnect: the binary closes the current WS, generates a fresh keypair, reconnects with the newprovider_receipt_public_keyin the existing v2auth_requestinitial-stage frame. The coordinator infers rotation by comparing the new pubkey against the previously-known one for thisprovider_id. §7.5 rewritten; §7.5.1 (rotate frame schema) deleted. - C3 (streaming deferral drifts from BUILD prompt): v0.1.2 explicitly narrows the SPEC-015 v0.1.x mission to non-streaming responses only. Streaming receipts are NOT in v0.1.x; they are v0.2+ scope with explicit READMe/mission truth-in-advertising guidance. The BUILD prompt's "MUST be present, but where" question is answered as "not present in v0.1.x; v0.2+ design". §1.1, §1.2, §6, §15 Q5 rewritten.
- C4 (contradictory retention MUST/SHOULD): The §6.3 SHOULD permitting bounded server-side retention is REMOVED. v0.1.2 pins server-side receipt-body persistence as PROHIBITED. A v0.2+ streaming-receipt design will name its own retention contract or use buyer-held-only delivery.
- M1 (
/poolzcandidate field count): §1.3 now explicitly names the two SPEC-002 v1.4 candidate fields:receipt_pubkeyandreceipt_pubkey_prev. - M2 (AC-9 non-executable): AC-9 dropped from the normative list; the byte-equivalence invariant moves to §5.5 informative. ACs renumbered 1–17.
- M3 (
model_idverbatim + NFC):model_idis now pinned as ASCII-only per SPEC-001 v1.5 §6.4 (which is already ASCII-oriented), so NFC normalization is a no-op formodel_id. NFC normalization applies only to natural-language strings in messages/output. §3.1, §3.2, §4.2 wording aligned. - M4 (rotation Keychain write race): v0.1.2 rotation writes the new key to Keychain only AFTER coordinator acceptance via successful reconnect auth. If the reconnect fails, the binary keeps the previous key active. §7.5 rewritten.
- m1 (v0.1 changelog mentions SSE): added a parenthetical note on the v0.1 change-log entry that v0.1.1+ supersedes the SSE delivery design.
- m2 (SPEC-011 §3.8 citation): corrected to SPEC-011 v0.5 R-3.8.3 drain semantics.
- C1 (
Change log v0.1.1:
- Round-1 codex audit fix pass against
specs/SPEC-015-audit.md(3 CRITICAL, 8 MAJOR, 4 MINOR, 2 QUESTIONS). Findings resolved:- C1 (streaming SDK incompat): Streaming receipt delivery is
deferred to v0.x pending a verified OpenAI-SDK-compatible
encoding. v0.1.1 emits
X-MacProvider-Receipton non-streaming responses ONLY. Streaming responses are accompanied by aX-MacProvider-Receipt-Pending: <request_id>response header for forward compatibility; the receipt body itself is NOT included in the SSE stream in v0.1.1. §6.3 rewritten; §15 Q5 expanded. - C2 (proof-stage auth_request scope):
provider_receipt_public_keyis restricted to the SPEC-001 v1.5 §6.7.1 initial-stage frame only. The proof-stage echo is dropped. §7.2 rewritten. - C3 (coordinator ALTER TABLE): v0.1.1 no longer prescribes
SPEC-002 storage mechanics. The coordinator MUST surface the
pubkey on
/poolz(SPEC-002 v1.4 candidate, unchanged); the durable-storage mechanism is named by the future BUILD spec, not pinned here. §7.3 and §13 rewritten. - M1 / q2 (prompt-hash field coverage): the prompt canonical
object expands from 10 to 16 keys, adding
presence_penalty,frequency_penalty,logit_bias,logprobs,top_logprobs,n. §4.2 updated. - M2 (JCS reuse mismatch): v0.1.1 names two required additive
extensions to
RFC8785JCS.swift— RFC 8785 §3.2.2.3 float handling and an explicit NFC normalization step on string inputs. §3.2 rewritten. - M3 (grace window mixed time+count): v0.1.1 uses a single 7-day time-based grace window; the request-count threshold is dropped. §7.5.2, AC-12 updated.
- M4 (AC-R8 byte-identity impossible): AC-8 now requires the
streaming response carries a pending request_id correlator, not
byte-identity. AC-9 unchanged on
output_hash. - M5 (Keychain race): §7.1 now requires atomic insert-or-load
on
errSecDuplicateItem. - M6 (audit event field-list contradiction): §11 names exact four fields once.
- M7 (CLI name drift): the manual rotation flag is now
macprovider rotate-key, matching the BUILD prompt. - M8 (README schema divergence not explained): new §16.1 compatibility table.
- m1 (RFC 8895): corrected to WHATWG HTML SSE.
- m2 (AC numbering): AC-R1..R18 → AC-1..18.
- m3 (model_id wording): clarified case-insensitive match, verbatim storage.
- m4 (README line range): corrected to 117–128.
- q1 (provider_id in tuple): RESOLVED. Provider identity in
the receipt is the pubkey itself;
provider_idremains out-of-band via/poolz. Rationale added to §3.1.
- C1 (streaming SDK incompat): Streaming receipt delivery is
deferred to v0.x pending a verified OpenAI-SDK-compatible
encoding. v0.1.1 emits
Change log v0.1 (historical; SSE delivery design + AC numbering superseded by v0.1.1/v0.1.2):
- Initial draft following the design rationale captured in §2.
- Defines the per-response signed receipt: a base64 ed25519 signature
over a JCS-canonicalized seven-field tuple (
model_id,prompt_hash,output_hash,provider_pubkey,ttft_ms,tokens_out,unix_ts). - Specifies prompt and output canonicalization, the
X-MacProvider-Receiptwire header for both non-streaming and SSE responses, the provider ed25519 keypair lifecycle (Keychain storage, publication on the v2auth_requestinitial-stage frame, manual rotation with a grace window), and the v0.1 pubkey trust root (/poolz). - Defers model-hash binding to SPEC-011's domain (v0.3+ in this SPEC), buyer verification CLI to v0.2, on-chain anchoring outside scope, request_id replay binding to Open Q2, and cross-segment route binding to Open Q3.
- Acceptance criteria AC-1 through AC-18 are deterministic and implementer-verifiable.
SPEC-015 v0.4.6 registers SPEC-015-R001..SPEC-015-R005 in
specs/CONFORMANCE.json. R001 remains the v0.4 settlement-capable
issuance/ingestion unit. R002–R005 group additional existing obligation
areas without changing them:
SPEC-015-R001— v0.4 settlement-capable receipt issuance and ingestion (§N).SPEC-015-R002— historical v0.1–v0.3 verification and forward-incompat with v0.4 (§10, §M).SPEC-015-R003— provider receipt-key lifecycle and buyer-safe pubkey resolver (§7).SPEC-015-R004— pubkey trust root (§8).SPEC-015-R005— coordinator receipt storage, ingest idempotency, and audit redaction (§13, §N). Buyer retrieval remains SPEC-022-R006.
requirement_id_migration is complete. R002–R005 are not promoted from
this close. Signed journey-result evidence is still required before any of
those rows can become conformant. Buyer retrieval remains SPEC-022-R006.
Implement SPEC-015 v0.1. As you work, maintain a running
phase3-binary/implementation-notes.html and (when coordinator/gateway
work begins) phase4-coordinator/implementation-notes.html and
phase5-gateway/implementation-notes.html that capture anything I
should know about how the implementation diverges from or interprets
the spec:
- Design decisions: choices made where the spec was ambiguous
- Deviations: places where you intentionally departed from the spec, and why
- Tradeoffs: alternatives considered and why you picked what you did
- Open questions: anything you'd want me to confirm or revise
SPEC-015 defines per-response signed receipts for MacProvider inference: a small, transport-attached, offline-verifiable proof that binds the response a buyer received to the provider that produced it, the prompt that requested it, and a small set of provider-reported quality signals.
This is the v0.1 normative floor. It pins:
- The receipt tuple and its canonical encoding.
- The signature algorithm.
- The wire transport (HTTP response header on non-streaming responses only; streaming responses carry no v0.1.x receipt — see §6.3).
- The provider keypair lifecycle (generation, storage, publication, manual rotation).
- The v0.1 pubkey trust root.
- Behavior on receipt-issuance failure.
The README.md line 22 ("Every response will carry a signed receipt
binding (prompt, output, provider) — verifiable inference, without a
datacenter (planned, not yet implemented)") and the §"Roadmap"
schema block at README.md:117-128 describe the product surface. As
of v0.1 LOCK, grep -r receipt phase3-binary phase4-coordinator phase5-gateway returns zero implementation; this SPEC is the
contract that closes that gap.
v0.1.x covers non-streaming chat completions only. Streaming is out of scope for v0.1.x; see §1.2 and §15 Q5 for the deferral.
- The receipt tuple and JCS canonical encoding.
- ed25519 signature algorithm and base64 encoding.
- Prompt canonicalization rules.
- Output canonicalization rules (for non-streaming responses; the byte-equivalence invariant in §5.5 is forward-compatibility guidance for the v0.2+ streaming design but is not testable in v0.1.x).
- Tool-call commitment inside
output_hash. - The
X-MacProvider-ReceiptHTTP response header value format. - Receipt-emission preconditions and the explicit omission cases (non-streaming responses only).
- Provider keypair generation, macOS Keychain storage, and publication
on the SPEC-001 v2
auth_requestinitial-stage frame via a new parser-optionalprovider_receipt_public_keyfield annotated as a SPEC-001 v1.6 candidate extension. - Manual key rotation (
macprovider rotate-key) performed via WS reconnect — no new control frames; the rotated pubkey is republished on the nextauth_requestinitial-stage frame using the existing single-field SPEC-001 v1.6 candidate. - Pubkey trust root: the coordinator's
/poolzJSON gains exactly two per-provider fields:receipt_pubkey(current) andreceipt_pubkey_prev(previous, populated for 7 days after rotation). This is the SPEC-002 v1.4 candidate annotation. - Acceptance criteria implementers can mechanically verify.
SPEC-015 v0.1.x does NOT specify:
- Streaming chat completions. Streaming
POST /v1/chat/completionsresponses do NOT carry receipts in v0.1.x. The round-1 audit C1 + the round-2 audit C1/C3 surfaced that the OpenAI Python and JavaScript SDKs JSON-parse every non-[DONE]SSEdata:payload and that v0.1's proposed terminalevent: receiptblock would raise on a base64 receipt string. v0.1.2 chose to narrow the v0.1.x mission to non-streaming receipts rather than introduce a second buyer-visible header (which would itself exceed the SPEC-006 v0.9 candidate scope). Streaming receipts are v0.2+; the design space is summarized in §15 Q5. README and operator-facing copy MUST be honest that v0.1.x receipts only cover non-streaming requests. - Buyer verification CLI.
macprovider verify <receipt.json>is a separate work item tracked as v0.2. v0.1 issues receipts; v0.2 verifies them. State of v0.2: not started; this SPEC will bump to v0.2 with the verifier surface once that work begins. - Model-hash binding. Whether the receipt commits to which
weights ran (sha256 of the loaded model) is SPEC-011's territory.
SPEC-011 v0.5 §3.3.1 already specifies provider-reported
heartbeat.model_hash(raw 64-character lowercase hex). Folding that into the receipt tuple — so a buyer can verify "which weights served me" — is deferred to SPEC-015 v0.3+ contingent on SPEC-011's catalog-signing posture (operator decision perbeta/DECISION_CRITERIA.mdEntry 80, Q3 tier-2 posture). v0.1 binds which name was requested and what content was produced, not which weights served it. - On-chain anchoring. Periodic Merkle roots of issued receipts posted anywhere durable (chain, AntFeed, ENS-published manifest) are gated on a Cluster D-tokens go/no-go decision the operator has not made. v0.1 says nothing about it.
- Request-id binding for replay-style verification. Whether the
receipt commits to a
request_idand where the buyer would obtain its expectedrequest_idis unresolved in v0.1.x through v0.3. v0.4 resolves settlement replay binding in §N.1 and §N.3. See §15 Q2. - Multi-segment route binding. Once Cluster F sharding lands a single response may have multiple provider segments; receipt-per- segment vs receipt-per-response with embedded route list is unresolved. See §15 Q3. v0.1 assumes one provider per response.
- TUF-style trust-root signing of
/poolz. v0.1 acknowledges the trust root is operator-mutable (the coordinator publishes the pubkey list); strengthening it is v0.3+. See §15 Q1.
SPEC-001 v1.5 remains the authoritative provider binary and provider
WebSocket protocol. SPEC-015 v0.1 MUST NOT edit SPEC-001 v1.5
text; it ANNOTATES one additive, parser-optional field
(provider_receipt_public_key) on the v2 auth_request initial-stage
frame, marked here as a SPEC-001 v1.6 candidate extension. Until that
candidate field lands in SPEC-001 the field MUST NOT appear on the
wire from a v1.5 binary; the receipt-issuing path on the provider
side is enabled only by a binary at SPEC-001 v1.6 or later. This
mirrors SPEC-008's SPEC-001 v2.0 annotation pattern.
SPEC-002 v1.3.5 remains the authoritative coordinator router spec.
SPEC-015 v0.1.x ANNOTATES exactly two additive, optional response
fields on each /poolz provider object — receipt_pubkey (current
pubkey) and receipt_pubkey_prev (previous pubkey populated only
during the 7-day rotation grace window) — marked here as a single
SPEC-002 v1.4 candidate annotation pair. SPEC-002 §7 surfaces
(/poolz shape, internal forwarding) are otherwise unchanged.
SPEC-005 v0.3 remains the authoritative billing/settlement spec.
SPEC-015 v0.1 reuses SPEC-005's effective completion-token accounting
unmodified: tokens_out in the receipt is the same int64 value the
billing path uses for effective_completion_tokens per
SPEC-005 §4 derivation. SPEC-015 v0.1 MUST NOT change SPEC-005's
formula, refund matrix, or null-usage error treatment.
SPEC-006 v0.8.3 remains the authoritative gateway buyer-API spec.
SPEC-015 v0.1 adds one buyer-visible response header
(X-MacProvider-Receipt) and registers it on the SPEC-006 §17
response-pass-through allowlist as a SPEC-006 v0.9 candidate
extension. SPEC-006 §17 header-strip rules (the gateway strips any
non-allowlisted X-MacProvider-* response header) otherwise apply
unchanged. The OpenAI SDK drop-in contract is preserved: the receipt
header is additive metadata; absence does not break SDK clients;
presence does not violate any OpenAI shape because OpenAI clients
ignore unknown response headers.
SPEC-008 v0.3 remains the authoritative Tier-2 trust layer. SPEC-015 v0.1 is orthogonal to SPEC-008. Specifically:
- Receipt issuance is independent of Pillar A model-hash verification (SPEC-008 §5.3). A receipt issued under v0.1 makes no claim about weight identity; SPEC-008 Pillar A makes that claim separately at admission and routing time.
- Receipt issuance is independent of Pillar B encrypted-leg AEAD (SPEC-008 §6). The receipt is computed over the cleartext request and response as observed at the provider; if the provider-leg is later AEAD-encrypted per Pillar B, the receipt is still computed over the same plaintext at the provider boundary before encryption.
- Receipt issuance is independent of Pillar C attestation. v0.1's
trust root for the provider receipt pubkey is
/poolz. If Pillar C is enabled, the attestation token does NOT bind the receipt key; v0.3+ MAY re-anchor receipt pubkeys to Pillar C attestations. - Receipt field names MUST NOT collide with SPEC-008 wire fields.
This SPEC uses
provider_receipt_public_keyto distinguish from SPEC-008provider_ecdh_public_key(auth_requestinitial-stage per SPEC-001 v1.5 §6.7.1).
SPEC-011 v0.5 remains the authoritative warm-swap spec. Receipt issuance MUST observe a model swap: a receipt MUST NOT be emitted for a response whose model load changed mid-response (SPEC-011 v0.5 R-3.8.3 drain semantics already prevent this, but §7.4 below makes the invariant explicit on the receipt side).
SPEC-013 v0.3 remains the authoritative autotune CLI subcommand
spec. SPEC-015 v0.1 reuses
phase3-binary/Sources/macprovider-cli/RFC8785JCS.swift (added by
SPEC-013) for canonical encoding; no parallel canonicalizer is
permitted.
A buyer who has fetched a provider's receipt pubkey from /poolz MUST
be able to verify, offline, that:
- The response they hold came from a provider holding that pubkey,
- The response was bound to a prompt they can canonicalize and hash
themselves to compare against
prompt_hash, - The output they hold canonicalizes to a digest matching
output_hash, - The provider-reported
ttft_ms,tokens_out, andunix_tsare committed to the signed tuple and cannot be silently revised after the fact.
If any of (1)–(4) fails for a verifier that follows §3 canonicalization correctly, the receipt is invalid and the verifier MUST reject it.
A buyer who does NOT trust /poolz (operator-mutable list) MUST
explicitly acknowledge that the v0.1 trust root is the coordinator
operator. v0.3+ stronger roots are §15 Q1.
The "verifiable inference" tag in the README is the central
differentiator from operator-trusted inference networks. The bar is
not academic ZK-verifiable inference (covered in
doc/internal/zk-verifiable-inference-design.md as exploratory) — it
is the minimum mechanism that lets a buyer prove a specific provider
served a specific prompt-output pair.
v0.1's design choices and their justifications:
- ed25519 over JCS-canonical JSON. ed25519 keys are small (32-byte
pubkey, 64-byte signature), signing is fast (~50 µs on Apple Silicon),
and the algorithm is widely implemented. JCS (RFC 8785) gives an
unambiguous canonical form for JSON that survives field-order
permutations and floating-point representation; the in-house Swift
implementation at
phase3-binary/Sources/macprovider-cli/RFC8785JCS.swiftis battle-tested by SPEC-013. - Seven-field tuple. The set was chosen to cover the four buyer-observable claims (model name, prompt content, output content, provider identity) plus three provider-reported quality signals (ttft, output token count, timestamp). It deliberately does NOT cover model-hash, request-id, or route — those are scoped to v0.3+, v0.2 verification, and Open Q3 respectively.
- Response header transport. A header is the lowest-friction surface that OpenAI clients tolerate unchanged. Body inclusion was rejected: it would force every buyer SDK to learn a new response shape and would break OpenAI SDK drop-in (SPEC-006 §C.1).
- Streaming receipts deferred to v0.2+. Two rejected designs
(v0.1 terminal
event: receiptSSE block and v0.1.1X-MacProvider-Receipt-Pendingcorrelator header) demonstrated that an SDK-compatible streaming receipt transport needs its own design pass. v0.1.x explicitly carries no receipt on streaming responses; v0.2+ will design the streaming transport. See §6.3 and §15 Q5. - Manual rotation with a time-based grace window. Auto-rotation
has operational hazards (key churn, in-flight verification
failures); v0.1.1 defers it. Manual rotation is a CLI flag; the
coordinator retains the previous pubkey for 7 days after the
new one is published, so receipts that left the provider under
the old key remain verifiable while a buyer is still polling
/poolz. The v0.1 draft mixed a time threshold with a request-count threshold; the round-1 audit M3 flagged the mix as unimplementable without a counter contract; v0.1.1 uses time only.
v0.3 wire shape supersedes this section for v0.3 receipts.
The §3.1 seven-field shape below describes the v0.1 / v0.2 wire
contract and remains authoritative for receipts emitted by v1.6
binaries pre-v0.3, and for verifiers consuming such receipts.
For v0.3 receipts (those carrying receipt_version: "3"), the
normative tuple shape is §M.0's nine-field table. v0.3-emitting
providers MUST construct the §M.0 nine-field tuple; v0.3
verifiers MUST detect tuple version per the §M.1.1 / §M.1.4
rules (presence of receipt_version field) and apply either the
§3.1 7-key path (legacy back-compat) or the §M.0 9-key path
accordingly. The §3.2 JCS canonicalization rules and the §3.3
signature rules are UNCHANGED across both shapes.
Every v0.1 / v0.2 receipt is a JCS-canonicalized JSON object with EXACTLY the following seven fields and no others:
| Field | Type | Definition |
|---|---|---|
model_id |
string | The buyer-requested model identifier. SPEC-001 v1.5 §6.4 model identifiers are ASCII-only and matched case-insensitively; v0.1.3 inherits this and requires model_id strings in the tuple to be ASCII-only. The receipt stores the original buyer-submitted model string verbatim (no case-fold). Because the string is ASCII-only, the §3.2 NFC normalization step is a no-op on this field; conformant verifiers MUST reject any receipt whose model_id contains a non-ASCII byte. |
prompt_hash |
string | Lowercase hex sha256 of the JCS-canonical encoding of the canonical prompt object defined in §4. 64 lowercase hex characters, no sha256: prefix. |
output_hash |
string | Lowercase hex sha256 of the JCS-canonical encoding of the canonical output object defined in §5. 64 lowercase hex characters, no sha256: prefix. |
provider_pubkey |
string | Base64 (standard, padded, no URL-safe substitution) of the provider's 32-byte ed25519 public key. Exactly 44 ASCII characters. |
ttft_ms |
int64 | Time-to-first-token in milliseconds, measured at the provider from request-accepted to first-output-byte-emitted. Non-negative. For non-streaming responses, this is the full generation latency. |
tokens_out |
int64 | Provider-reported output token count, the same int64 value SPEC-005 §4 names effective_completion_tokens. Non-negative. See §7.6 for null-usage and error cases. |
unix_ts |
int64 | Provider's response-completion timestamp, Unix seconds UTC. Non-negative. Provider clock; see §15 Q4 for cross-check semantics. |
Field omissions and extras. A receipt object MUST contain EXACTLY these seven keys. Verifiers MUST reject receipts with missing or extra keys. There are no optional fields in v0.1.
Why provider_id is NOT in the tuple (resolves audit q1). The
buyer's cryptographic root of trust in the receipt is the
provider_pubkey field. The human/operator-facing provider_id
ULID is the coordinator's mutable label for that pubkey in /poolz
(§8). v0.1's design choice is to bind only the pubkey because:
- The pubkey is the unforgeable identity for verification — a buyer
who has fetched
(provider_id, receipt_pubkey)from/poolzalready trusts that mapping or does not. - Including
provider_idwould double-bind to an operator-mutable label without strengthening the cryptographic claim. - If
/poolzlater strengthens to a TUF-style signed root (§15 Q1), the trust upgrade lands on the/poolzside without re-signing historical receipts.
A v0.x+ MAY revisit this if §15 Q1 trust-root strengthening lands and the operator wants the receipt to commit to a stable opaque identifier independent of the pubkey.
Types. model_id, prompt_hash, output_hash, and
provider_pubkey are JSON strings. ttft_ms, tokens_out, and
unix_ts are JSON numbers that fit in int64. Implementations MUST
serialize them as JSON integers (no decimal point, no exponent) and
verifiers MUST reject any non-integer numeric encoding. JCS already
constrains numeric formatting to a canonical decimal representation;
v0.1 forbids fractional or exponential numerics for these three
fields explicitly.
Let T be the receipt tuple object. The signing input MUST be
JCS(T) as defined by RFC 8785, with the additive profile pinned
below. The implementation reuses
phase3-binary/Sources/macprovider-cli/RFC8785JCS.swift and MUST
extend it with two clearly-named additions:
- Object key order: UTF-16 code-unit lexicographic, per
RFC 8785 §3.2.3. Already implemented at
RFC8785JCS.swift:44-46. - String escape rules: RFC 8785 §3.2.2.5. Already implemented
at
RFC8785JCS.swift:48-75for U+0000–U+001F,",\\, and U+FFFD. - NEW (extension required for v0.1.3): NFC normalization on
natural-language strings. Every JSON string value entering the
canonical form that may contain non-ASCII bytes — specifically,
prompt/output canonical-object string fields per §§4–5 — MUST be
Unicode-normalized to NFC (Unicode 15.1) BEFORE escape.
Implementations MUST extend
RFC8785JCS.swiftwith a pre-escape NFC step usingString.precomposedStringWithCanonicalMapping. Pre-normalized inputs (already NFC) are a no-op. Tuple-level string fields (model_id,prompt_hash,output_hash,provider_pubkey) are ASCII-only by their respective field definitions (§3.1), so NFC is a no-op on those fields by construction. - NEW (extension required for v0.1.3): JSON number handling for
floats. RFC 8785 §3.2.2.3 specifies the canonical decimal
representation for JSON numbers including IEEE 754 doubles.
RFC8785JCS.swiftv1 supports onlyint; v0.1.3 receipt implementations MUST extendRFC8785JCS.swift'sValueenum with adouble(Double)case implementing RFC 8785 §3.2.2.3 (the ECMAScriptNumber.prototype.toStringderived format). The prompt canonical object (§4) containstemperature,top_p,presence_penalty,frequency_penaltyas floats and is the driver for this extension. - No whitespace, no insignificant separators, no trailing newline.
The signing input is the UTF-8 bytes of JCS(T).
The receipt tuple itself (§3.1) contains only strings and integers,
so the receipt SIGNING step itself does not exercise the float
extension. Floats appear in the §4 prompt canonical object that
feeds prompt_hash. Both extensions are MANDATORY for a v0.1.3
conformant provider implementation; an implementation lacking either
MUST NOT emit receipts.
SIG = ed25519_sign(provider_receipt_private_key, UTF-8(JCS(T))).
SIG is exactly 64 bytes. The on-wire encoding is base64 (standard,
padded; no URL-safe substitution) — exactly 88 ASCII characters.
The full receipt artifact transmitted on the wire is the
JCS-canonical tuple plus the signature. The X-MacProvider-Receipt
header value MUST be:
<base64(JCS(T))>.<base64(SIG)>
That is: standard padded base64 of the UTF-8 bytes of JCS(T),
then a literal ASCII period (0x2E), then standard padded base64
of the 64-byte signature. No whitespace, no other delimiters, no
trailing characters.
The two base64 segments are independently decodable so a verifier
can reconstruct JCS(T) and check ed25519_verify(provider_pubkey, JCS(T), SIG). This format was chosen over JWS (compact serialization)
because v0.1 does not need a header (no algorithm agility, no key id
indirection — provider_pubkey is in the payload). A v0.x+ may
migrate to JWS once algorithm agility is needed; that migration is
NOT part of v0.1.
Maximum size. JCS(T) is bounded by the field sizes:
model_id ≤ 256 bytes (SPEC-001 v1.5 model-id constraint),
prompt_hash/output_hash = 64 hex chars each, provider_pubkey =
44 chars, three int64 numerals ≤ 20 chars each. With JSON
overhead, the v0.1/v0.2 JCS(T) ≤ 600 bytes; base64 expands by
4/3, so the v0.1/v0.2 header value is ≤ ~830 ASCII bytes.
v0.3 wire size (NEW). A v0.3 receipt adds model_hash
(≤ 80 bytes including key + quotes + colon: "model_hash":"<64 hex>") and receipt_version (≤ 22 bytes: "receipt_version": "3"). The v0.3 JCS(T) ceiling is ≤ ~700 bytes. The
X-MacProvider-Receipt header value is
<base64(JCS(T))>.<base64(SIG)>:
base64(JCS(T))= ⌈700 × 4/3⌉ ≈ 936 bytes (standard padded base64).- The literal period separator = 1 byte.
base64(SIG)for a 64-byte ed25519 signature = 88 bytes (standard padded base64).- Total v0.3 header value ≤ ~1025 ASCII bytes. v0.3 still fits comfortably within the 4096-byte budget below.
Implementations MUST permit a generous X-MacProvider-Receipt
header up to 4096 bytes to leave headroom for v0.3+ field
additions and to avoid edge-case nginx truncation.
The prompt_hash field commits to the buyer's request. The
canonicalization rule MUST be deterministic across implementations so
a verifier with the same request body produces the same hash.
The provider canonicalizes the request body it received at the point of inference, NOT the buyer's original HTTP body. For the v0.1 single-provider routing case (one provider per response, see §1.2) the gateway-to-coordinator-to-provider forwarding preserves the relevant fields byte-for-byte; see §4.5 for the normative subset.
The provider MUST construct the canonical prompt object as follows:
{
"model": <request.model>, // verbatim string
"messages": [<canonical_message>, ...], // see §4.3
"tools": [<canonical_tool>, ...] | null, // see §4.4
"temperature": <float|null>,
"top_p": <float|null>,
"max_tokens": <int|null>,
"stop": <string|array<string>|null>,
"seed": <int|null>,
"response_format": <object|null>,
"tool_choice": <string|object|null>,
"presence_penalty": <float|null>,
"frequency_penalty": <float|null>,
"logit_bias": <object|null>,
"logprobs": <bool|null>,
"top_logprobs": <int|null>,
"n": <int|null>
}
A field that is absent from the request body MUST be encoded as JSON
null in the canonical prompt object. The object MUST contain
EXACTLY these sixteen keys; no other request fields enter
prompt_hash in v0.1.
The sixteen keys are the union of OpenAI chat-completion fields the
provider observes and that materially affect the output distribution
or the response shape. The audit-driven expansion from v0.1's
ten-key list closed the "weak prompt binding" gap surfaced in the
round-1 audit M1: presence_penalty, frequency_penalty,
logit_bias, logprobs, top_logprobs, and n were missing in
v0.1 and could have let two responses differ on sampling while their
receipts hashed identical prompts.
Implementations MUST NOT include OpenAI fields outside this list
(user, stream, stream_options, store, metadata,
function_call, functions, etc.) even if the buyer sent them.
v0.1.3 deliberately excludes fields that are non-deterministic on
the provider side (stream, stream_options) or operationally
noisy (user, metadata), and excludes legacy aliases
(function_call, functions) in favor of tools and
tool_choice. A v0.2+ may widen the subset; verifiers built against
v0.1.3 MUST hash exactly these sixteen keys.
Each message in messages MUST canonicalize to:
{
"role": <string>, // "system" | "user" | "assistant" | "tool"
"content": <canonical_content>, // string or array; see §4.3.1
"name": <string|null>,
"tool_call_id": <string|null>, // for role:"tool" messages
"tool_calls": [<canonical_tool_call>, ...] | null // for role:"assistant" with tool calls
}
Each message MUST contain EXACTLY these five keys; fields absent from
the buyer-supplied message are encoded as JSON null.
content is one of:
- A JSON string (the common case for text-only messages). The string MUST be Unicode-normalized to NFC (Unicode 15.1 stabilization). A request that contains pre-NFC content (decomposed sequences, legacy escapes) is normalized at the provider before hashing.
- A JSON array of content parts, used for OpenAI multimodal-style
messages. Each part MUST canonicalize to one of:
{"type":"text","text":<nfc-string>}{"type":"image_url","image_url":{"url":<string>,"detail":<string|null>}}{"type":"input_audio","input_audio":{"data":<string>,"format":<string>}}Each part object MUST contain EXACTLY the keys named for its type.
If the buyer sent content: null (legacy OpenAI shape for
assistant tool-call messages), the canonical form is JSON null.
Within a content string:
\r\nand bare\rMUST be normalized to\nbefore NFC.- Trailing whitespace MUST NOT be stripped. Some prompts legitimately
end with whitespace and a strip would silently change
prompt_hash. - Leading whitespace MUST NOT be stripped, same reason.
- Internal whitespace runs MUST NOT be collapsed.
Each tool in tools MUST canonicalize to:
{
"type": "function",
"function": {
"name": <string>,
"description": <string|null>,
"parameters": <json-schema-object|null>
}
}
parameters is a JSON Schema object as supplied; JCS canonicalizes
the object recursively. v0.1 does NOT reorder or normalize the
schema beyond JCS's standard sort.
The §4.1–§4.4 fields MUST be passed end-to-end from buyer to provider
without modification. SPEC-006 v0.8.3 §17 already enforces this for
the OpenAI request body (gateway forwards the body verbatim);
SPEC-002 v1.3.5 §5 already enforces it on the coordinator. Receipts
issued under v0.1 inherit this invariant. If a future gateway or
coordinator change rewrites any of the §4.2 fields between buyer and
provider (e.g. coercing temperature defaults), receipts will fail
verification against the buyer's raw body — this is a deliberate
detection mechanism, not a bug.
The output_hash field commits to the output the provider produced.
The provider MUST construct the canonical output object as follows:
{
"content": <nfc-string>, // see §5.2
"tool_calls": [<canonical_tool_call>, ...] | null, // see §5.3
"finish_reason": <string> // v0.1.x non-streaming: "stop" | "length" | "tool_calls" | "content_filter" | "error" (v0.2+ streaming may add "cancelled")
}
The object MUST contain EXACTLY these three keys.
- For non-streaming responses (the only receipt-bearing path in
v0.1.x): the full
choices[0].message.contentstring as the provider produced it, NFC-normalized. - For responses where the assistant message contains ONLY tool calls
(no text content),
contentis the JSON empty string"". - For responses with no content emitted at all (e.g., immediate error after token allocation), see §5.4.
Informative forward-compatibility note (v0.2+): a future
streaming receipt design will need to canonicalize the concatenated
choices[0].delta.content chunks. NFC normalization across chunk
boundaries is not associative, so a future v0.2+ design MUST NFC-
normalize the concatenated result once at end-of-stream, not
per-chunk. This guidance is not testable in v0.1.x and binds only
the v0.2+ streaming design.
\r\n → \n and bare \r → \n apply, identical to §4.3.2.
No whitespace stripping.
If the assistant emitted one or more tool calls, the receipt commits
to all of them inside output_hash, not as a separate field. Each
tool call MUST canonicalize to:
{
"id": <string>,
"type": "function",
"function": {
"name": <string>,
"arguments": <string> // the JSON-stringified argument blob the assistant emitted, byte-for-byte
}
}
For non-streaming responses in v0.1.x, a single completed tool call
MUST appear with its full arguments string. Tool calls MUST appear
in tool_calls in the emission order the assistant produced them.
Informative forward-compatibility note (v0.2+): the OpenAI SSE
shape emits choices[0].delta.tool_calls[].function.arguments as a
partial string across many chunks. A v0.2+ streaming receipt design
MUST concatenate those deltas in emission order to match the
non-streaming arguments byte-for-byte. Not testable in v0.1.x.
The arguments field is a string, NOT a parsed JSON object. v0.1
deliberately commits to the byte-exact string the assistant emitted
so a verifier can rebuild it from streaming chunks without parsing
hazards. A v0.x+ may add a parsed-object commitment alongside, but
v0.1's output_hash covers the string form only.
finish_reason is the same value SPEC-005 §3 maps to billing
treatment. For v0.1.x non-streaming receipts, finish_reason is one
of "stop", "length", "tool_calls", "content_filter", or
"error". When the provider returns SPEC-001 null-usage error
classes (error_model_not_loaded, error_context_exceeded,
error_queue_full, error_internal), finish_reason MUST be
"error" and content is the empty string. See §7.6 for the
emission rule in this case.
Informative forward-compatibility note (v0.2+): the OpenAI SDKs
treat a buyer disconnect on a streaming response as
finish_reason="cancelled". v0.1.x streaming requests carry no
receipt regardless of finish_reason; a v0.2+ design that emits
streaming receipts will need to canonicalize the cancelled case.
v0.1.x receipts cover non-streaming responses only (§6.3). The canonical output object defined in §5.1–§5.3 is therefore exercised only by non-streaming output.
For forward compatibility with successor streaming receipts: when a
successor design adds streaming receipts, identical output bytes
emitted in streaming and non-streaming modes MUST hash to the same
output_hash. v0.1.x §5.2's "concatenated output" guidance is
preserved to support that invariant; in v0.1.x it has no testable
consequence and is informative. v0.4's §N.5 makes this invariant
settlement-binding for the delivered streaming prefix.
The receipt is delivered in the HTTP response as:
X-MacProvider-Receipt: <base64(JCS(T))>.<base64(SIG)>
The header name X-MacProvider-Receipt is NEW in SPEC-015 v0.1.
SPEC-006 v0.8.3 §17 lists X-MacProvider-Provider,
X-MacProvider-Route, X-MacProvider-Session,
X-MacProvider-Conversation, X-MacProvider-Internal-Conv,
X-MacProvider-Pref, X-MacProvider-Retry. X-MacProvider-Receipt
does not collide. SPEC-006 v0.9 (candidate, deferred to SPEC-015 v0.1
- SPEC-006 v0.9 absorption) MUST add
X-MacProvider-Receiptto the buyer-facing response-pass-through allowlist so the gateway does not strip it on the buyer hop.
For a non-streaming POST /v1/chat/completions (request body
stream: false or absent), the provider MUST emit
X-MacProvider-Receipt on the inference response. The header value
is set BEFORE the response body is written. The header is forwarded
by coordinator and gateway untouched.
v0.1.x DOES NOT issue receipts for streaming
POST /v1/chat/completions responses. Provider, coordinator, and
gateway MUST treat a streaming request as receipt-free: no
X-MacProvider-Receipt header is emitted; no SSE event is added;
no data: payload is altered. The SSE stream's wire shape is
exactly what SPEC-001 v1.5 and SPEC-006 v0.8.3 already specify.
This is a deliberate v0.1.x scope narrowing in response to round-1 audit C1 and round-2 audit C1/C3. Both rounds established that:
- The v0.1 plan to emit a terminal
event: receiptSSE block is incompatible with the OpenAI Python and JavaScript SDKs' stream loops (Python:openai/_streaming.py; JavaScript:openai-node/streaming.ts). - The v0.1.1 plan to emit an
X-MacProvider-Receipt-Pendingcorrelator header introduces a second buyer-visibleX-MacProvider-*response header that exceeds the single-field SPEC-006 v0.9 candidate allowlist annotation. - Embedding the receipt as an extra field on the final chat-completion chunk is unverified across SDK versions and needs its own SDK-compatibility study.
v0.4 §N.5 defines the settlement streaming receipt transport through SDK-safe internal channels. Before v0.4 implementation, README and operator-facing copy MUST disclose that v0.1.x receipts cover non-streaming responses only. A buyer who needs receipts for streaming traffic in v0.1.x has two options:
- Issue the same request non-streaming and verify against a
pinned
seed(idempotent if the model is deterministic). - Wait for v0.4 settlement-capable streaming receipt delivery.
§15 Q5 records the historical open design question and its v0.4 resolution.
For non-streaming responses, the receipt MUST be omitted (no
X-MacProvider-Receipt header) in the following cases:
- The provider's receipt keypair has not yet been generated (first launch before Keychain setup completes). See §7.1.
- The buyer disconnected before any token was emitted AND the
provider has no committed
tokens_outvalue (tokens_out: 0is committable; see §7.6). - The response was served by a SPEC-001 binary at version
< v1.6(noprovider_receipt_public_keypublished). - The model swap mid-response invariant is violated (see §7.4) — the provider MUST close the response with a 500-class error and MUST NOT emit a receipt.
- The request was streaming. v0.1.x emits no receipts for streaming responses (§6.3).
When a receipt is omitted, the provider MUST NOT emit a placeholder,
empty value, or X-MacProvider-Receipt: omitted sentinel. Header
absence is the signal.
On first launch of phase3-binary serve at SPEC-001 v1.6 or later,
the binary MUST perform an atomic insert-or-load against macOS
Keychain to obtain its receipt private key:
- Construct the Keychain query with:
kSecClass = kSecClassGenericPasswordkSecAttrService = "com.malibu.provider.receipt-key"kSecAttrAccount = <provider_id>kSecAttrAccessible = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlykSecAttrSynchronizable = false
- Attempt
SecItemCopyMatchingwith that query. If a record is present, decode the 32-byte raw private key fromkSecValueDataand skip to step 5. - If
SecItemCopyMatchingreturnserrSecItemNotFound, generate a fresh ed25519 keypair usingCryptoKit.Curve25519.Signing.PrivateKey.init()and callSecItemAddwith the query pluskSecValueData = privateKey.rawRepresentation. - If
SecItemAddreturnserrSecDuplicateItem, anotherserveprocess won the race: discard the just-generated keypair, repeat step 2 to load the winning private key, then proceed. The binary MUST NOT cache the lost candidate. - Cache the loaded private key for the lifetime of the
serveprocess. Refresh from Keychain onSIGHUPor process restart.
The atomic insert-or-load above closes the round-1 audit M5 race:
two simultaneous serve launches with the same provider_id MUST
converge to a single private key (the first SecItemAdd wins; the
loser falls back to load).
The pubkey is derivable from the private key; the binary MUST NOT
store the pubkey separately. The Keychain item is per-provider_id
so reinstalling the binary with a different provider_id produces a
different keypair.
On the next v2 auth_request initial-stage frame (SPEC-001 v1.5
§6.7.1, candidate extension SPEC-001 v1.6), the binary MUST add the
optional field:
"provider_receipt_public_key": "<base64-32-byte-ed25519-public-key>"
The field name is provider_receipt_public_key to mirror the
existing SPEC-008 provider_ecdh_public_key field and to make the
key purpose unambiguous. Encoding is standard padded base64 (44
ASCII characters).
The field MUST be parser-optional on the coordinator side (a
pre-v1.6 binary that does NOT carry the field MUST still admit
successfully; the coordinator MUST treat that provider as
non-receipt-issuing and the gateway MUST NOT emit
X-MacProvider-Receipt for responses routed through that provider).
The proof-stage frame (SPEC-001 v1.5 §6.7.2) is NOT modified by
v0.1.1. The round-1 audit C2 surfaced that the v0.1 plan to echo
provider_receipt_public_key on the proof-stage frame exceeds the
single-field SPEC-001 v1.6 candidate boundary; SPEC-001 v1.5 R-6.7.6
limits proof-stage byte-identity rules to supported_models[] and
publishes_supported_models. v0.1.1 restricts the candidate
annotation to the initial-stage frame ONLY. A future SPEC-015
revision that needs proof-stage echo MUST file it as a separate
SPEC-001 candidate with its own compatibility analysis.
The coordinator stores provider_receipt_public_key on the in-memory
provider struct alongside the existing provider_ecdh_public_key
storage (see phase4-coordinator/internal/pool/provider.go,
SPEC-008 v0.3 §5.5). The field MUST be exposed on /poolz per §8
below; that exposure (with receipt_pubkey and
receipt_pubkey_prev) is the SPEC-002 v1.4 candidate annotation
v0.1.3 pins.
Persistence across restart is an implementation concern, not a
v0.1.3 normative requirement. The round-1 audit C3 surfaced that
the v0.1 plan to mandate
ALTER TABLE providers ADD COLUMN receipt_pubkey TEXT exceeds the
/poolz SPEC-002 v1.4 candidate boundary AND prescribes a schema
(providers table) that does not exist in the locked SPEC-002
v1.3.5 surface. v0.1.3 deliberately scopes the SPEC-002 candidate
annotation to the /poolz shape change and defers the
durable-storage mechanism to the implementation BUILD spec
(BUILD_SPEC_015_IMPL_*_PROMPT.md, not yet written).
The implementation BUILD spec MAY choose any of:
- In-memory only on the coordinator: providers republish their pubkey on every reconnect, the coordinator never persists. This is acceptable because reconnect is the existing recovery path (SPEC-002 v1.3.5 §4 admission semantics).
- Durable in a new SPEC-002 candidate column on the existing
provider_tokensor admission audit table, named as a separate SPEC-002 candidate annotation. - Durable in a v0.x dedicated
receipt_pubkeystable.
v0.1.3 ACs 10–11 verify the runtime surface (the pubkey is exposed
on /poolz, the rotation grace window behavior holds) without
asserting a specific storage mechanism.
A receipt MUST commit to a single provider running a single set of
weights for the duration of the response. If a SPEC-011 v0.5 warm
swap is initiated mid-response, the in-flight response MUST drain
under the old ModelRuntime per SPEC-011 §3.8.4. The receipt is
emitted from the same ModelRuntime instance that produced the
output; no special handling is required for the receipt itself.
If a binary or coordinator bug causes a mid-response swap that violates the drain invariant, the provider MUST close the response with an HTTP 500 error envelope and MUST NOT emit a receipt. This is a fail-closed default; the alternative (emit a receipt over partial output) would silently weaken the binding.
v0.1.x defines manual rotation only. Auto-rotation is deferred to a later version.
The binary MUST support the CLI flag:
macprovider rotate-key
Rotation is performed via WebSocket reconnect, NOT via a new control
frame. The round-2 audit C2 established that introducing a new
provider→coordinator WS frame would exceed the single-field
SPEC-001 v1.6 candidate annotation. The reconnect-based design
reuses the already-authorized initial-stage auth_request field.
When macprovider rotate-key is invoked:
- The binary generates a fresh ed25519 keypair IN MEMORY ONLY. The new keypair is NOT yet written to Keychain.
- The binary closes the current WS connection cleanly.
- The binary opens a fresh WS connection and sends a v2
auth_requestinitial-stage frame carrying the NEWprovider_receipt_public_key. - If the coordinator accepts the auth and proof stages (returning
auth_response.accepted=true), the binary atomically swaps Keychain:- Move the existing Keychain item at
(service=com.malibu.provider.receipt-key, account=<provider_id>)to(service=com.malibu.provider.receipt-key.prev, account=<provider_id>). - Add the new keypair at the original
(service, account). The.prevKeychain item is retained for a 7-day operator recovery window and is auto-deleted by the nextservelaunch that detects it older than 7 days.
- Move the existing Keychain item at
- If the reconnect fails (coordinator rejects auth, network down,
timeout), the binary discards the in-memory new keypair, restores
the WS connection using the OLD Keychain-resident key, and
surfaces the rotation failure to the operator
(
macprovider rotate-keyexits non-zero with a clear error message). - The coordinator infers rotation by comparing the new pubkey
against the previously-known one for this
provider_id. On detection:- The coordinator moves the prior pubkey to
receipt_pubkey_prevwithrotated_at = now. - Sets
receipt_pubkeyto the new value. - Updates
/poolzaccordingly (§8).
- The coordinator moves the prior pubkey to
- The binary signs all NEW receipts emitted after step 4 with the new private key. There is no in-flight rotation window for the PROVIDER side — by construction the old key is unreachable from the moment a new WS connection is established.
The previous-pubkey grace window described in §7.5.1 covers buyers
whose /poolz cache still points at the old key at rotation time.
During the grace window, the coordinator's /poolz response carries
both pubkeys:
"receipt_pubkey": "<new-base64>",
"receipt_pubkey_prev": {
"pubkey": "<old-base64>",
"rotated_at": <unix-seconds>,
"expires_at": <unix-seconds>
}
expires_at is rotated_at + 7 * 86400. After expiration the
coordinator removes the receipt_pubkey_prev block. v0.2 verifiers
MUST accept receipts signed under either receipt_pubkey or
receipt_pubkey_prev during the grace window.
The grace window is time-only in v0.1.3. A v0.x+ may add a request-count-bounded short-circuit (e.g. "after the rotated provider has signed 10000 receipts under the new key, the previous key MAY be retired early"), but that requires a counter contract v0.1.3 deliberately does not pin.
When the provider returns a SPEC-001 null-usage error
(error_model_not_loaded, error_context_exceeded,
error_queue_full, error_internal) per SPEC-005 v0.3 §3 X-1 row:
tokens_outMUST be0.output_hashMUST be the sha256 hex of the canonical output object withcontent="",tool_calls=null,finish_reason="error".ttft_msMUST be the elapsed milliseconds from request-accepted to error-emitted (i.e. the "time to error", which is observationally useful for the buyer).unix_tsis set normally.
The receipt is emitted. This is deliberate: the buyer paying zero under SPEC-005 X-1 still gets a signed acknowledgement that the provider was reached and produced an error response. This closes a SPEC-006 v0.8.2 ambiguity: the v0.8.2 X-1 row debited the buyer zero quota but said nothing about whether the buyer learned what the provider did.
If the provider was never reached (gateway-internal failure,
coordinator preflight rejection, no provider available), no receipt
is emitted because there is no provider to sign one. The error
envelope SPEC-006 §H normalizes the response shape; the absence of
X-MacProvider-Receipt distinguishes "provider never ran this" from
"provider ran and errored".
Buyers retrieve the provider receipt pubkey from the coordinator's
/poolz endpoint (SPEC-002 v1.3.5 §7). v0.1.x ANNOTATES two new fields
per provider object, marked as SPEC-002 v1.4 candidate:
{
"provider_id": "p_01HK4Z3VYE...",
"state": "ready",
"model": "...",
...
"receipt_pubkey": "<base64-32-byte-ed25519>" | null,
"receipt_pubkey_prev": null | { "pubkey": "...", "rotated_at": ..., "expires_at": ... }
}
receipt_pubkey is null for providers whose binary is at SPEC-001
< v1.6 (no key published). Such providers MUST NOT have
X-MacProvider-Receipt headers on responses they serve; the gateway
MUST omit the header if the upstream coordinator's chosen provider
has receipt_pubkey: null.
receipt_pubkey_prev is null outside the rotation grace window.
Buyers SHOULD cache /poolz responses for short windows (≤ 60
seconds) to avoid hammering the endpoint on every verification.
SPEC-002 v1.3.5 already permits /poolz caching at this cadence per
§7.4.
The coordinator operator can rewrite /poolz at any time; v0.1's
trust root is therefore "the coordinator operator does not lie about
which pubkey corresponds to which provider". This is consistent with
the rest of the MacProvider Tier-1 trust posture (SPEC-006 v0.8.3
§1.6) and is acknowledged in the README:
Buyer prompts and provider responses are processed as plaintext on provider hardware … This is acceptable for cooperative deployments where buyer and provider have an established trust relationship; it is NOT a private-inference guarantee.
A stronger trust root — TUF-style operator-signed /poolz, an
external anchor at AntFeed, or a Cluster D-token-anchored registry —
is §15 Q1 and explicitly out of scope for v0.1. Implementers
documenting v0.1 to buyers MUST be honest about this limit; v0.1
receipts protect against provider misbehavior, NOT against
coordinator-operator misbehavior.
When a v0.3+ stronger root lands, the wire format of receipts
(§3.4) MUST be unchanged. Only provider_pubkey source-of-truth
changes. This forward-compatibility commitment is binding on v0.1
implementers: do NOT bake /poolz-specific assumptions into the
verification path; the verifier takes a provider_pubkey argument
out-of-band and verifies against it.
For a non-streaming response:
t0: provider receives request from coordinator
t1: provider begins inference (load model, accept prompt)
t2: first output token emitted → ttft_ms = (t2 - t1) / ms
t3: last output token emitted, finish_reason set, tokens_out known
t4: provider canonicalizes prompt object → prompt_hash
t5: provider canonicalizes output object → output_hash
t6: provider builds tuple T with unix_ts = floor(t3 / second)
t7: provider computes SIG = ed25519_sign(privkey, JCS(T))
t8: provider writes X-MacProvider-Receipt header
t9: provider writes response body
Streaming responses are out of scope in v0.1.x (§6.3); no receipt is emitted, no header is added, and steps t4–t9 do not run on the streaming path.
v0.1 carried this section as a sketch (informative; v0.2 normative). v0.2 promotes it to normative: buyers MUST be able to
use the algorithm below — implemented by the v0.2 macprovider-verify
CLI (binary contract per §10.4) — to obtain a deterministic
verification result for any v0.1.3-shape receipt.
A buyer with the receipt header value and a trusted provider pubkey verifies as follows:
1. Split the header value on the first '.' → (b64_tuple, b64_sig).
2. Decode JCS_T = base64_decode(b64_tuple).
3. Decode SIG = base64_decode(b64_sig). Reject if len(SIG) != 64.
4. Parse JCS_T as JSON to confirm well-formed and contains exactly
the seven SPEC-015 §3.1 keys.
5. Resolve the trusted pubkey for the resolved `provider_id` per
§10.2 (sources: explicit `--pubkey` → cached entry → live
`GET /v1/receipt-keys/<provider_id>`). The verifier MUST NOT
resolve by scanning across providers for a matching
`provider_pubkey`; `provider_id` is the resolver address. If
the trust root cannot reach a verdict → `inconclusive`
(§10.1).
6. ed25519_verify(trusted_pubkey, JCS_T, SIG). Reject on failure
→ `invalid` (§10.1).
7. Canonicalize the buyer's recorded request prompt per §4 →
prompt_hash_local. If != receipt.prompt_hash → `invalid`.
8. Canonicalize the buyer's recorded response output per §5 →
output_hash_local. If != receipt.output_hash → `invalid`.
9. → `valid` (§10.1).
The optional unix_ts skew check that appeared in v0.1's sketch is
removed from the v0.2 core algorithm. Per §10.6, the timestamp is
NOT proven by a valid result; cross-checking against buyer-side
received-at remains a v0.3+ candidate (§15 Q4). A v0.2 verifier MAY
emit an informational warning if unix_ts is wildly off (e.g.
24h skew vs. system clock), but MUST NOT downgrade the result to
invalidon skew alone.
A verifier MUST return exactly one of three results for any (receipt, request, response) input:
| Result | Meaning | Exit code |
|---|---|---|
valid |
Signature verifies, canonical hashes match, pubkey resolved to a trusted source | 0 |
invalid |
Signature fails OR a canonical hash mismatches OR pubkey is known-revoked | 1 |
inconclusive |
Pubkey could not be resolved AND no explicit pubkey was supplied | 2 |
A verifier MUST NOT collapse inconclusive into either of the other
results. In particular, a verifier MUST NOT report valid when the
pubkey is unresolved, even if a signature self-verifies against a
pubkey embedded in the receipt: an unrooted pubkey is unrooted,
regardless of the signature's internal consistency.
inconclusive is the correct result when ANY of the following
hold AND no explicit --pubkey was supplied AND the verifier
passed §10.4 input validation (i.e. provider_id was obtainable
at parse time per §10.4 "Provider-id requirements"):
- The configured coordinator's
GET /v1/receipt-keys/<provider_id>endpoint (§10.7) is unreachable (network down, DNS failure, 5xx, timeout, rate-limited via 429) AND no fresh cached entry exists for(coordinator_host, provider_id, receipt_pubkey), OR - The §10.7 endpoint returns HTTP 404 for the
provider_id— an authoritative "this provider is unknown to me" answer, treated asinconclusivewithreason: "provider_id_not_in_pool"because the receipt itself may predate provider removal, OR - The cache holds only a stale entry (older than the §10.2 7-day TTL) AND the live fetch fails.
The "provider_id is not addressable at all" case (no CLI arg, no
bundle field, no single-match cache) is NOT an inconclusive
case in v0.2 — it is an exit-64 usage error per §10.4. The
verifier rejects the invocation at parse time and never reaches
the result-determination algorithm. See §10.4 "Provider-id
requirements" for the exit-64 contract.
invalid (not inconclusive) is the correct result when ANY of
the following hold:
- The resolver returns an authoritative provider record for the
resolved
provider_id(HTTP 200, parseable response) whosereceipt_pubkeyandreceipt_pubkey_prev.pubkeyare BOTH different from the receipt's embeddedprovider_pubkey— the coordinator has explicitly named the keys it endorses for this provider and the receipt's key is not among them, OR - The resolver returns a
receipt_pubkey_prevmatch BUT the receipt'sunix_tsfalls outside the §10.2.1 grace window, OR - The signature check fails against a successfully-resolved trusted pubkey, OR
- Either canonical hash mismatches.
The boundary between inconclusive and invalid is the
authoritative-resolver-answer test: if the trust root reached a
verdict ("no, I don't endorse this key for this provider"), the
receipt is invalid; if the trust root could not reach a verdict
(unreachable, identity unresolvable), the receipt is
inconclusive. A receipt MUST NOT be inconclusive when the
coordinator's authoritative response excludes its
provider_pubkey — that case is a coordinator-rejected forgery
(or retired key), not an environmental failure.
The HTTP 404 response from the §10.7 endpoint (provider not in
the current pool) is a degenerate case: it is authoritative
("this provider_id is unknown to me") but the receipt itself may
predate the provider's removal. v0.2 verifiers MUST treat 404 as
inconclusive with reason: "provider_id_not_in_pool" per the
§10.4.2 reason enum. v0.3+ MAY revisit this if the coordinator
gains a "retired but historic" state.
A verifier MUST resolve the pubkey it trusts against, in this priority order:
- Explicit: A pubkey supplied by the caller via
--pubkey <44-char base64>. Used for offline / air-gap verification. When supplied alongside--provider-id, the verifier MUST treat the pair as the trusted root regardless of live coordinator state. The explicit pubkey wins the verification result; live divergence is reported viawarnings[]per §10.4.2, not via result downgrade. - Cached: A pubkey stored locally from a prior
GET /v1/receipt-keys/<provider_id>fetch (§10.7), keyed by the tuple(coordinator_host, provider_id, receipt_pubkey). Cache entries MUST carry afetched_attimestamp, therotated_atandexpires_attimestamps as returned by the coordinator, and the correspondingreceipt_pubkey_prevrecord (if any) for grace-window verification. All three timestamps MUST be stored as RFC3339 UTC strings matching the §10.7 wire shape; conversion from the receipt's Unix-secondsunix_tsto RFC3339 (or vice versa) happens at the cache boundary, not at every comparison. The receiptunix_tsitself remains Unix seconds per the locked v0.1 wire contract.- Fresh entry (cache
fetched_at≤ 7 days beforenow()): used directly. - Stale entry (cache
fetched_at> 7 days beforenow()): MUST trigger a fresh live fetch. On fetch success, replace the entry. On fetch failure, the verifier MUST NOT use the stale entry to producevalid— the result isinconclusive. The provider-reportedunix_tsMUST NOT be used to revalidate a stale cache entry (per §10.6, timestamp honesty is not proven; staleness is a coordinator-attested property, not a buyer-derivable one). - The 7-day TTL matches §7.5.2 rotation grace; a key that has not rotated within 7 days remains valid via fresh-fetch refresh.
- Fresh entry (cache
- Live: A fetch of
GET /v1/receipt-keys/<provider_id>(§10.7 — SPEC-002 v1.5 candidate annotation, public / unauthenticated / rate-limited) on the coordinator named in the verifier's config (default:coordinator.malibu.tech). MUST be a singleGETover HTTPS with a 5-second timeout and no retries. On success, the verifier MUST update its cache (writefetched_at = now()) before continuing. The verifier MUST NOT fall back toGET /poolz: that endpoint is operator-only per SPEC-002 v1.4 §FR-O2 and is not buyer-safe.
provider_id resolution: when the bundle provides provider_id,
the verifier uses it directly. When provider_id is absent, the
verifier MUST NOT scan all known providers. The fallback order is:
(1) explicit --provider-id CLI argument, (2) a single matching
cached entry's provider_id under the configured coordinator. If
neither yields a provider_id AND no --pubkey is supplied, the
verifier exits 64 per §10.4 "Provider-id requirements" — the
verifier MUST NOT emit inconclusive for missing-input cases.
Pubkey-byte scanning across providers re-introduces the
identity-loss problem audit A4 named.
Explicit-vs-live divergence handling (S5): Whenever an
explicit pubkey is supplied AND the verifier is not running with
--offline, the verifier MUST attempt the live /v1/receipt-keys
fetch in the background. If the live pubkey for the supplied
provider_id differs from the explicit one, the verifier MUST
record a warnings[] entry in JSON output with kind
explicit_vs_live_divergence and the differing live pubkey. The
explicit pubkey still wins for result; the warning is recorded
regardless of --quiet (which suppresses only stderr emission,
not the warning record itself). With --offline, the live check
is skipped and a warnings[] entry of kind live_check_skipped
is recorded for output transparency.
If sources (1), (2), and (3) all fail to yield a trusted pubkey
(no explicit, no fresh cached entry, /v1/receipt-keys
unreachable or returns no matching entry), the result is
inconclusive. A verifier MUST NOT fall back to "trust the
receipt's embedded provider_pubkey on faith."
A receipt issued under the previous key during the §7.5.2 rotation
grace window MUST verify valid ONLY when ALL of the following
hold:
- The resolved
/v1/receipt-keys/<provider_id>response (live or cached) contains a non-nullreceipt_pubkey_prevblock with apubkeyfield matching the receipt'sprovider_pubkey. - The receipt's
unix_tssatisfiesrotated_at - 60s ≤ unix_ts ≤ expires_at, whererotated_atandexpires_atare taken from thereceipt_pubkey_prevblock.
The -60s slack matches the v0.1 AC-11 invariant and absorbs
provider-side clock skew within the rotation moment. A previous-
key match OUTSIDE this interval MUST verify invalid, NOT
valid or inconclusive: the coordinator has explicitly named the
window during which the previous key was endorsed, and a receipt
outside it is one of (a) a clock-cheating provider attempting to
extend the grace, (b) a stale receipt the buyer is presenting late
(out of contract), or (c) a forgery. None of these warrant
valid.
A receipt whose provider_pubkey matches neither receipt_pubkey
nor receipt_pubkey_prev.pubkey for the resolved provider_id
MUST be invalid, not inconclusive: the coordinator has
explicitly stated which keys it endorses for this provider, and
the receipt's key is not among them.
The verifier MUST canonicalize the buyer-held prompt and response using bit-identical rules to those §3.2 and §§4-5 pin for the provider-side signing path. Specifically:
- JCS per RFC 8785, with the SPEC-015 v0.1.1 §3.2 extensions (RFC 8785 §3.2.2.3 float handling and explicit NFC normalization of natural-language strings).
- Prompt canonical object per §4.2 (16-key shape).
- Output canonical object per §5.1 (
content/tool_calls/finish_reason).
A v0.2-compliant verifier that diverges from these rules is non-conforming. A verifier MUST NOT add a "lenient" mode that accepts non-canonical inputs: doing so destroys the cryptographic property that makes verification meaningful.
If a buyer's tool has re-serialized or pretty-printed the response
JSON before passing it to macprovider verify, the canonicalization
step (which re-parses the response to its abstract value and
re-emits canonical bytes) MUST still reproduce the same
output_hash the provider signed. If it does not, the receipt is
invalid, not "verifier needs to be more lenient."
The Go port of RFC8785JCS.swift shipped with the v0.2 verify CLI
MUST include a parity test (testdata/jcs_parity.json — same
inputs, same canonical outputs across Swift and Go) wired as a CI
gate. Any drift between the two implementations MUST fail CI
before the verify binary can be released.
The verifier MUST accept these input shapes:
- Header + hashes mode:
macprovider verify --receipt <base64> --prompt-hash <hex> --output-hash <hex> [--provider-id <id>]— for callers who have already canonicalized and hashed the request/response.--provider-idis REQUIRED in this mode UNLESS--pubkeyis also supplied (see §10.4 "Provider-id requirements" below); without it the live resolver cannot be addressed. - Bundle mode:
macprovider verify --bundle <path> [--provider-id <id>]— bundle JSON shape pinned in §10.4.1. The bundle'sprovider_idfield MAY be omitted; if so,--provider-idbecomes REQUIRED for online verification.--provider-id(when supplied) MUST match the bundle'sprovider_id(when also present); a mismatch is a usage error (exit 64). - Stdin mode:
cat bundle.json | macprovider verify - [--provider-id <id>]— same shape as bundle mode, read from stdin. Same--provider-idrules.
A verifier MAY accept additional input shapes (e.g. raw HTTP response capture) as long as they reduce to one of the three above before the §10.0 algorithm runs.
Provider-id requirements (CF5 / CF7 normative): The §10.7
resolver endpoint is addressed by provider_id. The verifier MUST
obtain provider_id from one of:
- The
--provider-id <id>CLI argument (first-class input). - The bundle's
provider_idfield (bundle/stdin modes only). - A single matching cached entry for
receipt_pubkeyunder the configured coordinator (degenerate "I've seen exactly one of these before" path; verifier MUST NOT scan multiple cached entries).
Without --pubkey (online verification path): provider_id
MUST be obtained from (1)/(2)/(3) before the verifier runs. If
none of those sources yield a provider_id, the verifier MUST
reject the invocation with exit code 64 (usage error) and a
clear error message naming --provider-id as the missing input.
The verifier MUST NOT run to completion and emit inconclusive in
this case: the receipt may be perfectly valid, but the buyer has
not supplied enough information to reach the trust root. This is
a CLI contract violation, not a trust-root failure. Other
"missing required argument" cases (--receipt, --bundle) follow
the same exit-64 convention.
With explicit --pubkey: online verification does NOT require
provider_id to produce valid — the explicit pubkey serves as
the trust root. The verifier MUST still attempt to record
provider_id (from sources 1/2/3) for output reporting and the
live divergence-warning check (§10.2). If no provider_id is
recoverable AND the verifier is online, JSON output emits
provider_id: null and warnings[] gains a
live_check_skipped entry with reason: "provider_id_unresolvable". The verifier MUST NOT
fingerprint-scan across providers under any circumstance.
The verifier MUST NOT use inconclusive as a substitute for the
missing-provider-id exit-64 case: inconclusive is reserved for
trust-root failures the verifier discovered during execution, not
for CLI contract violations the verifier knows at parse time.
{
"bundle_version": 1,
"receipt": "<base64(JCS(T))>.<base64(SIG)>",
"request": { "model": "...", "messages": [ ... ], ... },
"response": { "id": "...", "choices": [ ... ], "usage": { ... } },
"provider_id": "m1-anon"
}bundle_version(REQUIRED, integer): pinned to1in v0.2.x. A verifier MUST reject any other value as an input format error with exit code65per §10.4.3. (Unsupportedbundle_versionis data that the verifier cannot parse, not a CLI usage mistake.) v0.3+ MAY introducebundle_version: 2for additive fields; v0.3+ verifiers MUST continue to acceptbundle_version: 1.receipt(REQUIRED, string): the verbatim value of theX-MacProvider-Receiptresponse header, which per §3.4 has the shape<base64(JCS(T))>.<base64(SIG)>(two base64 segments separated by a literal.).request(REQUIRED, object): the OpenAI/v1/chat/completionsrequest body as captured by the buyer. This is the raw request as the buyer's HTTP client saw it; the verifier MUST NOT require pre-canonicalization or pre-population of optional fields. Any §4.2 canonical-prompt field absent from the captured request canonicalizes as JSONnullper the locked v0.1 §4.2 rule. Buyers who use the OpenAI SDK with only the requiredmodel+messagesparameters MUST be able to bundle the SDK-sent request unchanged and have it verify.response(REQUIRED, object): the OpenAI completion response as captured by the buyer. Same rule: raw, no pre-canonicalization.provider_id(OPTIONAL, string): the provider identifier as surfaced by the coordinator. When present, this is used as the primary key for §10.2 step 2/3 pubkey resolution and §10.2.1 rotation-grace lookup. When absent, the verifier follows the §10.4 "Provider-id requirements" fallback order (explicit--provider-id, then single-match cache); if neither yields aprovider_idAND no--pubkeyis supplied, the verifier exits64(usage error) before any verification runs. The verifier MUST NOT emitinconclusivefor missing-input cases.
A v0.2 verifier MUST reject unknown top-level keys with exit code
65 (input format error per §10.4.3). This prevents future
ambiguity about field semantics and forces forward-compatibility
changes through the bundle_version bump.
--json MUST emit a single line of JSON conforming to the field
table below.
Top-level fields:
| Field | Disposition | Type | Notes |
|---|---|---|---|
result |
REQUIRED | enum string | One of valid, invalid, inconclusive. |
reason |
REQUIRED | enum string | See "reason values" table below. |
provider_id |
REQUIRED-when-resolved, else null |
string|null | The coordinator-attested provider_id used for pubkey lookup. null when result is inconclusive and no provider could be identified. |
model_id |
REQUIRED-when-resolved, else null |
string|null | Read from the receipt tuple. null only when the tuple itself could not be parsed (a 65 exit-code path that produces no JSON anyway). |
signed_at |
REQUIRED-when-resolved, else null |
integer|null | The receipt's unix_ts. Same null rule as model_id. |
trust_source |
REQUIRED | enum string | One of explicit_pubkey, cache, live, none. The last only when result == "inconclusive". |
coordinator_host |
REQUIRED-when-trust_source-is-network-derived, else null |
string|null | The coordinator host that supplied the trust root. Required when trust_source is cache (cache origin host) or live. null for explicit_pubkey (no coordinator involved) and none. |
details |
REQUIRED-when-invalid, else absent | object | See "details schema" below. MUST be present when result == "invalid". MUST be absent otherwise. |
warnings |
OPTIONAL | array of objects | Each entry has a kind (enum) and kind-specific fields. See "warnings schema" below. Array MAY be empty or absent when no warnings apply. |
reason values (enum, exhaustive for v0.2.x):
- For
valid:signature_and_canonicalization_match - For
invalid:signature_verify_failed,prompt_hash_mismatch,output_hash_mismatch,pubkey_not_endorsed,previous_key_outside_grace_window,bundle_pubkey_provider_mismatch - For
inconclusive:pubkey_unresolvable,provider_id_not_in_pool,cache_stale_and_live_unreachable
v0.3+ MAY extend the enum additively; v0.3+ verifiers MUST emit v0.2-known values for v0.2-mapped cases.
details schema (REQUIRED when result == "invalid"):
| Field | Type | Notes |
|---|---|---|
field |
enum string | One of signature, prompt_hash, output_hash, pubkey, grace_window. |
computed |
string | The value the verifier computed (hex for hashes, base64 for pubkey, etc.). Absent only when field == "signature" (the signature check is opaque). |
receipt |
string | The value carried by the receipt for comparison. |
extra |
object | OPTIONAL, field-specific extra context (e.g. rotated_at/expires_at/unix_ts for grace_window). |
warnings[] schema:
kind value |
Additional fields | When emitted |
|---|---|---|
explicit_vs_live_divergence |
live_pubkey (string), coordinator_host (string) |
Explicit --pubkey was used AND a live /v1/receipt-keys fetch succeeded AND returned a different pubkey for the same provider_id. |
live_check_skipped |
reason (one of offline_flag, network_unreachable, provider_id_unresolvable) |
The live divergence check did not run. offline_flag: --offline was passed. network_unreachable: live fetch failed (network down, 5xx, timeout, 429). provider_id_unresolvable: explicit --pubkey was supplied AND no provider_id was recoverable from CLI, bundle, or cache (the verifier had nothing to address the resolver with). |
non_default_coordinator |
coordinator_host (string) |
A non-default coordinator (i.e. not coordinator.malibu.tech) was used as the trust-root source. |
non_default_tls_trust |
ca_file_path (string) |
The MACPROVIDER_VERIFY_TLS_CA_FILE env var was honored and successfully augmented the TLS trust pool used to reach the coordinator. Surfaces silent trust widening so a buyer running under a wrapper script (CI helper, devcontainer setup, ~/.profile modification by malware) where the env var has been set to point at an attacker-controlled CA chain sees a visible indicator. Added in SPEC-015 v0.3.4 (issue #128). |
clock_skew |
unix_ts (int), system_time (int), delta_seconds (int) |
Receipt unix_ts differs from the verifier's system clock by more than 24 hours. Informational only — does NOT downgrade result per §10.6. |
A verifier MUST emit warnings[] entries regardless of --quiet
(which suppresses only stderr emission, not the JSON record).
Example outputs:
{"result":"valid","reason":"signature_and_canonicalization_match","provider_id":"m1-anon","model_id":"qwen2.5-7b-instruct-q4","signed_at":1719144000,"trust_source":"live","coordinator_host":"coordinator.malibu.tech","warnings":[]}{"result":"invalid","reason":"output_hash_mismatch","provider_id":"m1-anon","model_id":"qwen2.5-7b-instruct-q4","signed_at":1719144000,"trust_source":"live","coordinator_host":"coordinator.malibu.tech","details":{"field":"output_hash","computed":"ab12...","receipt":"cd34..."}}{"result":"inconclusive","reason":"cache_stale_and_live_unreachable","provider_id":"m1-anon","model_id":"qwen2.5-7b-instruct-q4","signed_at":1719144000,"trust_source":"none","coordinator_host":null,"warnings":[{"kind":"live_check_skipped","reason":"network_unreachable"}]}Default (non-JSON) human-readable output is a single line:
valid (m1-anon · qwen2.5-7b-instruct-q4 · signed 2026-06-23T08:00Z · trust=live@coordinator.malibu.tech)
invalid: output_hash mismatch (computed=ab12... receipt=cd34...)
inconclusive: cache stale and /v1/receipt-keys unreachable on coordinator.malibu.tech
When the trust_source is live or cache, the human-mode line
MUST include the coordinator host (rendered as
trust=<source>@<host>). Warnings MUST be printed to stderr (one
per line, prefixed warning:) unless --quiet suppresses stderr.
The v0.2 CLI SHOULD include a --explain flag that prints §10.6
verbatim to stderr after a valid result, so a buyer who reads
valid is reminded of what valid does and does not mean.
| Code | Meaning |
|---|---|
| 0 | valid |
| 1 | invalid (signature, canonicalization, coordinator-rejected pubkey, or previous-key-outside-grace-window) |
| 2 | inconclusive (pubkey unresolvable, provider_id not in pool per §10.7 404, cache stale + live unreachable) |
| 64 | usage error (per sysexits.h, EX_USAGE) — unknown CLI flag, missing required CLI argument, mutually-exclusive flags combined (e.g. --bundle + --receipt), invalid value format for a CLI flag (e.g. malformed --pubkey base64) |
| 65 | input format error (per sysexits.h, EX_DATAERR) — malformed bundle JSON, missing required bundle field, unknown bundle top-level key, unsupported bundle_version, malformed receipt header value (cannot split on .), base64 decode failure on tuple or signature, tuple JSON not well-formed or wrong key set |
These exit codes are normative. Scripts and CI pipelines WILL rely on them. A future v0.3+ verifier MUST preserve the 0/1/2/64/65 mapping; adding new exit codes for new failure modes is allowed only in the >65 range (e.g. 66 for cache-corruption diagnostics).
64 vs 65 boundary: 64 is for problems with how the
verifier was invoked; 65 is for problems with the data the
verifier was asked to verify. An unsupported bundle_version is
data the verifier cannot parse, so it is 65. A typo'd flag is
how the verifier was invoked, so it is 64. A malformed
--pubkey argument is 64 (the flag value is malformed,
preventing invocation), but a malformed receipt field inside a
syntactically-valid bundle is 65 (the bundle was accepted, but
its receipt content is unparseable).
The v0.2 CLI flags are listed in §10.4. This matrix pins their interaction semantics for combinations that are not obvious from individual flag descriptions.
| Flag combination | Live /v1/receipt-keys fetch? |
Divergence warning? | Stderr emission? | Result downgrade? |
|---|---|---|---|---|
(no --pubkey, no --offline) |
YES (default path) | n/a | per-mode | n/a |
--pubkey P (no --offline) |
YES (background, for divergence check) | YES if live differs | per --quiet |
NO — explicit wins |
--pubkey P --offline |
NO | n/a — live_check_skipped warning emitted |
per --quiet |
NO |
--offline (no --pubkey) |
NO | n/a | per --quiet |
inconclusive if cache miss / stale |
--quiet (alone) |
per other flags | per other flags | SUPPRESSED (stderr only) | NO |
--quiet --json |
per other flags | per other flags | SUPPRESSED (stderr); warnings still in JSON warnings[] |
NO |
--coordinator H (or env) |
YES, against host H |
per other flags | per --quiet |
NO; non_default_coordinator warning if H != coordinator.malibu.tech |
--explain |
per other flags | per other flags | §10.6 verbatim printed to stderr after valid result | NO |
--bundle B --receipt R |
n/a | n/a | n/a | USAGE ERROR (exit 64) — mutually exclusive |
--bundle - (stdin mode) |
per other flags | per other flags | per --quiet |
NO |
--provider-id I + header+hashes mode (no --pubkey) |
YES, addressed by I |
n/a (no explicit) | per --quiet |
NO if resolver responds; inconclusive if I returns 404 |
--provider-id I + header+hashes mode + --pubkey P |
YES (background only) | YES if live differs | per --quiet |
NO — explicit wins |
--provider-id I + bundle mode where bundle also has provider_id: J and I != J |
n/a | n/a | n/a | USAGE ERROR (exit 64) — mismatched provider identity |
--provider-id I + bundle mode where bundle has provider_id: I (or none) |
per other flags | per other flags | per --quiet |
NO |
header+hashes mode (no --provider-id, no --pubkey) |
n/a | n/a | n/a | USAGE ERROR (exit 64) — --provider-id required for online verification without explicit pubkey |
bundle/stdin mode (no bundle provider_id, no --provider-id, no --pubkey, no single-match cache entry) |
n/a | n/a | n/a | USAGE ERROR (exit 64) — same as above; provider id unobtainable for online verification |
header+hashes mode + --pubkey P (no --provider-id) |
NO (no provider_id to address) |
n/a | per --quiet; live_check_skipped warning with reason: provider_id_unresolvable |
NO — explicit pubkey wins; provider_id: null in JSON output |
--provider-id summary: REQUIRED for online verification in
header+hashes mode unless --pubkey is supplied. OPTIONAL in
bundle/stdin modes when the bundle carries provider_id (the
bundle field takes precedence on absence; mismatch is a usage
error). When neither source provides provider_id AND no
--pubkey is supplied, the verifier rejects the invocation with
exit code 64 per §10.4 "Provider-id requirements" — the
verifier MUST NOT scan and MUST NOT emit inconclusive in this
case (it is a CLI contract violation, not a trust-root failure).
The matrix is normative. A verifier MUST NOT introduce flag combinations whose semantics aren't covered here or aren't trivially derivable from §10.4 / §10.4.2 / §10.5. A v0.3+ verifier MAY add new flags; if a new flag interacts with any v0.2 flag, the v0.3+ spec MUST extend this matrix.
--quiet semantics (final): suppresses all stderr emission
(including warning: lines and --explain output). Does NOT
suppress JSON warnings[] records. Does NOT change exit code.
The verifier MUST NOT make any network call beyond
GET /v1/receipt-keys/<provider_id> (§10.7) on the configured
coordinator host for pubkey resolution. No telemetry. No opt-in
analytics. No version-check beacon. No crash reporting. No update
check. No fallback to /poolz (which is operator-only per SPEC-002
v1.4 §FR-O2). A buyer running
macprovider verify --offline --pubkey <p> ... on an air-gapped
Mac MUST observe zero network traffic (verifiable via packet
capture or a network sandbox that denies all egress).
The live fetch is a single GET over HTTPS with a 5-second
connection-plus-read timeout. No retries. The verifier MUST NOT
follow HTTP redirects beyond the configured coordinator host
(default: coordinator.malibu.tech; configurable via
--coordinator flag or MACPROVIDER_COORDINATOR environment
variable). Redirects whose Location resolves to a different host
MUST be treated as a fetch failure (contributing to inconclusive
when no fresh cache exists), not silently followed. A redirect to
the SAME host (e.g. http→https upgrade) MAY be followed.
A buyer who wants different timeout / retry semantics MUST
pre-populate the cache and run with --offline. The verifier MUST
NOT expose --timeout or --retries flags in v0.2: variability
in fetch semantics across deployments would make inconclusive
mean different things to different buyers.
When the configured coordinator host is NOT the default
coordinator.malibu.tech, the verifier MUST record a
non_default_coordinator warning per §10.4.2 in every output
(JSON and human-mode stderr unless --quiet). The trust boundary
is coordinator-specific; making non-default coordinators visible
is a buyer-protection invariant.
A valid result from macprovider verify proves exactly this:
a holder of the provider's private key signed a canonical tuple
containing the values (model_id, prompt_hash, output_hash,
provider_pubkey, ttft_ms, tokens_out, unix_ts), AND the
pubkey that signature checks against is the one the coordinator
publishes for the resolved provider_id at verification time (or
was within the §7.5.2 rotation grace window per §10.2.1).
The phrasing "signed a tuple containing unix_ts" is deliberate:
the signature commits the holder of the private key to the claimed
timestamp value, but does NOT prove that value reflects the real
wall-clock time at signing. The signed-at attestation is about
content, not chronology.
A valid result DOES NOT prove:
- That the response was generated by the model named in
model_id. Model-hash binding is the SPEC-011 v0.5 catalog-signing surface; foldingmodel_hashinto the receipt tuple is the v0.3+ candidate per §15 Q6. A v0.2 verifier MUST NOT silently treatvalidas "model attestation." v0.3 supersession (NORMATIVE). This bullet is SUPERSEDED by §M.3.3 for v0.3validresults that carry non-nullmodel_hashAND were resolved against a fresh, signature-valid, non-expired catalog. For v0.3validresults with nullmodel_hashOR without catalog arguments, this bullet REMAINS in force unchanged. v0.1 / v0.2 verifiers (locked releases) continue to read this bullet at full strength. - That
unix_tsis honest. The timestamp is provider-reported. The verifier MAY optionally cross-check against a buyer-recorded received-at timestamp with an operator-set skew window, but v0.2 does NOT require this check (see §15 Q4), and avalidresult without skew-check does NOT attest to timestamp honesty. - That no other party also saw the response. Privacy
properties are SPEC-008 / Cluster E territory and are orthogonal
to receipt verification. A receipt with
validsays nothing about whether the operator, the coordinator, the gateway, or another buyer also observed the response bytes. - That the pubkey itself is trustworthy in some absolute sense.
v0.1's §8 trust root (
/poolz) is operator-mutable. The v0.1 SPEC is honest about this; v0.2 inherits that honesty without weakening it. The §15 Q1 stronger-trust-root work (TUF-style signing, on-chain anchor) is v0.3+ scope. - That the response was delivered to the buyer who is now
verifying it. A receipt commits to (prompt, output, provider);
it does not commit to
request_idor a buyer-supplied nonce. Replay-resistance is §15 Q2 (v0.2 verifier scope per the v0.1 text, now deferred to v0.3+ — see §15 Q2 update below). - That this was the only receipt issued for this response. A
receipt does not commit to uniqueness. A provider could in
principle issue multiple receipts for the same canonical
(prompt, output) tuple — to different buyers, on different
reconnects, or by re-running the same prompt. Each receipt
independently verifies on its own merits;
validsays nothing about whether anothervalidreceipt also exists. This matters for accounting (a buyer cannot use a receipt as proof of sole-delivery for billing-dispute purposes) and is orthogonal to the replay-resistance concern above.
A valid result from macprovider verify is therefore a narrow,
specific proof: cryptographic evidence that some holder of the
provider's signing key — which the coordinator currently endorses
— attests to having produced this (prompt → output) mapping. It
is necessary for verifiable inference. It is not sufficient.
SPEC-015 v0.3+ closes the remaining gaps (model attestation,
timestamp honesty, replay resistance, stronger trust root) in
priority order determined by audit-loop and operator demand.
A verifier's human-mode output line for valid SHOULD frame this
scope visibly — e.g. by including the phrase signed by m1-anon
rather than verified m1-anon. The --explain flag of §10.4.2
exists precisely to make this trust boundary unmissable to a
buyer who is about to act on a valid result.
v0.2's verifier contract depends on a public, buyer-callable
pubkey-resolution endpoint that the locked SPEC-002 v1.4 surface
does not provide (GET /poolz is operator-only per §FR-O2;
GET /v1/pool/check does not return receipt-key material). v0.2
pins the buyer endpoint as a SPEC-002 v1.5 candidate annotation
following the same parser-optional / additive / non-breaking
pattern v0.1 used for receipt_pubkey (SPEC-002 v1.4 candidate)
and provider_receipt_public_key (SPEC-001 v1.6 candidate).
A SPEC-002 v1.5 release MUST add the endpoint as specified below; SPEC-015 v0.2 implementations MAY use it before SPEC-002 v1.5 LOCK provided the coordinator returns the exact shape.
Endpoint: GET /v1/receipt-keys/<provider_id>
- Host placement: Same nginx route split as the existing
buyer-facing
GET /v1/pool/check(SPEC-002 v1.4 §FR-O3) — i.e. on thebuyer_portroute, NOT the operator/poolzroute. - Authentication: NONE (public). A buyer with no operator credentials MUST be able to call this endpoint. Pubkey attestation is a public-trust-root surface — the same property TUF / on-chain anchoring (§15 Q1) layers on top of.
- Rate limiting: Operator-configurable; recommended floor
10 req/secper source IP, with a429response on overage. This protects the coordinator against amplification attacks while leaving headroom for batch buyer-side verification. Source-IP derivation (issue #125). Per-source bucket keying goes through the operator-configuredproxy.trusted_proxiesCIDR set (see SPEC-002 v1.4.xproxy.trusted_proxiesblock): when the immediate peer is in the trusted set the coordinator parsesX-Forwarded-Forrightmost-untrusted-hop first, falling back toX-Real-IP; for untrusted peers the forwarded headers are ignored and the peer's own IP is the bucket key (spoof rejection). - Caching headers: Response MUST include
Cache-Control: public, max-age=300(5 minutes). Verifiers SHOULD NOT bypass this cache viaCache-Control: no-cacherequest headers — staleness up to 5 minutes is acceptable for receipt verification, and bypass attacks would defeat the rate-limit.
Response (success, HTTP 200):
{
"provider_id": "m1-anon",
"receipt_pubkey": "<44-char base64 ed25519 pubkey>",
"receipt_pubkey_prev": null | {
"pubkey": "<44-char base64 ed25519 pubkey>",
"rotated_at": "<RFC3339 UTC>",
"expires_at": "<RFC3339 UTC>"
},
"fetched_at": "<RFC3339 UTC; server-side now()>"
}The receipt_pubkey and receipt_pubkey_prev fields MUST be
sourced from the same coordinator memory the SPEC-002 v1.4 §FR-O2
/poolz response reads (i.e. the in-memory Provider.ReceiptPubkey
state per §13). Response MUST NOT leak any operator-sensitive
field (e.g. endpoint_url, hostname, connected_at,
slots_total, throughput_tps_estimate) — only the receipt-key
tuple.
Response (error):
- 404 —
provider_idnot in the current pool. Body is the SPEC-002 §FR-X-N standard JSON error envelope witherror.code = provider_not_found. The verifier treats this asinconclusivewithreason: "provider_id_not_in_pool", NOTinvalid: the provider may have been retired, but the receipt is not necessarily a forgery. - 429 — rate limit exceeded. Verifier treats as a fetch
failure (contributing to
inconclusiveif no cache), MUST NOT retry within the same verification invocation. - 5xx — coordinator internal failure. Same fetch-failure
treatment as
429.
Reference behavior on rotation: Within the §7.5.2 7-day grace
window, the response carries BOTH receipt_pubkey (the new key)
AND receipt_pubkey_prev (the previous key block, with
rotated_at and expires_at). After the grace window expires,
the coordinator MUST drop receipt_pubkey_prev (set to null).
This precisely mirrors the existing /poolz receipt_pubkey_prev
shape so SPEC-002 v1.5 reuses the v1.4 data model.
Why this is a candidate annotation, not an operator demand:
the SPEC-002 v1.5 amendment is additive (new endpoint, no changes
to existing endpoints), non-breaking (/poolz retains operator-
only access), parser-optional (a SPEC-002 v1.4 coordinator without
the new endpoint returns 404; verifier treats as inconclusive
and falls back to explicit/cache). This matches the SPEC-008 v0.3
§5.3 / §5.7 candidate-annotation pattern used throughout the
v0.1-line cross-cuts.
v0.3 extends the receipt tuple to bind which model weights actually
served the buyer, closing the v0.1 / v0.2 gap the §10.6 trust
boundary names explicitly ("DOES NOT prove that the response was
generated by the model named in model_id"). The infrastructure
to do this already exists in production; v0.3 binds it into the
receipt:
- SPEC-011 v0.5 R-3.3.1 defines provider-reported
model_hashon the heartbeat — raw 64-char lowercase hex of the loaded MLX container. scripts/sign-catalog.goproduces ed25519-signed model catalogs mappingmodel_id → expected_hash(the per-entry field is namedsha256perscripts/sign-catalog.go:31).phase4-coordinator/internal/tier2/catalog.goparses + verifies signed catalogs (in-memory shapeParsedCatalogpercatalog.go:45).- Production observation mode is LIVE. As of 2026-06-24 Pearl
journald shows 342+
model_hash_verifiedevents over the last 7 days for air5 against catalogmacprovider-tier2-model-catalog-2026-05-31, alldecision:"allow", reason:"hash_match".
The missing piece v0.3 closes: the receipt tuple does not include
model_hash, so a buyer-side verifier cannot use any of the
above. v0.3 extends the tuple, the verify CLI, and the /poolz
surface to make catalog-based hash verification a buyer-driven
choice.
Relationship to RequireHashVerified (Entry 80). v0.3 is
ORTHOGONAL to coordinator-side hash enforcement. The
Tier2Config.RequireHashVerified flag
(phase4-coordinator/internal/config/config.go:142,335) remains
at its false default per the
beta/DECISION_CRITERIA.md 2026-06-22 Entry 80 ruling. v0.3
receipts BIND the hash from any provider that opts into SPEC-011
hash reporting (whether or not the coordinator enforces); the
buyer decides whether to demand catalog-match. AC-40 pins this
orthogonality.
A v0.3 receipt is a JCS-canonicalized JSON object with EXACTLY the
following NINE fields and no others. The table rows are presented
in JCS canonical order — UTF-16 code-unit lexicographic per RFC
8785 §3.2.3 — so this is the literal byte order
RFC8785JCS.swift will emit:
| Field | Type | Definition |
|---|---|---|
model_hash |
string (64 lowercase hex) OR JSON null | The SHA-256 of the MLX container the provider had loaded at receipt-generation time, sourced from the SPEC-011 v0.5 R-3.3.1 heartbeat state. MUST be a raw 64-char lowercase hex string with no sha256: prefix (matching SPEC-008 §5.3-5.6 wire form and SPEC-011 R-3.3.1). MUST be the JSON literal null if and only if the provider is running with --enable-warm-swap=false per SPEC-011 R-3.3.0 (and therefore has no heartbeat-reported hash to bind). See §M.2 for provenance rules. MUST NOT be the empty string; MUST NOT be absent. |
model_id |
string | Unchanged from v0.1.3 §3.1 — ASCII-only, case-insensitive matching for routing, verbatim-stored in the tuple. |
output_hash |
string (64 lowercase hex) | Unchanged from v0.1.3 §3.1. |
prompt_hash |
string (64 lowercase hex) | Unchanged from v0.1.3 §3.1. |
provider_pubkey |
string (44 char base64) | Unchanged from v0.1.3 §3.1. |
receipt_version |
string | Wire-shape discriminant. MUST be exactly the ASCII string "3" in v0.3 receipts (NOT the integer 3, NOT "v3", NOT "0.3"). The string-typed choice avoids the JSON-number-vs-int canonicalization edge cases the v0.1.3 §3.1 typing notes already raised. v0.4+ MAY bump this to "4"; v0.3 verifiers MUST treat unknown receipt_version values as inconclusive: unknown_receipt_version per §M.1.4. |
tokens_out |
int64 | Unchanged from v0.1.3 §3.1. |
ttft_ms |
int64 | Unchanged from v0.1.3 §3.1. |
unix_ts |
int64 | Unchanged from v0.1.3 §3.1. |
Field omissions and extras. A v0.3 receipt MUST contain EXACTLY
these nine keys. Verifiers MUST reject v0.3 receipts (i.e. those
with receipt_version: "3") with missing or extra keys as invalid
with reason: "extra_field" or reason: "missing_field" and
details.field populated. There are no optional fields in v0.3.
The null-valued model_hash is NOT a "missing" field — it is
present with the JSON null literal per the §M.2.3 normative rule.
Types. model_hash is string | null. receipt_version,
model_id, prompt_hash, output_hash, and provider_pubkey
are JSON strings. ttft_ms, tokens_out, and unix_ts are JSON
integers per the v0.1.3 §3.1 numeric rules (no decimal point, no
exponent).
JCS extension status. No RFC8785JCS.swift amendments are
required for v0.3 beyond emitting two additional keys through the
existing sorted-emit path. See §M.1.5 for the proof: the new
fields are ASCII-only (so NFC is a no-op) and the null literal
encoding is RFC 8785 §3.2.2.2-trivial.
A v0.3 verifier given a receipt with NO receipt_version field
MUST:
- Treat the receipt as
receipt_version: "1"(the implicit v0.1 tuple shape — the same shape v0.2 inherited unchanged). - Run the v0.1.3 §3.1 7-field validation: exactly the seven
keys
model_id,prompt_hash,output_hash,provider_pubkey,ttft_ms,tokens_out,unix_ts— no missing, no extra. - Canonicalize per v0.1.3 §3.2 (7-field JCS) and check the signature against the resolved pubkey per §10.2.
- Report
valid/invalid/inconclusiveper the §10.1 tri-state, exactly as a v0.1 / v0.2 verifier would. - Skip the catalog check entirely. The v0.1 / v0.2 receipt
carries no
model_hashto compare against. If--catalog- family arguments WERE supplied, the JSON output'swarnings[]MUST include a single entry of kindcatalog_skipped_legacy_receiptnaming the receipt'sprovider_pubkeyand stating that the receipt predates v0.3 wire shape.
The valid result for a v0.1 / v0.2 receipt under a v0.3
verifier carries the v0.1 / v0.2 §10.6 trust boundary, NOT the
v0.3 §M.3.3 trust boundary. The verifier's --explain output
MUST disclose that the legacy receipt cannot attest model-hash
binding even when a catalog was supplied to the verifier.
A v0.1 or v0.2 verifier (the locked v0.1.3 / v0.2.4 releases)
given a v0.3 receipt MUST report invalid. The failure path:
- The receipt has 9 keys, two of which (
model_hashandreceipt_version) are unrecognized. - The v0.1 / v0.2 §3.1 "MUST contain EXACTLY these seven keys"
rule rejects the receipt as
invalidwithreason: "extra_field"(or implementation-equivalent) BEFORE the signature check. The signature would have failed anyway — the signed canonical bytes differ — but the field- shape check fires first.
v0.3 does NOT amend v0.1 / v0.2 verifier behavior retroactively;
those releases are locked. The operational consequence: buyers
holding v0.1 / v0.2 verifier releases will see v0.3 receipts as
invalid. This is unavoidable for the v0.1 / v0.2 lock state.
v0.4+ SHOULD adopt the §M.1.4 unknown-version path so a future
v0.4 receipt against a v0.3 verifier reports inconclusive: unknown_receipt_version rather than invalid. Buyers MUST
coordinate verifier upgrades with provider upgrades during the
v0.2 → v0.3 transition; release notes accompanying the v0.3
implementation MUST call this out.
Per §M.0 (nine fields exactly, signature checked per §10 against
the resolved pubkey), catalog checked per §M.3 if both
--catalog-family arguments AND a non-null model_hash are
present.
If a v0.3 verifier reads a receipt whose receipt_version field
is PRESENT and NOT equal to "3" (and not equal to any other
v0.x value the verifier was specifically built to handle), the
verifier MUST:
- Report
inconclusivewithreason: "unknown_receipt_version"and exit code2. - NOT attempt to canonicalize or signature-check the unknown version.
- Include the unknown version string under
details.receipt_versionin the JSON output. - NOT use field count as a fallback heuristic for version
detection — field count is a
valid/invaliddiscriminant for a known version, not a version detector.
This locks v0.4+ as a forward-compat path: a v0.4 receipt
against a v0.3 verifier reports inconclusive (NOT invalid),
matching the §10.1 inconclusive tri-state intent.
The v0.1.3 §3.2 JCS profile (UTF-16 key order, RFC 8785 string escape, NFC normalization on natural-language strings, RFC 8785 number handling) handles the v0.3 9-field tuple WITHOUT amendment. Per §M.0:
model_hashis either ASCII (64 hex chars) or the JSON null literal. ASCII → NFC is a no-op. JSON null → RFC 8785 §3.2.2.2 fixes the encoding as the literal four bytesnull.receipt_versionis the ASCII string"3"(or future ASCII strings). NFC no-op.- The two new keys (
model_hash,receipt_version) sort cleanly into the existing UTF-16 key order — see §M.0 for the emitted order. The implementation's sorted-emit path picks up the new keys with no code change beyond constructing the tuple to include them.
The §3.2 float-handling extension (step 4) was added in v0.1.3
for the §4 prompt canonical object (temperature, top_p,
presence_penalty, frequency_penalty). Neither v0.1/v0.2 nor
v0.3 tuple-level encoding exercises floats; that extension is
still triggered only by the prompt canonical hash path.
The §3.3 signature step (ed25519_sign(provider_receipt_private_key, UTF-8(JCS(T)))) and the §3.4 wire envelope (<base64(JCS(T))>. <base64(SIG)>) are UNCHANGED for v0.3 — only T's shape grows.
The provider's model_hash value at receipt-generation time MUST
be sourced from the provider's local SPEC-011 R-3.3.1
hash-tracking state — the same value the provider reports on
heartbeats per SPEC-011 §3.3. "Most recent heartbeat" is
ambiguous in three edge cases; this section pins each.
If a SPEC-011 §3.2 warm-swap completed at T−100 ms but no heartbeat has yet been emitted (next heartbeat scheduled at T+200 ms), the receipt MUST commit to the POST-SWAP hash — the SHA-256 of the in-memory container at the moment inference ran, NOT the pre-swap hash the coordinator last received on heartbeats.
Rationale: the buyer consumed the post-swap weights; the
receipt binds to what served the buyer, not to what the
coordinator most-recently knew. The implementation reads
model_hash from the provider-local SPEC-011 R-3.3.1
state-tracking variable, which transitions to the new hash at
SPEC-011 §3.2 ready re-entry — i.e. at the moment of atomic
swap, not at next-heartbeat emission.
Coordinator/provider transient disagreement is OK. Across
the heartbeat window the coordinator-side hash and the
provider's receipt-bound hash MAY transiently disagree. That
disagreement appears as a hash_status churn on the next
heartbeat (the coordinator's R-3.3.5 SPEC-011 path re-verifies
and Provider.HashStatus reflects the post-swap value). That
churn is a SPEC-011 coordinator-side audit condition, NOT a
SPEC-015 verifier-side failure. The verifier's trust root for
catalog-check is the catalog itself, NOT the coordinator's
heartbeat-derived state.
A SPEC-011 §3.2 warm-swap MUST NOT span a single receipt-bound response. v0.3 expresses this rule via the SPEC-011 §3.4 drain semantics, which already make it enforceable by construction:
- A request that BEGAN on the old container and FINISHED before the SPEC-011 R-3.4.2 drain timeout: the runtime knows the inference ran entirely on the old container (R-3.4.1 in-flight-set tracking + R-3.2.2 snapshot semantics). The receipt-emission code path MUST emit a receipt with the hash at request START — the hash that served the response, even if the global provider state has moved to a new hash mid-response. This is the §M.2.2 normative shape.
- A request that BEGAN on the old container and was R-3.4.2 drain-timed-out: the response is HTTP 503 per SPEC-011 R-3.4.2; v0.1.3 §12 / §6.4 already specifies no receipt for non-200 responses. The drain timeout itself is the audit trail.
- A request that ARRIVED during
loadingordraining: rejected with HTTP 503 per SPEC-011 R-3.4.4; no receipt. - A request that BEGAN on the NEW container after the swap completed: receipt-emission emits the NEW hash. Normal §M.2.1 path.
The construction is therefore: every receipt commits to the hash of the model that started generation for this request, and v0.3 forbids any other shape.
Defence-in-depth refusal. If the runtime detects a
swap-in-progress state at receipt-emission time AND cannot
disambiguate which container served the response (this is
not reachable under SPEC-011 R-3.4.1 / R-3.2.2 by construction;
this clause exists for future implementation regressions), the
provider MUST refuse to emit a receipt and MUST log a
receipt_omitted audit event with reason: "model_swap_violation". The response itself MAY still complete
normally (the buyer gets their tokens; HTTP 200) but carries
no X-MacProvider-Receipt header. The §6.4 receipt-omission
rules already accept this outcome.
Deferred to v0.4+: representing a multi-hash response in a
single receipt — i.e. a receipt that binds two model_hash
values for one response, one pre-swap and one post-swap. v0.3
§M.2.2 NORMATIVELY REFUSES the shape; v0.4 may design it,
particularly in the streaming-receipts context where a swap
genuinely spans a long-running response. See §15 Q5 (streaming
delivery) and the new §15 Q7 (multi-hash receipt shape).
If the provider is running with --enable-warm-swap=false (the
SPEC-011 R-3.1.0 default, and the production default per Entry
80), the heartbeat omits model_hash per SPEC-011 R-3.3.0.
The provider has no SPEC-011-sourced hash to bind into the
receipt. In this mode the provider MUST emit model_hash: null
in the v0.3 receipt tuple. Not the empty string. Not absence.
The JSON literal null, which JCS encodes as the four bytes
null.
A v0.3 verifier reading model_hash: null MUST:
- Run the standard v0.3 verification path: §3.2 canonicalization
over the 9-field tuple (including the literal
null), signature check, prompt/output hash recomputation, exit code per §10.1 tri-state. - Skip the catalog check entirely — there is no hash to
compare. The verifier MUST NOT report
invalidsolely becausemodel_hashis null. - If
--catalog-family arguments WERE supplied, include inwarnings[]an entry of kindcatalog_skipped_null_hash(NOTcatalog_skipped_legacy_receipt— the receipt IS v0.3, the hash is opted-out via the warm-swap-disabled config). - Report
validif signature + canonicalization + prompt/ output hash checks pass.
Trust statement (NORMATIVE). A v0.3 valid result for a
null-hash receipt carries the v0.1 / v0.2 §10.6 trust boundary
PLUS the explicit attestation: "the holder of the provider's
private key signed a tuple in which model_hash was the JSON
literal null." The provider is committed to the null — a
provider cannot later claim "the receipt is null because I
couldn't get a hash"; the receipt SIGNED the null. v0.3
exposes provider hash-attestation participation as a per-receipt
attestable property.
Design rationale (the most contentious §M choice). The
choice of "inconclusive-for-hash + valid-for-signature" rather
than "invalid because the provider didn't participate" is
deliberate. v0.3 ships against a production pool that runs
default --enable-warm-swap=false per Entry 80. Reporting every
receipt from that pool as invalid would break existing buyer
tooling on the day v0.3 ships; the softer rule preserves
"signature attestation works" while letting the catalog-check
side remain an opt-in trust upgrade. Operators who want to
demand hash attestation MAY ship a deployment-specific verifier
wrapper that filters model_hash: null results to "reject" —
this is policy, not protocol. AC-32 pins the protocol behavior;
the policy layer is out of scope.
When the v0.3 receipt has a non-null model_hash AND the
verifier is invoked with catalog arguments, the verifier MUST
compare receipt.model_hash against a signed catalog's expected
hash for receipt.model_id. The catalog format is the output
of scripts/sign-catalog.go, parsed and verified consistent
with phase4-coordinator/internal/tier2/catalog.go. The
verifier MUST re-implement parse + verify in pure Go in
phase7-verify/ rather than import the coordinator package,
maintaining the v0.2 pure-Go discipline.
| Flag | Type | Required? | Purpose |
|---|---|---|---|
--catalog <path> |
string | optional | Path to a local signed catalog file (the output of scripts/sign-catalog.go). Mutually exclusive with --catalog-url. |
--catalog-url <url> |
string | optional | URL to fetch the signed catalog. Suggested target: the SPEC-002 v1.6 candidate GET /catalog/<catalog_id> endpoint per §M.4. Mutually exclusive with --catalog. |
--catalog-pubkey <base64url> |
string | optional | base64.RawURLEncoding (base64url-unpadded, NOT standard padded base64) of the ed25519 catalog-signing pubkey. Exactly 43 ASCII characters. Matches scripts/sign-catalog.go:90,316-328 and SPEC-008 §5.2.1 wire form for ed25519 pubkeys. Mutually exclusive with --catalog-pubkey-url. |
--catalog-pubkey-url <url> |
string | optional | URL to fetch the catalog signing pubkey. Suggested target: the SPEC-002 v1.6 candidate GET /catalog/pubkey endpoint per §M.4. Mutually exclusive with --catalog-pubkey. |
Flag-combination rules (NORMATIVE; extends §10.4.4).
- If NONE of the four catalog flags is supplied: catalog check
is skipped entirely. A v0.3
validresult carries the v0.1 / v0.2 §10.6 trust boundary, NOT the §M.3.3 boundary. The JSON output MUST setmodel_hash_verified: null(NOT absent) to explicitly signal the catalog check did not run. - If
--catalog-family flag is supplied without--catalog-pubkey-family flag (or vice versa): exit64(usage error) per §10.4.3. The catalog and the catalog pubkey are mutually required — a catalog without a pubkey cannot be verified, and a pubkey without a catalog has nothing to verify. - If both
--catalogand--catalog-urlare supplied (or both--catalog-pubkeyand--catalog-pubkey-url): exit64. - If
--catalog-urlor--catalog-pubkey-urlis used with--offline(§10.5): exit64(incompatible flags). The buyer is asserting offline AND requesting a network fetch. --catalog-urlwith no network egress (a transient network failure under §10.5's 5-second total network budget): reportinconclusivewithreason: "catalog_unreachable". The verifier MUST NOT silently fall back to "skip catalog check and report valid" when the buyer explicitly asked for a catalog check. This is the same posture §10.2 takes for/v1/receipt-keys/<provider_id>unreachability.
This sub-matrix extends the v0.2.4 §10.4.4 flag matrix with the
v0.3 catalog flags. Rows = v0.3 catalog flags; columns = v0.2 +
v0.3 flags they interact with. Cells name the exit code or
behaviour. "OK" means the combination is legal and the verifier
proceeds per §M.3.2. "64" means exit 64 (usage error per
§10.4.3).
--offline |
--coordinator H |
--pubkey (receipt) |
--json |
--quiet |
--explain |
--provider-id |
|
|---|---|---|---|---|---|---|---|
--catalog <path> |
OK (no network) | OK (independent) | OK | OK | OK | OK (catalog shown) | OK |
--catalog-url <url> |
64 (incompatible: offline-vs-fetch) | OK (independent host from H) |
OK | OK | OK | OK (catalog shown) | OK |
--catalog-pubkey <b64url> |
OK | OK | OK | OK | OK | OK (key id shown) | OK |
--catalog-pubkey-url <url> |
64 (incompatible: offline-vs-fetch) | OK (independent host from H) |
OK | OK | OK | OK (key id shown) | OK |
--catalog + --catalog-url |
n/a | n/a | n/a | n/a | n/a | n/a | n/a |
--catalog-pubkey + --catalog-pubkey-url |
n/a | n/a | n/a | n/a | n/a | n/a | n/a |
| catalog flag without matching pubkey flag (or vice versa) | n/a | n/a | n/a | n/a | n/a | n/a | n/a |
--require-model-hash (§M.3.1.2) |
OK | OK | OK | OK | OK | OK (policy disclosed) | OK |
Cross-row rules.
- Catalog flags compose freely with v0.2 input-mode flags
(
--bundle,--receipt+--prompt-hash+--output-hash, stdin) — the catalog check runs orthogonal to receipt resolution. --coordinator Hand--catalog-url Uare INDEPENDENT hosts.Hresolves/v1/receipt-keys/<provider_id>for the pubkey;Uresolves the catalog. They MAY be the same host (Pearl-style single-coordinator deployment) or different (e.g.H = test-coord+U = production-catalog).- Explicit
--pubkey <b64>(the v0.2 receipt-signing-pubkey pin) and--catalog-pubkey <b64url>(the v0.3 catalog- signing pubkey pin) are independent and can be combined. They sign different things (receipt vs. catalog). --jsonoutput ALWAYS includes the v0.3 fieldsmodel_hash_verifiedand any §M-nameddetails/warnings[]entries per §M.3.2.1, regardless of whether catalog flags were supplied (the field isnullwhen catalog check did not run).--quietsuppresses stderr emission but does NOT suppress anywarnings[]entries from the JSON output (same posture v0.2 §10.4.4 takes for divergence warnings).--explainMUST disclose which catalog (catalog_id,catalog_url-or---catalogsource path,expires_at) contributed to a v0.3validverdict per §M.3.3.
To give buyers a first-class fail-closed knob on the §M.2.3 null-hash path without changing default §10.1 tri-state semantics, v0.3 introduces ONE optional CLI flag:
| Flag | Type | Default | Purpose |
|---|---|---|---|
--require-model-hash |
boolean (presence) | off | When SET, a v0.3 receipt with model_hash: null causes invalid with reason: "model_hash_required" regardless of signature outcome. When NOT SET (default), null-hash receipts verify per §M.2.3 + AC-32 (valid + catalog_skipped_null_hash warning when catalog flags supplied). |
Flag-interaction rules:
--require-model-hashcomposes with the v0.3 catalog flags per §M.3.1.1 (last matrix row). It is OPTIONAL — a v0.3 verifier MAY omit the flag's implementation entirely if the release target is "buyer-default tooling"; the §M.5 AC-32a test is the gate.--require-model-hashapplied to a v0.1/v0.2 LEGACY receipt (noreceipt_versionfield) MUST reportinvalidwithreason: "model_hash_required"— the legacy receipt has no hash to attest, and the buyer asked to fail closed.--require-model-hashapplied to a v0.3 receipt with a NON-NULLmodel_hashis a no-op on result (the catalog check proceeds normally per §M.3.2; result is determined by that check).--require-model-hashwithout--catalog-family flags is LEGAL — the buyer is asserting "I demand the provider participates in hash attestation, but I'll trust the provider's self-reported hash without catalog cross-check." Result isvalid(if signature checks) orinvalid(if signature fails OR null hash);model_hash_verifiedisnull(no catalog ran). This is the minimal fail-closed posture.
Trust statement. A v0.3 valid result with
--require-model-hash set carries the §M.2.3 trust statement
("the holder of the provider's private key signed a tuple in
which model_hash was non-null") PLUS the buyer's policy
attestation that they demanded participation. A v0.3 invalid
result with reason: "model_hash_required" is the buyer's
explicit reject of a provider that opted out of hash
attestation — this is policy, not protocol.
For a v0.3 receipt with non-null model_hash AND catalog
arguments supplied, the verifier MUST execute these steps in
order. Any failed step short-circuits with the named result:
- Resolve catalog bytes per
--catalogor--catalog-url(5-second total network budget per §10.5; fetch failure →inconclusivewithreason: "catalog_unreachable"). - Resolve catalog pubkey per
--catalog-pubkeyor--catalog-pubkey-url(same 5-second budget shared with step 1). - Parse the catalog as the
phase4-coordinator/internal/tier2catalogFileschema: top-level fieldscatalog_id(string),expires_at(RFC3339 UTC string),issued_at(RFC3339 UTC string),models[](array of{artifact_kind, hash_scope, model_id, min_ram_gb?, notes?, sha256, source}),signature{alg, key_id, sig},version(int). If present,min_ram_gbis a positive integer RAM floor for installer/provider UX and is covered by the catalog signature; it is NOT used for model-hash equality. Reject asinvalidwithreason: "catalog_format_invalid"on any schema mismatch (missing required field, wrong type, malformed RFC3339,sha256field not matching[0-9a-f]{64}perphase4-coordinator/internal/tier2/catalog.go:22). - Verify catalog signature. Reconstruct the canonical body
(the
catalogFileminus thesignaturefield, in the exact key orderscripts/sign-catalog.go:42-49produces:catalog_id,expires_at,issued_at,models,version) and verifyed25519_verify(catalog_pubkey, canonical_body_bytes, base64_decode(signature.sig)). The verifier MUST decodesignature.sigasbase64.RawURLEncoding(base64url-unpadded) to matchscripts/sign-catalog.go:145. Thesignature.algfield MUST be the ASCII string"Ed25519"(capital E, matching the existing emitter atscripts/sign-catalog.go:142-145and the existing coordinator validator atphase4-coordinator/internal/tier2/catalog.go:470) OR the verifier reportsinvalidwithreason: "catalog_signature_invalid". The catalog pubkey itself MUST be decoded viabase64.RawURLEncodingfrom the--catalog-pubkey/--catalog-pubkey-urlsource — exactly 43 ASCII characters decoded to 32 bytes per RFC 8032. Thesignature.key_idfield is informational (the verifier uses the resolved--catalog-pubkey/--catalog-pubkey-urlbytes, NOT the embeddedkey_id, for verification —key_idis a fingerprint for operator-side rotation tracking). Ifed25519_verifyreturns false:invalidwithreason: "catalog_signature_invalid". - Check
expires_atagainst the verifier's wall clock. Ifnow() > expires_at + 60s(60s grace for clock skew, matching §10.2.1 grace-window precedent): reportinconclusivewithreason: "catalog_expired"and emit awarnings[]entry with the catalog'scatalog_idandexpires_atpopulated. v0.3 does NOT allowvalidagainst an expired catalog — catalog expiry is the operator's signal to rotate; a verifier that ignored it would defeat the rotation mechanism. - Find the catalog entry whose
model_idequalsreceipt.model_idAFTER applying the canonicalcatalogModelKeytransform:strings.ToLower(strings.TrimSpace(modelID))perphase4-coordinator/internal/tier2/catalog.go:559-560. The buyer-side verifier MUST mirror the coordinator-side match function exactly. v0.3 catalog lookup is case-FOLDED (lowercase) and whitespace-trimmed; this matches both SPEC-001 v1.5 §6.4's ASCII case-insensitivity rule for model IDs and the existing SPEC-008 §5.6 routing predicate. Diverging from the coordinator's match function would let the coordinator accept a model the verifier rejects (or vice versa) on case differences alone, which is the audit- round-1 A1 finding the v0.3.1 fix pass closed. If no entry matches after the canonical transform: reportinconclusivewithreason: "model_id_not_in_catalog",details.model_id: <receipt.model_id verbatim, no transform>(the buyer needs to see what was in the receipt, not the lookup key). The verifier MUST NOT reportvalidfor a model the operator has not published a hash for. - Compare hashes. Compare
receipt.model_hashto the entry'ssha256field (the catalog schema names thissha256perscripts/sign-catalog.go:31, NOTmodel_hash— do NOT invent a different field name). Comparison is case-sensitive (both sides are required to be lowercase hex by their respective specs; case mismatch is a schema bug, not an attack vector). If equal: continue to step 8. If mismatched: reportinvalidwithreason: "model_hash_mismatch",details.field: "model_hash",details.expected: <catalog sha256>,details.actual: <receipt model_hash>. This isinvalidregardless of signature outcome — a signature-valid receipt that names a wrong-hash model is a model attestation failure, and v0.3 §M.3.3 makes thisinvalidrather thaninconclusivebecause the buyer's explicit catalog choice asserts "this is the hash I expect." - Emit
model_hash_verified: truein the JSON output and continue to the normal §10 result determination. The receipt reportsvalidiff signature, prompt/output hashes, AND catalog check all pass.
If the receipt has model_hash: null AND catalog arguments are
supplied, the verifier MUST skip steps 1-8 and apply §M.2.3
instead (catalog_skipped_null_hash warning, normal §10 result
determination, no hash check, model_hash_verified: null in
output).
v0.3 extends the v0.2.4 §10.4.2 JSON output schema. The v0.2.4
"details only on invalid" rule is SUPERSEDED for the
v0.3-named inconclusive cases below; otherwise §10.4.2
remains authoritative.
New REQUIRED top-level field (every v0.3 verifier output):
| Field | Type | Required? | Disposition |
|---|---|---|---|
model_hash_verified |
bool OR JSON null | REQUIRED (always present) | Tri-state: true ⇔ catalog check ran AND hash equality held (§M.3.2 step 8); false ⇔ catalog check ran AND mismatched (§M.3.2 step 7 mismatch path; result is invalid with reason: "model_hash_mismatch") OR --require-model-hash set with null hash (reason: "model_hash_required"); null ⇔ catalog check did NOT run for any reason (no catalog flags supplied; null model_hash without --require-model-hash; legacy v0.1/v0.2 receipt; unknown receipt_version; catalog fetch / signature / expiry failure that short-circuits before step 7). |
The field MUST be present in every JSON output, including
valid, invalid, inconclusive results. Absence = schema
violation. Distinguishes false (catalog ran, mismatched —
the cryptographic case) from null (catalog did not run — the
operational case).
Extended reason enum (v0.3 ADDS these values):
reason |
Result | Source |
|---|---|---|
model_hash_mismatch |
invalid | §M.3.2 step 7 |
model_hash_required |
invalid | §M.3.1.2 + AC-32a |
model_id_not_in_catalog |
inconclusive | §M.3.2 step 6 |
catalog_signature_invalid |
invalid | §M.3.2 step 4 |
catalog_unreachable |
inconclusive | §M.3.2 step 1/2 + §M.4 404/429/5xx |
catalog_expired |
inconclusive | §M.3.2 step 5 |
catalog_format_invalid |
invalid | §M.3.2 step 3 |
unknown_receipt_version |
inconclusive | §M.1.4 |
extra_field / missing_field |
invalid | §M.0 strict 9-key rule for receipt_version: "3" receipts |
The v0.2.4 reason enum values (signature, prompt_hash,
output_hash, provider_pubkey, pubkey_not_endorsed,
previous_key_outside_grace_window, fetch, offline_flag,
network_unreachable, provider_id_unresolvable,
provider_id_not_in_pool, etc.) remain valid and unchanged.
Extended details disposition for v0.3-named inconclusive
cases. §10.4.2's "details optional for valid / required for
invalid" rule is extended:
reason |
details requirement |
Required keys |
|---|---|---|
unknown_receipt_version (inconclusive) |
REQUIRED | details.receipt_version (the unrecognized string value, verbatim) |
model_id_not_in_catalog (inconclusive) |
REQUIRED | details.model_id (the receipt's model_id verbatim, no case transform) |
catalog_expired (inconclusive) |
REQUIRED | details.catalog_id, details.expires_at (RFC3339 UTC) |
catalog_unreachable (inconclusive) |
OPTIONAL | details.url if --catalog-url was set; absent otherwise |
model_hash_mismatch (invalid) |
REQUIRED | details.field: "model_hash", details.expected: <catalog sha256, 64 hex>, details.actual: <receipt model_hash, 64 hex> |
model_hash_required (invalid) |
REQUIRED | details.field: "model_hash", details.policy_flag: "require-model-hash" |
catalog_signature_invalid (invalid) |
REQUIRED | details.field: "signature", details.alg: <observed alg string> (e.g. "ed25519" lowercase to differentiate from the required "Ed25519") |
catalog_format_invalid (invalid) |
REQUIRED | details.field: <name of failing field>, details.cause: <human-readable description> |
extra_field / missing_field for v0.3 receipts (invalid) |
REQUIRED | details.field: <name of the offending key> |
Extended warnings[] kinds:
catalog_skipped_null_hash— v0.3 receipt withmodel_hash: nullAND catalog flags supplied (§M.2.3, AC-32).catalog_skipped_legacy_receipt— v0.1/v0.2 receipt (noreceipt_version) AND catalog flags supplied (§M.1.1, AC-37).
The v0.2.4 warnings[] kinds (live_check_skipped,
divergence, non-default coordinator) remain valid and
unchanged.
Schema versioning. v0.3 verifier output JSON schema is a strict superset of the v0.2.4 schema for the unchanged fields. The release artifact MUST include an updated JSON-Schema document (per v0.2.4 AC-24) reflecting the v0.3 additions above; the schema document MUST be addressable from the release so independent buyer-side automation can validate v0.3 output without re-deriving the schema from this spec.
A v0.3 valid result with non-null model_hash AND catalog
arguments supplied means: a holder of the provider's private key
signed a canonical tuple containing the values (model_hash,
model_id, prompt_hash, output_hash, provider_pubkey,
receipt_version: "3", ttft_ms, tokens_out, unix_ts); the
pubkey that signature checks against is the one the coordinator
publishes for the resolved provider_id at verification time
(or in the §7.5.2 rotation grace window per §10.2.1); AND the
catalog the buyer trusted (signature-valid against the supplied
catalog pubkey, non-expired within the 60s skew grace) endorses
that receipt.model_hash is the expected SHA-256 of the loaded
weights for receipt.model_id.
In other words, v0.3 valid (with catalog) closes the §10.6
v0.2 "DOES NOT prove that the response was generated by the
model named in model_id" bullet, subject to the buyer trusting
the catalog signing pubkey — which is the new trust root the
v0.3 verifier requires, and which §15 Q1 (TUF-style root) and
the §M.4 /poolz catalog_pubkey_url surface inherit.
The remaining §10.6 DOES-NOT-PROVE list is PRESERVED unchanged:
v0.3 valid still does not prove timestamp honesty, no-other-
observer, pubkey-trust-root incorruptibility, replay-resistance,
or uniqueness.
Disclaimers that v0.3 specifically inherits. The §M.4
catalog-pubkey trust root is operator-mutable in the same way
§8 / §10.6 /poolz is operator-mutable: a malicious operator
can swap both the catalog AND the catalog pubkey AND a v0.3
verifier with --catalog-pubkey-url (rather than a pinned
--catalog-pubkey) will report valid. v0.3 §M.3.3 strengthens
the receipt against PROVIDER substitution attacks (a provider
loading a different SHA-256 than the operator published) — it
does NOT strengthen the receipt against OPERATOR-level attacks
on the catalog trust root. §15 Q1 (TUF / on-chain anchor) is
where that strengthening lands; v0.3 is honest that it is not
that work.
A v0.3 valid result with NULL model_hash, OR with no
catalog arguments, retains the v0.1 / v0.2 §10.6 trust boundary
unchanged — model-hash attestation is not made.
A --catalog-url resolution MUST cache the fetched catalog
bytes AND the resolved catalog pubkey keyed by
(catalog_url, catalog_pubkey_url_or_explicit_marker). Cache
TTL:
Let R = expires_at - now() at cache-write time, expressed in
seconds. The three TTL bands use explicit interval notation
(half-open intervals; integer-second resolution; boundary at
6h falls into the upper band):
R ∈ (6h, +∞)(i.e.R > 21600s): cache for exactly21600s(6 hours).R ∈ [60s, 6h](i.e.60 ≤ R ≤ 21600): cache forR - 60sseconds (so the cache expires 60 seconds before the catalog itself, matching the §M.3.2 step 5 skew grace).R ∈ (-∞, 60s)(i.e.R < 60s, including R ≤ 0 — a catalog accepted only by the §M.3.2 step 5 60s skew grace, OR a catalog with expires_at already in the past at fetch time): do NOT cache. The next verification SHOULD re-fetch. A catalog accepted only by skew grace is NEVER cached so that the next verification re-checks expiry against a fresh wall-clock reading.
Cache location: the same ~/.macprovider/verify/ directory as
the §10.2 pubkey cache, in a sibling subdirectory
catalogs/<sha256-of-catalog-url>.json. Cache entries MUST
include {catalog_bytes, catalog_pubkey_b64, fetched_at, expires_at, catalog_url} so a later verification can detect
either a --catalog-pubkey-url rotation (cached pubkey
differs from freshly-resolved pubkey → cache miss) or a manual
pubkey override on the CLI (cached pubkey differs from
--catalog-pubkey → cache miss).
A stale cache entry (older than its computed TTL) MUST NOT
produce valid. The verifier MUST attempt a fresh fetch; on
fetch failure with a stale cache, report inconclusive with
reason: "catalog_unreachable" — mirroring §10.2's stale-cache
rule for the pubkey cache.
The coordinator's /poolz response gains three OPTIONAL
top-level fields (NOT per-provider-row fields — these are
catalog-level). The three fields are present iff ALL of the
following hold (the "effectively active catalog" condition):
Tier2Config.CatalogPathis set (non-empty perphase4-coordinator/internal/config/config.go:142),- The configured catalog file loaded cleanly (file present,
well-formed JSON, schema-valid per
catalogFileinphase4-coordinator/internal/tier2/catalog.go:64), - The catalog's signature verified against
Tier2Config.CatalogPublicKey(equivalent totier2.Default().Active() == true).
If ANY of (1)/(2)/(3) fails, the three fields MUST be ABSENT
from the /poolz response (NOT present-with-null, NOT
present-with-empty-string). This single rule governs §M.4
field presence AND the §M.5 AC-39 acceptance test AND the
two /catalog/... endpoint 404 cases below.
Response shape (additive — extends the SPEC-002 v1.4 §FR-O2
locked shape, which uses top-level keys pool and summary).
{
"pool": [...], // unchanged — SPEC-002 v1.4 §FR-O2
"summary": {...}, // unchanged — SPEC-002 v1.4 §FR-O2
"catalog_id": "macprovider-tier2-model-catalog-2026-05-31",
"catalog_url": "https://coordinator.malibu.tech/catalog/macprovider-tier2-model-catalog-2026-05-31",
"catalog_pubkey_url": "https://coordinator.malibu.tech/catalog/pubkey"
}catalog_id(string): thecatalog_idfrom the loaded signed catalog (matchesParsedCatalog.CatalogIDperphase4-coordinator/internal/tier2/catalog.go:45).catalog_url(string): URL where the same catalog file can be fetched. SPEC-002 v1.6 candidate addsGET /catalog/ <catalog_id>returning the signed catalog bytes verbatim withContent-Type: application/json. No authentication (public) — same trust posture asGET /v1/receipt-keys/ <provider_id>in §10.7.catalog_pubkey_url(string): URL where the catalog signing pubkey can be fetched. SPEC-002 v1.6 candidate addsGET /catalog/pubkeyreturning a JSON object{"pubkey": "<43-char base64url-unpadded ed25519 pubkey>", "alg": "Ed25519"}per the detailed endpoint block below — matchingscripts/sign-catalog.go:90,142-145emitters,phase4-coordinator/internal/tier2/catalog.go:470,479-485validators, and SPEC-008 §5.2.1's locked ed25519 wire form. No authentication (public).
Parsers that don't recognize the fields ignore them per the SPEC-002 v1.4 candidate-annotation pattern. Field absence is covered by the "effectively active catalog" condition above.
GET /catalog/<catalog_id> endpoint (SPEC-002 v1.6 candidate).
- Authentication: None (public).
- Rate limiting: Operator-configurable; recommended floor
10 req/sec per source IP, mirroring §10.7's
/v1/receipt-keysposture. Source-IP derivation goes throughproxy.trusted_proxies(issue #125; see §10.7 and SPEC-002 v1.4.x). - Cache-Control:
public, max-age=300(5 minutes; same as §10.7). - Response: the literal signed catalog bytes accepted by the
coordinator's catalog signature verification, as produced by
scripts/sign-catalog.go. Implementations MUST NOT reread and serve mutable on-disk catalog bytes that have not passed the active catalog verification state.Content-Type: application/json. - 404: the
<catalog_id>path segment does NOT match the effectively-active catalog'scatalog_id, OR the coordinator has no effectively-active catalog per the §M.4 three-condition rule above. Body is the SPEC-002 §FR-X-N standard JSON error envelope witherror.code = "catalog_not_found". Verifier treats asinconclusive: catalog_unreachable. - 5xx / 429: verifier treats as fetch-failure →
inconclusive: catalog_unreachable; no retry within the same verification invocation.
GET /catalog/current endpoint (SPEC-002 v1.6 candidate).
- Same authentication / rate-limit / cache posture as
GET /catalog/<catalog_id>. - Response: the same verified signed catalog bytes as
GET /catalog/<active catalog_id>, without requiring clients to discover the active ID from operator-only/poolz. - 404: no effectively-active catalog per the §M.4 three-condition
rule. Verifier treats as
inconclusive: catalog_unreachable.
GET /catalog/pubkey endpoint (SPEC-002 v1.6 candidate).
- Same authentication / rate-limit / cache posture as
GET /catalog/<catalog_id>. - Response:
{"pubkey": "<43-char base64url-unpadded>", "alg": "Ed25519"}. Thepubkeyvalue MUST bebase64.RawURLEncoding(base64url-unpadded), exactly 43 ASCII chars decoding to 32 bytes — matching thescripts/sign-catalog.go:90keygen emitter, the SPEC-008 §5.2.1 wire form, and the v0.3--catalog-pubkeyCLI flag per §M.3.1. Thealgvalue MUST be the capital-E ASCII string"Ed25519"matching the existingscripts/sign-catalog.go:142-145catalog-signaturealgfield. - Optional:
key_id(informational fingerprint, identical to thesignature.key_idembedded in catalog files). - 404: no effectively-active catalog per the §M.4 three-
condition rule. Verifier treats as
inconclusive: catalog_unreachable.
Composes with §M.3.1. A verifier can do:
macprovider-verify --bundle X \
--catalog-url https://coordinator.malibu.tech/catalog/macprovider-tier2-model-catalog-2026-05-31 \
--catalog-pubkey-url https://coordinator.malibu.tech/catalog/pubkey
with the verifier resolving both URLs in two fetches (plus the
§10.7 /v1/receipt-keys/<provider_id> resolution for the
provider pubkey — three total fetches per verify when no caches
are warm).
Trust posture (NORMATIVE). §M.4 inherits §8.3's operator-
mutability limit. The operator controls /poolz, the catalog
file, AND the catalog signing key. v0.3 does NOT add TUF /
on-chain anchoring; that's the §15 Q1 work and remains v0.4+.
A buyer who needs stronger trust SHOULD pin
--catalog-pubkey to a value out-of-band (key fingerprint
shared by the operator via a separate channel) rather than
relying on --catalog-pubkey-url, which delegates the pubkey
trust to the same coordinator that serves /poolz.
SPEC-002 v1.6 candidate-annotation status. §M.4 is named
as a SPEC-002 v1.6 candidate annotation following the SPEC-015
v0.1.3 receipt_pubkey (SPEC-002 v1.4 candidate) and SPEC-015
v0.2 /v1/receipt-keys (SPEC-002 v1.5 candidate) precedent.
Implementations MAY add the fields and endpoints before
SPEC-002 v1.6 LOCK provided the shape matches §M.4 / §M.5 ACs.
SPEC-002 v1.6 LOCK is OUT-OF-SCOPE for v0.3 of this SPEC;
SPEC-015 v0.3 is the source of truth for the catalog-surface
shape until that LOCK.
Each AC is independently verifiable from outside this SPEC. Each AC cites a concrete test command an implementer can run.
AC-28 (v0.3 receipt wire shape). A v0.3 provider binary
serving a non-streaming POST /v1/chat/completions with a
fixed model, prompt, and temperature: 0 MUST emit an
X-MacProvider-Receipt header whose tuple decodes to exactly
nine fields in JCS canonical (UTF-16 code-unit lexicographic)
order: model_hash, model_id, output_hash, prompt_hash,
provider_pubkey, receipt_version, tokens_out, ttft_ms,
unix_ts. receipt_version MUST be exactly the ASCII string
"3". Test command:
curl -sD - -X POST http://provider/v1/chat/completions -d '<fixed body>' \
| grep -i '^X-MacProvider-Receipt:' \
| cut -d' ' -f2 | tr -d '\r' | cut -d. -f1 | base64 -d \
| jq -r 'keys | join(",")'Returns exactly model_hash,model_id,output_hash,prompt_hash,provider_pubkey,receipt_version,tokens_out,ttft_ms,unix_ts.
AC-29 (model_hash matches loaded weights — warm-swap-on).
A v0.3 provider running with --enable-warm-swap=true emits a
v0.3 receipt whose model_hash equals the SHA-256 of the loaded
MLX container at the moment of receipt generation.
Test commands (any of the two equivalent observation routes
— v0.3 does NOT require a new introspection CLI):
- Heartbeat route (preferred — uses existing wire surface):
capture the provider's heartbeat-reported
model_hashper SPEC-011 R-3.3.1 from a coordinator-side log/journald scrape (journalctl -u macprovider-coordinator | grep model_hash_verifiedon Pearl, or the equivalent locally); assertreceipt.model_hash == heartbeat.model_hashover a fresh heartbeat window (no swap in flight). This route is observation-only and matches the Pearl-productionmodel_hash_verifiedevents documented in the §M opening paragraph. - MLX container route (alternative — direct binary
introspection): compute SHA-256 over the MLX container the
provider has loaded (same algorithm
phase3-binary/Sources/macprovider-cli/ModelRuntime.swift:294-325uses for the heartbeat report); assert byte-equality withreceipt.model_hash. v0.3 does NOT require a newmodels inspectCLI subcommand to expose this; an implementer MAY add one as an ergonomics extension under a future SPEC-001 revision, but it is NOT a v0.3 prereq.
Implementations choose either route for the AC test. v0.3 §M.5 treats both as equally normative; the divergence in form (coordinator-side vs. binary-side) does NOT change the expected outcome.
AC-30 (model_hash: null when warm-swap disabled). A
v0.3 provider running with default --enable-warm-swap=false
emits a v0.3 receipt whose model_hash is the JSON literal
null, NOT the empty string, NOT absent. Test command:
curl -sD - -X POST http://provider/v1/chat/completions -d '<fixed body>' \
| grep -i '^X-MacProvider-Receipt:' | cut -d' ' -f2 | tr -d '\r' \
| cut -d. -f1 | base64 -d \
| jq '.model_hash == null and (.model_hash | type) == "null" and has("model_hash")'Returns true (three conjuncts: value is null, type is "null",
key is present).
AC-31 (null-usage receipts inherit §M.2 hash). A v0.3
provider emitting a null-usage / error receipt (SPEC-001 §6.0
error path; v0.1.3 AC-12) sets model_hash per §M.2 — i.e. the
same value a successful receipt from the same provider would
carry. The error did not change the loaded weights. Test
command: trigger error_model_not_loaded on a v0.3 provider
running with --enable-warm-swap=true; verify the resulting
receipt's model_hash equals the post-error SPEC-011
heartbeat-reported hash from the same provider over a fresh
heartbeat window.
AC-32 (verifier valid on null-hash v0.3 receipt — default
posture). A v0.3 verifier without --require-model-hash
MUST report valid for a v0.3 receipt with
model_hash: null when signature + canonicalization checks
pass, regardless of whether --catalog-family flags were
supplied. If catalog flags WERE supplied, JSON output MUST
include warnings[] of kind catalog_skipped_null_hash.
Test command: golden fixture in
phase7-verify/testdata/spec015_v03_null_hash/; assert
macprovider-verify --bundle bundle.json --catalog catalog.json --catalog-pubkey <pk> exits 0, result: "valid",
warnings | map(.kind) | contains(["catalog_skipped_null_hash"]).
AC-32a (verifier --require-model-hash fail-closed on null
hash — opt-in buyer policy per §M.3.1.2). A v0.3 verifier
invoked with --require-model-hash reading a v0.3 receipt with
model_hash: null MUST report invalid with reason: "model_hash_required", exit code 1, and JSON output MUST
include model_hash_verified: false AND warnings[] MAY
include catalog_skipped_null_hash (the warning still records
the underlying skip; the policy flag is what flipped the result
to invalid). The signature MAY be valid; the policy flag
demands hash attestation regardless. Test command: same
golden fixture as AC-32; assert
macprovider-verify --bundle bundle.json --catalog catalog.json --catalog-pubkey <pk> --require-model-hash exits 1,
result: "invalid", reason: "model_hash_required".
AC-33 (verifier invalid on hash mismatch). A v0.3 verifier
MUST report invalid with reason: "model_hash_mismatch"
for a v0.3 receipt whose model_hash does not equal the
catalog's sha256 for the receipt's model_id. The signature
MAY be valid; the verifier MUST still report invalid.
details.expected and details.actual MUST both be populated
with the 64-hex strings. Test command: fixture
phase7-verify/testdata/spec015_v03_hash_mismatch/.
AC-34 (verifier inconclusive on unknown model_id). A v0.3
verifier MUST report inconclusive with reason: "model_id_not_in_catalog" for a v0.3 receipt whose model_id
is not present in the supplied catalog's models[]. Test
command: fixture
phase7-verify/testdata/spec015_v03_unknown_model_id/.
AC-35 (verifier invalid on bad catalog signature). A v0.3
verifier MUST report invalid with reason: "catalog_signature_invalid" when the catalog's signature.sig
does not verify against the supplied catalog pubkey, OR when
signature.alg != "Ed25519" (capital E; the v0.3 wire form
matches the existing scripts/sign-catalog.go:142-145
emitter). Test command: fixture
phase7-verify/testdata/spec015_v03_catalog_bad_sig/ covering
both failure modes (tampered sig bytes; alg field set to
"ed25519" lowercase, "", "ECDSA", etc.).
AC-36 (verifier inconclusive on expired catalog). A v0.3
verifier MUST report inconclusive with reason: "catalog_expired" when the catalog's expires_at is more than
60 seconds in the past relative to the verifier's wall clock.
Test command: fixture
phase7-verify/testdata/spec015_v03_catalog_expired/ using
SOURCE_DATE_EPOCH or libfaketime for deterministic clock.
AC-37 (backward-compat: v0.3 verifier on v0.1/v0.2 receipt).
A v0.3 verifier reading a v0.1 / v0.2 receipt (no
receipt_version field, 7 keys total) MUST report valid
without catalog check, and JSON output MUST include
warnings[] of kind catalog_skipped_legacy_receipt when
catalog flags were supplied. Test command:
macprovider-verify --bundle <v0.2-bundle.json> --catalog <good-catalog.json> --catalog-pubkey <good-key.b64>
exits 0, result: "valid",
warnings | map(.kind) | contains(["catalog_skipped_legacy_receipt"]).
AC-38 (forward-incompat: v0.2 verifier on v0.3 receipt).
A v0.1.3 / v0.2.4 verifier (i.e. unmodified, locked releases)
reading a v0.3 receipt (9 keys including receipt_version)
MUST report invalid per its §3.1 seven-keys-only rule. Test
command: ./phase7-verify-v0.2.4 --bundle <v0.3-bundle.json>
exits 1, result: "invalid". The exact reason string is
implementation-defined for the locked release (likely
extra_field or signature failure depending on which check
fires first), but result: "invalid" MUST hold.
AC-39 (/poolz catalog fields). A coordinator with
Tier2Config.CatalogPath configured AND a successfully
loaded+verified catalog MUST emit catalog_id, catalog_url,
and catalog_pubkey_url as top-level fields in the /poolz
response. A coordinator with Tier2Config.CatalogPath == ""
MUST omit those three fields entirely (NOT
present-with-null-value). A coordinator with
Tier2Config.CatalogPath set but the catalog failed to load /
parse / verify-signature MUST omit those three fields (the
catalog is not effectively configured). Test commands:
curl -s -H "Authorization: Bearer $OP" http://coordinator/poolz | jq 'has("catalog_id") and has("catalog_url") and has("catalog_pubkey_url")'returnstrueon a catalog-configured coordinator.- Same command returns
falseon an unconfigured coordinator. - Same command returns
falseon a coordinator whose configured catalog fails to load (e.g. file missing).
AC-40 (RequireHashVerified orthogonality preserved).
A coordinator with RequireHashVerified: false (the Entry 80
deferred default) MUST continue to route to providers whose
hash status is uncatalogued OR catalog_unavailable —
matching the SPEC-008 §5.6 routing predicate
(phase4-coordinator/internal/tier2/catalog.go:599-604
IsHashPredicateFailure) which fails-CLOSED on
hash_mismatch and hash_invalid REGARDLESS of the
RequireHashVerified setting. v0.3 does NOT change SPEC-008
§5.6 routing semantics. v0.3 receipt issuance on
routing-eligible providers (i.e. providers in uncatalogued,
catalog_unavailable, or hash_verified states) MUST still
emit the actual loaded hash per §M.2 (even when the hash is
uncatalogued, the receipt commits to it — the buyer is the one
who decides via verifier --catalog whether to accept).
Test command: with RequireHashVerified: false, a provider
serving a model NOT in the catalog (hash status
uncatalogued) routes normally; the receipt carries
non-null model_hash; the buyer running macprovider-verify --bundle X --catalog <catalog.json> --catalog-pubkey ...
reports inconclusive: model_id_not_in_catalog. The
coordinator's routing decision (uncatalogued allowed at flag
default) and the verifier's catalog decision (model_id not
in catalog → inconclusive) are independent and v0.3 does NOT
change either. A separate test with a provider in
hash_mismatch state (configured catalog + provider reporting
the wrong hash) confirms the coordinator REJECTS routing at
both RequireHashVerified settings per SPEC-008 §5.6.
AC-41 (catalog cache TTL). The --catalog-url-fetched
catalog cache MUST follow §M.3.4: a catalog whose
expires_at - now() > 6h caches for 6h; a catalog with
expires_at - now() < 60s does NOT cache; a catalog with
expires_at - now() in [60s, 6h] caches for
expires_at - now() - 60s. Test command: mock catalog
with controlled expires_at + verifier wall clock; assert
cache file mtime + content + cache-miss-on-rotated-pubkey.
AC-42 (mid-swap defence-in-depth refusal — NOT normal swap
in-flight requests). §M.2.2's construction proof says a
normal in-flight request that began on the OLD container and
finishes while the runtime is in loading or draining
state MUST still emit a v0.3 receipt with the hash captured
at request START (per SPEC-011 R-3.4.1 in-flight tracking +
R-3.2.2 snapshot semantics). AC-42 tests ONLY the
defence-in-depth case: a synthetic state in which the
runtime CANNOT identify which container served a specific
request (i.e. the request-start-hash capture path failed or
was bypassed — unreachable in correct SPEC-011 R-3.4.1
implementations, but the §M.2.2 defence-in-depth clause
demands the check). Test command: synthetic harness in
phase3-binary that drives the runtime into a
swap-in-progress state AND simulates a request whose
request-start-hash slot is unset / nil / corrupted (e.g. via
unit-level injection that bypasses the normal R-3.4.1 path);
verify the response has no X-MacProvider-Receipt header AND
the audit sink contains a matching receipt_omitted: reason = model_swap_violation row. A separate positive test ensures
a NORMAL in-flight request through loading / draining
DOES emit a receipt with the request-start hash (this is the
§M.2.2 construction-proof path, not a violation).
Recorded here so future audit cycles do not re-litigate. Each item names the future SPEC that may revisit it.
-
RequireHashVerifiedenforcement at the coordinator. Perbeta/DECISION_CRITERIA.mdEntry 80 (2026-06-22), the flag stays at itsfalsedefault until any of three triggers fire (pool size growth, catalog pipeline ergonomics, buyer demand). v0.3 receipts BIND the hash; the coordinator's route/reject policy is independent. AC-40 pins this. Successor: an Entry-80 revisit, not a SPEC-015 revision. -
Streaming receipts. v0.1.3 §15 Q5 (streaming receipt delivery mechanism) was deferred by v0.3 and is resolved for settlement by v0.4 §N.5. v0.3's §M.2.2 one-model-per-response rule remains the input to v0.4's request-start model-hash rule.
-
Mid-response model swaps producing a multi-hash receipt. v0.3 §M.2.2 NORMATIVELY REFUSES the shape. v0.4 continues to use one request-start
model_hashper request attempt. A later profile may introduce a multi-hash receipt shape, particularly for streaming responses where a swap genuinely spans a long response. §15 Q7 captures the future question. -
Cross-catalog federation. A coordinator serves ONE signed catalog. v0.3 is single-catalog-per-coordinator; federation across operators (or multiple catalogs per coordinator) is v0.4+ scope.
-
On-chain anchoring of catalog Merkle roots. Gated on the Cluster D-tokens go/no-go decision; orthogonal to v0.3.
-
Quantization-aware verification. v0.3 is one-
model_id-one-sha256. Quantization variants of the same logical model (e.g. 4-bit vs 8-bit qwen2.5-7b) MUST be published with distinctmodel_idvalues per SPEC-008 v0.3- SPEC-010 v1.5 convention. A future SPEC may allow
per-
model_idaccept-list of multiple hashes; v0.3 does not.
- SPEC-010 v1.5 convention. A future SPEC may allow
per-
-
HuggingFace-style "soft" model identity (model card metadata, training-run provenance, dataset provenance). v0.3 receipts bind weights (SHA-256 of the loaded MLX container), not lineage. Deferred indefinitely.
-
RFC8785JCS.swiftamendments. None required for v0.3 beyond extending the keyset emitted by the existing sorted-emit path. See §M.1.5. -
TUF / signed-root upgrade of the catalog-pubkey trust root. §M.3.3 inherits the §8.3 operator-mutability limit; §15 Q1 names TUF / on-chain anchoring as the v0.x+ successor. v0.3 is explicit about the limit and does not close it.
v0.4 defines the first SPEC-015 profile that can be used by SPEC-022 verified-model settlement. v0.1/v0.2/v0.3 receipts remain valid for their historical verifier purposes, but they are NOT settlement-capable under SPEC-022 enforce mode because they do not bind request attempt, terminal state, route-time verification snapshot, canonical usage, or streaming delivery/storage.
v0.4 is a new receipt profile, identified by
receipt_version: "4". A v0.4 settlement receipt MUST NOT be
encoded as a v0.3 tuple with optional fields. The v0.4 field set is
strict and version-discriminated.
A v0.4 verifier MUST:
- Continue to verify v0.1/v0.2/v0.3 receipts under their existing semantics.
- Classify v0.1/v0.2/v0.3 receipts as
not_settlement_capablefor SPEC-022 money movement, even when their historical signature/prompt/output checks arevalid. - Treat unknown future
receipt_versionvalues asinconclusive: unknown_receipt_version, notvalid, notinvalidsolely by field count, and not payable.
A v0.3 verifier reading a v0.4 receipt follows §M.1.4 and reports
inconclusive: unknown_receipt_version.
A v0.4 receipt is a JCS-canonicalized JSON object with EXACTLY the fields in this section and no others. The tuple is signed using the same envelope form as §3.4:
<base64(JCS(T))>.<base64(SIG)>
SIG = ed25519_sign(provider_receipt_private_key, UTF-8(JCS(T))).
The signed tuple fields are:
| Field | Type | Definition |
|---|---|---|
account_scope |
string | Privacy-preserving account scope for the buyer account or tenant whose ledger row will consume the receipt. It MAY be a digest. It MUST be stable for settlement of the exact request attempt and MUST NOT expose bearer tokens. |
catalog_body_digest |
string | SHA-256 digest, 64 lowercase hex, over the signed catalog body that was route-valid for this attempt. |
catalog_id |
string | Catalog id from the route-time snapshot. |
expected_catalog_model_hash |
string | Non-null 64 lowercase hex expected hash for model_id from the route-time catalog snapshot. |
issued_at_unix_ms |
int64 | Provider receipt issuance timestamp in Unix milliseconds. |
model_hash |
string | Non-null 64 lowercase hex of the request-start loaded model hash. JSON null is not settlement-capable in v0.4. |
model_id |
string | Requested model id, verbatim as routed. |
output_hash |
string | SHA-256 digest, 64 lowercase hex, of the canonical delivered output material defined by §N.5. |
output_prefix_end_byte |
int64 | Exclusive byte offset of this attempt's buyer-visible canonical output prefix in the request-level delivered-output byte stream. Non-negative. |
output_prefix_start_byte |
int64 | Inclusive byte offset of this attempt's buyer-visible canonical output prefix in the request-level delivered-output byte stream. Non-negative and <= output_prefix_end_byte. |
prompt_hash |
string | SHA-256 digest, 64 lowercase hex, of the canonical request material as normalized by the coordinator/gateway. |
provider_id |
string | Provider identity selected for this route attempt. |
provider_receipt_key_id |
string | ed25519-sha256:<64 lowercase hex>, where the digest is SHA-256 over the raw 32-byte Ed25519 receipt public key pinned in the route-time snapshot. The raw public key is resolved out of band and MUST NOT be copied into audit rows. |
receipt_version |
string | MUST be exactly "4" for this profile. |
request_id |
string | Buyer-visible or coordinator-internal request id bound to the ledger row. |
route_snapshot_digest |
string | SHA-256 digest, 64 lowercase hex, of the immutable route-time verification snapshot. |
route_snapshot_mode |
string | Effective SPEC-022 policy mode at route time, e.g. observe or enforce. |
route_snapshot_policy_version |
string | Effective SPEC-022 policy version at route time. |
signature_key_alg |
string | MUST be "Ed25519". |
terminal_state |
string | One of the §N.4 terminal states. |
terminal_state_ts_unix_ms |
int64 | Coordinator/gateway-recorded terminal-state timestamp in Unix milliseconds, echoed in the receipt. The coordinator ledger timestamp is authoritative and anchors pending-deadline calculation. |
attempt_n |
int64 | Zero-based monotonic route-attempt number for the request. The first attempt is 0; each retry or failover increments by exactly 1, matching the SPEC-002/SPEC-005 ledger identity. |
usage |
object | Canonical usage object defined by §N.6. |
The tuple MUST use JCS canonicalization. Field order in the table is explanatory; JCS key ordering is authoritative for signed bytes.
The signed wire envelope necessarily contains a signature and may be verifiable with public-key material. Audit, telemetry, verifier-result rows, settlement verdict rows, operator surfaces, and buyer-facing status rows MUST NOT contain raw receipt signatures, raw receipt public keys, raw receipt envelopes, raw prompts, raw outputs, bearer tokens, receipt private keys, or provider-private state. Such rows MAY carry digests, fingerprints, reason codes, and parsed scalar fields needed for settlement.
For every v0.4-settled request attempt, the coordinator MUST create and persist an immutable route-time verification snapshot before forwarding work to the provider. The snapshot MUST be retrievable by settlement verification. The digest is the binding anchor, not the only retained material.
A route snapshot digest is computed as:
sha256(UTF-8(JCS(route_snapshot_v1)))
where route_snapshot_v1 is a strict JCS object with EXACTLY these
fields:
account_scope;request_id;attempt_n;provider_id;provider_session_id: string or JSON null when not available;provider_generation_id: string or JSON null when not available;paid_entrypoint: string identifying the paid entrypoint that admitted the request;provider_receipt_key_id: theed25519-sha256:<hex>value defined by §N.1;provider_receipt_key_source: enum string, one ofauth_session,rotation_grace, oroperator_pin;model_id;provider_reported_model_hash;expected_catalog_model_hash;catalog_id;catalog_body_digest;catalog_signature_key_id;catalog_signature_pubkey_fingerprint:ed25519-sha256:<64 lowercase hex>over the raw 32-byte catalog public key;catalog_expires_at_unix_ms;spec008_hash_status: string, one of the SPEC-008 §5.5 hash-status enum values observed at route time;route_snapshot_policy_version;route_snapshot_mode;route_decision_ts_unix_ms;request_start_ts_unix_ms;pending_deadline_seconds;prompt_hash_basis: enum string naming the coordinator/gateway canonical request normalizer version;prompt_hash.
No other fields are allowed in route_snapshot_v1. If a deployment needs
additional route-validity fields for settlement, it MUST define
route_snapshot_v2 and a corresponding policy version rather than silently
changing the digest input.
Settlement verification MUST prove the SPEC-022 three-way equality:
receipt.model_hash == route_snapshot.provider_reported_model_hash == route_snapshot.expected_catalog_model_hash.
Catalog rotation, catalog rollback, provider reconnect, warm-swap, or delayed receipt arrival MUST NOT change the snapshot used for this attempt.
v0.4 timestamp checks are settlement-critical. Clock-skew warnings alone are not sufficient for positive settlement.
A v0.4 settlement verifier MUST reject or quarantine positive money movement unless all of the following hold:
issued_at_unix_msandterminal_state_ts_unix_msare within the exact account/request/attempt settlement window.terminal_state_ts_unix_msequals the coordinator/gateway-recorded terminal-state timestamp on the ledger row. The receipt echoes this timestamp; it is not authoritative for deadline calculation.issued_at_unix_msis greater than or equal toroute_snapshot.request_start_ts_unix_ms - 60000and less than or equal to the coordinator receipt-received timestamp plus 60000 ms. The maximum v0.4 skew allowance is 60000 ms. A deployment MAY choose a smaller skew, but not a larger one, without a successor profile.- Receipt arrival before/after deadline is decided by the
coordinator-recorded receipt-received timestamp compared with
terminal_state_ts_unix_ms + pending_deadline_seconds * 1000. Providerissued_at_unix_mscannot extend this deadline. - The receipt is for the exact
(account_scope, request_id, attempt_n, provider_id, provider_receipt_key_id)ledger row. - A replay of a valid receipt onto a different account, request,
attempt, provider, receipt key, route snapshot, or terminal state
maps to
quarantined. - A receipt that arrives after the ledger row has terminally quarantined by deadline is an idempotent no-op or rejected audit event. It MUST NOT resurrect the row, re-debit the buyer, create provider credit, or create payout readiness.
v0.4 defines the following terminal states:
normal_done;provider_error;buyer_cancel;gateway_timeout;upstream_transport_disconnect.
The terminal state in the receipt MUST match the terminal state stored
on the ledger row for the same request attempt. A mismatch maps to
quarantined.
The provider/issuer-facing contract MUST expose the receipt submission
deadline or pending_deadline_seconds basis. It MUST disclose that a
late receipt is non-settling once its row has deadline-quarantined.
v0.4 does not use the historical §5 three-key canonical output object as
the settlement output_hash input. Historical v0.1/v0.2/v0.3 receipt
hashing remains unchanged. A v0.4 implementation instead reconstructs
the §5 content, tool-call, and finish-reason material, then wraps it in
the settlement_output_v1 object defined below so streaming and
non-streaming attempts share one hash profile.
For streaming requests, the client-facing SSE stream MUST remain OpenAI-compatible:
- no non-standard
event: receiptblock is required; - no non-standard receipt-only
data:payload is required; - a stock OpenAI-compatible client MUST be able to read through
[DONE]or the terminal stream condition without receipt-parser changes.
Streaming receipts are delivered through a coordinator-ingested provider terminal frame, a post-stream internal receipt submission, or another SDK-safe internal channel. Buyer receipt retrieval MAY exist, but internal settlement verification MUST NOT depend on buyer action.
The delivered output for every v0.4 attempt is canonicalized as a JCS
object named settlement_output_v1 with EXACTLY these fields:
| Field | Type | Definition |
|---|---|---|
content |
string | Buyer-visible UTF-8 content for this provider attempt. For streaming, concatenate choices[].delta.content fragments in delivery order and ignore missing content fragments. For non-streaming, use the same content string that §5 would place in the canonical output object. |
finish_reason |
string or JSON null | Final OpenAI finish reason if observed for this attempt; otherwise null. For non-streaming, use the same finish reason that §5 would place in the canonical output object. |
output_prefix_end_byte |
int64 | Exclusive byte offset of this attempt's canonical output bytes in the request-level delivered-output byte stream. |
output_prefix_start_byte |
int64 | Inclusive byte offset of this attempt's canonical output bytes in the request-level delivered-output byte stream. |
terminal_state |
string | One of §N.4. |
tool_calls |
array or JSON null | Final reconstructed tool-call array using the same field order and argument byte-preservation rules as §5.2 and §5.3; null when no tool calls were delivered. For non-streaming, use the same tool-call material that §5 would place in the canonical output object. |
output_hash MUST equal
sha256(UTF-8(JCS(settlement_output_v1))) for both streaming and
non-streaming attempts.
For non-streaming normal_done, output_prefix_start_byte MUST be 0
and output_prefix_end_byte MUST equal the UTF-8 byte length of the
canonical buyer-visible output reconstructed from the complete response.
For non-streaming non-creditable terminal states, the same
settlement_output_v1 object is used with the observed delivered prefix
or an empty prefix under §N.7. Implementations MUST NOT substitute the
legacy §5 three-key object itself as the v0.4 settlement hash input.
The coordinator/gateway MUST persist (request_id, attempt_n, provider_id, output_prefix_start_byte, output_prefix_end_byte, output_hash) for every attempt. Half-open byte ranges
[output_prefix_start_byte, output_prefix_end_byte) are the overlap
authority. Two attempts overlap when these ranges intersect. Overlap
or duplicate ranges MUST be excluded from buyer final debit and
provider positive settlement unless a later SPEC defines an explicit
deduplication transform.
If transparent failover delivers output from multiple provider attempts, each provider attempt MUST have its own v0.4 receipt binding the prefix attributed to that attempt. Overlapping or duplicate output across attempts MUST be detectable. A duplicate prefix MUST NOT be charged to the buyer or credited to any provider twice.
If delivered-prefix hash material needed for buyer debit or provider
positive settlement is unavailable, the row remains pending until
deadline, then maps to quarantined with buyer reservation released
and provider credit zero.
The v0.4 usage field is a strict JCS object with EXACTLY these
integer fields and no others:
| Field | Type | Definition |
|---|---|---|
billable_input_tokens |
int64 | Input tokens eligible for buyer debit/provider settlement for this attempt. Non-negative. |
billable_output_tokens |
int64 | Output tokens eligible for buyer debit/provider settlement for this attempt. Non-negative. |
delivered_output_bytes |
int64 | Length in bytes of this attempt's canonical buyer-visible output prefix. MUST equal output_prefix_end_byte - output_prefix_start_byte. |
observed_input_tokens |
int64 | Coordinator/gateway-observed or cross-checked input token count for this attempt. Non-negative. |
observed_output_tokens |
int64 | Coordinator/gateway-observed or cross-checked output token count for this attempt. Non-negative. |
Null values are not allowed in usage. Non-creditable terminal states
MUST set billable_input_tokens and billable_output_tokens to 0.
If a future billing profile needs non-token units, it MUST define a
successor receipt profile rather than adding optional fields to v0.4.
Provider-signed usage alone is not authority. Usage used for buyer
final debit or provider positive settlement MUST be derived from or
cross-checked against coordinator/gateway-observed canonical request
and delivered-output state under the applicable SPEC-005 rules. A
provider-only usage value maps to quarantined for positive money
movement.
The v0.4 profile defines the settlement relationship for every §N.4 terminal state. These rows are deterministic for the v0.4 profile. SPEC-005 may later absorb or replace them in a successor settlement profile, but SPEC-022 enforce mode MUST NOT activate against v0.4 unless these exact rows or a locked successor table is implemented.
| Terminal state | Buyer final debit | Provider positive settlement | zero_settled possible |
Required output hash material | Required usage material | Missing receipt or insufficient binding |
|---|---|---|---|---|---|---|
normal_done |
yes, if verified | yes, if verified | no | complete delivered output | billable_input_tokens == observed_input_tokens; billable_output_tokens == observed_output_tokens |
pending until deadline, then quarantined |
provider_error |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes when delivered_output_bytes == 0 and verified |
delivered prefix or empty prefix | billable output tokens MUST be 0 when delivered_output_bytes == 0; otherwise billable output tokens MUST be <= observed_output_tokens |
pending until deadline, then quarantined |
buyer_cancel |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes when delivered_output_bytes == 0 and verified |
delivered prefix or empty prefix | billable output tokens MUST be 0 when delivered_output_bytes == 0; otherwise billable output tokens MUST be <= observed_output_tokens |
pending until deadline, then quarantined |
gateway_timeout |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes when delivered_output_bytes == 0 and verified |
delivered prefix or empty prefix | billable output tokens MUST be 0 when delivered_output_bytes == 0; otherwise billable output tokens MUST be <= observed_output_tokens |
pending until deadline, then quarantined |
upstream_transport_disconnect |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes only for the verified delivered prefix when delivered_output_bytes > 0; otherwise no |
yes when delivered_output_bytes == 0 and verified |
delivered prefix or empty prefix | billable output tokens MUST be 0 when delivered_output_bytes == 0; otherwise billable output tokens MUST be <= observed_output_tokens |
pending until deadline, then quarantined |
zero_settled is only for verified non-creditable terminal states
allowed by this table or a successor SPEC-005 settlement profile. A
missing, invalid, legacy, hashless, wrong-key, wrong-attempt,
wrong-snapshot, wrong-terminal-state, or insufficient-binding receipt
MUST NOT map to zero_settled.
The historical verifier tri-state (valid, invalid,
inconclusive) is necessary but not sufficient for settlement. v0.4
adds settlement outcomes consumed by SPEC-022:
pending;verified;quarantined;zero_settled.
Mapping rules:
validwith a chargeable terminal-state row maps toverifiedonly after every route snapshot, request attempt, model-hash, prompt-hash, output-hash, usage, timestamp, terminal-state, and receipt-key check succeeds.validwith a non-creditable terminal-state row maps tozero_settledonly when §N.7 or a successor SPEC-005 profile explicitly allows zero settlement for that terminal state.invalid, mismatched, legacy, hashless, wrong-key, wrong-attempt, wrong-account, wrong-provider, wrong-snapshot, wrong-terminal-state, replayed, or insufficient-binding receipts map toquarantined.- Missing receipts and receipt trust-root
inconclusiveresults remainpendinguntil the configured deadline, then map toquarantined. - Unknown future receipt versions are
inconclusiveand not payable. They MUST NOT map toverifiedorzero_settled.
First terminal receipt selection closes the settlement row for this attempt. Later receipts for the same attempt are idempotent no-ops or rejected audit events and cannot change buyer debit, provider credit, payout readiness, or settlement outcome.
v0.4 requires a coordinator receipt-ingestion path that supports settlement verification without requiring buyer action.
The coordinator MUST store parsed receipt records keyed by:
(account_scope, request_id, attempt_n, provider_id).
The stored record MUST include:
- parsed verifier-safe v0.4 fields needed by SPEC-022;
- receipt verification outcome and reason;
- terminal-state timestamp and pending-deadline basis;
- route-snapshot digest and policy version/mode;
- provider receipt-key fingerprint or digest;
- catalog id/body digest and expected catalog model hash;
- prompt/output hash verification result;
- usage verification result;
- idempotency/replay status.
Raw receipt retention is allowed only where a retention/security policy explicitly permits it. Raw receipt retention MUST be segregated from audit, telemetry, verdict, and operator rows. Implementations MUST never copy receipt signatures, receipt public keys, raw receipt envelopes, raw prompts, raw outputs, bearer tokens, receipt private keys, or provider-private state into audit, telemetry, verdict, or operator rows.
The coordinator MUST expose internal verification APIs for SPEC-022 settlement code. This v0.4 SPEC does not itself add provider-positive credit, buyer final debit, payout-ready insertion, or SPEC-022 enforce activation.
A v0.4 settlement verified outcome means:
- the receipt signature verified against the route-snapshot-pinned provider receipt-key identity;
- the receipt bound the exact account/request/attempt/provider row;
- the receipt bound the same model id and non-null model hash as the route-time catalog snapshot;
- the receipt proved
receipt.model_hash == route_snapshot.provider_reported_model_hash == route_snapshot.expected_catalog_model_hash; - prompt/output hashes matched the persisted canonical material;
- usage was derived from or cross-checked against coordinator/gateway observation;
- timestamp/window checks passed;
- terminal-state and chargeability checks passed.
v0.4 does NOT prove that a malicious provider cannot falsify its own local model-hash measurement before reporting it. That boundary remains outside SPEC-015 without hardware/runtime attestation. Product and buyer-facing language MUST NOT claim more than this.
SPEC-015 v0.4 receipts MUST NOT include request-start compute-integrity state
digests, sampler state, SPEC-036 policy digests, probe/reference digests, or
SPEC-036 audit-artifact digests in the signed tuple or strict usage object.
Any such field in a receipt_version: "4" tuple is an extra field under §N.1
and AC-43.
This is deliberate. SPEC-015 v0.4 proves settlement tuple integrity and provider receipt-key accountability. SPEC-036 compute integrity is coordinator-owned sampled/overt drift evidence and may narrow settlement only through the SPEC-022/SPEC-036 policy gate. A v0.4 verifier MUST NOT infer compute-integrity state, adverse-state absence, or proof of honest computation from the receipt alone.
If externally reviewable request-start compute-integrity binding is required,
the compatible path is a separate SPEC-036 audit artifact keyed to the same
account/request/attempt/provider, route-snapshot digest, and digest of the
provider-signed SPEC-015 tuple. That artifact is not itself a SPEC-015 receipt
and is not required for v0.4 receipt validity. A future SPEC-015 successor MAY
reference a SPEC-036 artifact digest only by defining a new receipt_version
and verifier behavior; it MUST NOT be added as an optional v0.4 field.
The v0.4 implementation MUST satisfy these acceptance criteria before SPEC-022 can consume the profile:
- AC-43: A v0.4 receipt with missing or extra tuple fields is rejected before settlement.
- AC-44:
receipt_versionMUST be exactly"4"for the v0.4 profile. A v0.3 verifier reports it asinconclusive: unknown_receipt_version. - AC-45:
model_hash: nullis not settlement-capable and maps toquarantinedfor SPEC-022 positive money movement. - AC-46: A receipt whose
request_id,attempt_n,account_scope,provider_id, orprovider_receipt_key_iddiffers from the ledger row maps toquarantined. - AC-47: A receipt whose route snapshot digest or route-time
policy version/mode differs from the ledger row maps to
quarantined; the test fixture computessha256(UTF-8(JCS(route_snapshot_v1)))and mutates each route-validity field in §N.2 at least once. - AC-48: A receipt verifies the three-way model-hash equality and quarantines on any mismatch.
- AC-49: Prompt/output hash mismatch maps to
quarantined. - AC-50: Prompt/output canonical hash unavailable maps to
quarantined; entrypoints that cannot persist canonical hashes are excluded from paid SPEC-022 traffic. - AC-51: Provider-signed usage without coordinator/gateway
cross-check cannot produce buyer final debit or provider positive
settlement; fixtures cover missing usage fields, extra usage fields,
null usage values, negative usage values, and mismatched
delivered_output_bytes. - AC-52: Non-streaming
normal_donewith a settlement-capable, catalog-matching v0.4 receipt can map toverified. - AC-53: Streaming
normal_doneproduces an internally verifiable v0.4 receipt without breaking OpenAI-compatible clients; the test hashessettlement_output_v1and verifies the half-open prefix byte range. - AC-54: Streaming
provider_errorbinds terminal state, output hash material, and usage material required by its chargeability row. - AC-55: Streaming
buyer_cancelbinds terminal state, delivered prefix, and partial usage when any partial money movement is allowed. - AC-56: Streaming
gateway_timeoutbinds terminal state, delivered prefix, and partial usage when any partial money movement is allowed. - AC-57: Streaming
upstream_transport_disconnectbinds terminal state, delivered prefix, and partial usage when any partial money movement is allowed. - AC-58: Partial-output binding unavailable remains pending until deadline, then quarantines with buyer reservation released and no provider credit.
- AC-59: Transparent failover emits one receipt per provider-attempt prefix and prevents duplicate/overlapping prefix double charge or double credit; fixtures include adjacent ranges, overlapping ranges, duplicate ranges, and an out-of-order retry.
- AC-60: Replaying a valid receipt onto a different account, request, attempt, provider, key, snapshot, or terminal state cannot produce positive money movement.
- AC-61: Resubmitting a receipt after a terminal outcome is idempotent and cannot create a second buyer debit, provider credit, or payout-ready row.
- AC-62: A valid receipt arriving after deadline quarantine does
not resurrect the row; fixtures prove the receipt
terminal_state_ts_unix_msmust exactly equal the coordinator/gateway ledger timestamp and thatissued_at_unix_mscannot extend the deadline. - AC-63: Unknown future receipt versions are inconclusive and not payable.
- AC-64: Legacy v0.1/v0.2/v0.3 receipts are not settlement-capable for SPEC-022 enforce mode.
- AC-65: Audit/telemetry/verdict rows redact raw receipt signatures, raw receipt public keys, raw receipt envelopes, raw prompts, raw outputs, bearer tokens, receipt private keys, and provider-private state.
- AC-66: Raw receipt retention, if enabled, is segregated from audit/telemetry/verdict/operator rows and is covered by an explicit retention/access policy.
- AC-67: Provider/issuer docs or API surfaces expose the receipt
submission deadline or
pending_deadline_secondsbasis and disclose late receipt non-settlement. - AC-68: Buyer/product disclosures state that v0.4 verifies the provider-reported request-start model hash against the route-time catalog snapshot and does not detect a provider that falsifies its own measurement.
- AC-69: A receipt whose
signature_key_algis absent or present with any value other than"Ed25519"is rejected before settlement. - AC-70:
provider_receipt_key_idis exactlyed25519-sha256:<64 lowercase hex>over the raw 32-byte Ed25519 public key pinned in the route snapshot; a receipt signed by any other key or carrying any other fingerprint maps toquarantined. - AC-71: Each §N.7 terminal-state row is exercised for
delivered_output_bytes == 0anddelivered_output_bytes > 0, proving the deterministicverifiedvszero_settledmapping.
The following audit categories are added (SPEC-006 v0.9 candidate absorption; tracked locally for now):
receipt_issued: emitted by the provider when a receipt is written to the response. Event-specific fields:model_id,tokens_out,ttft_ms,unix_ts. The audit-record envelope (provider_id,request_id, event timestamp) is inherited from the common SPEC-005 v0.3 §6 audit-sink envelope and MUST NOT be duplicated inside the event-specific block. Implementations MUST NOT log the receipt'sprovider_pubkey,prompt_hash,output_hash, or signature into the audit sink: the receipt is a buyer-held proof, not a server-side audit row.receipt_omitted: emitted by the provider/coordinator/gateway when a receipt is suppressed per §6.4. Fields:provider_id,request_id,reason(pre_v1_6_binary|no_keypair|model_swap_violation|pre_token_cancel|streaming_request). v0.3 update: themodel_swap_violationreason is PROMOTED from v0.1/v0.2 placeholder to defined semantics per §M.2.2: the provider's runtime was inloadingordrainingstate at receipt-emission time AND could not disambiguate which container served the response. Emission is constrained to the defence-in-depth path of §M.2.2; the normal §M.2.2 construction (every receipt commits to the hash at request START) does NOT fire this reason. Operator monitoring SHOULD treat anyreceipt_omitted: model_swap_violationevent as an implementation regression to investigate.receipt_rotation_detected: emitted by the coordinator when a reconnecting provider'sauth_request.provider_receipt_public_keydiffers from the previously-known pubkey for thatprovider_id. Historical v0.1 through v0.3 fields:provider_id,old_pubkey,new_pubkey,rotated_at. This event replaces the v0.1/v0.1.1receipt_rotate_requestandreceipt_rotate_invalidevents, which are no longer emitted because v0.1.2 rotation is reconnect-based, not control-frame based. v0.4 redaction update: settlement-capable deployments MUST emitold_pubkey_fingerprintandnew_pubkey_fingerprintusing the §N.1ed25519-sha256:<hex>form instead of rawold_pubkey/new_pubkeyin audit, telemetry, verdict, or operator rows. Raw public keys remain available only from the key-resolution surface that verifies signatures, not from audit rows.settlement_receipt_ingested(v0.4): emitted by the coordinator when a v0.4 receipt is ingested through an internal settlement channel. Fields:account_scope,request_id,attempt_n,provider_id,receipt_version,terminal_state,route_snapshot_digest,route_snapshot_policy_version,route_snapshot_mode,catalog_id,catalog_body_digest,provider_receipt_key_fingerprint,model_id,model_hash,expected_catalog_model_hash,prompt_hash,output_hash,usage_digest,received_at_unix_ms. MUST NOT include raw receipt signatures, raw receipt public keys, raw receipt envelopes, raw prompts, raw outputs, bearer tokens, receipt private keys, or provider-private state.settlement_receipt_verdict(v0.4): emitted when a v0.4 settlement verifier maps a receipt or missing receipt topending,verified,quarantined, orzero_settled. Fields: allsettlement_receipt_ingestedscalar fields plussettlement_outcome,reason,deadline_unix_ms, and redacted verifier diagnostics. Raw receipt material remains prohibited.
receipt_issued is a high-cardinality event (one per response). Its
audit destination is the existing SPEC-005 v0.3 §6 billing audit
sink; the four event-specific scalar fields named above
(model_id, tokens_out, ttft_ms, unix_ts) plus the inherited
audit envelope are the complete v0.1.3 audit shape.
Rows below preserve v0.1.x through v0.3 behavior. v0.4 supersedes the streaming and settlement-capable rows through §N. Streaming requests can produce v0.4 receipts through coordinator-internal channels, while v0.1.x through v0.3 streaming requests carry no receipt regardless of outcome.
All historical rows below describe non-streaming
POST /v1/chat/completions behavior unless explicitly noted.
| Condition | Receipt? | Header value | finish_reason | tokens_out |
|---|---|---|---|---|
| Normal non-streaming completion | yes (header) | populated | stop | length | tool_calls | content_filter |
reported |
| Streaming request (any outcome) | no (v0.1.x out of scope; v0.2+ design pending) | absent | n/a | n/a |
| Buyer HTTP disconnect mid-response on non-streaming | no | absent | n/a | n/a (provider has no full response to commit to and no buyer to deliver a receipt to) |
| Provider returns SPEC-001 null-usage error | yes | populated | error |
0 |
| Pre-v1.6 binary | no | absent | n/a | n/a |
| Model swap drain violation (defensive) | no, 500 returned | absent | n/a | n/a |
| Gateway/coordinator internal failure (provider never reached) | no | absent | n/a | n/a |
SPEC-005 v0.3 §X-1 settlement semantics for non-streaming disconnects continue to apply on the billing side; v0.1.x simply declines to emit a receipt for the partial-response disconnect case because there is no buyer-deliverable receipt to commit to. A v0.2+ design that captures partial-response receipts is open design space.
v0.1.3 pins ONLY the provider-side Keychain storage (because the private key is a security-critical artifact) and the audit-log emission (because audit events are observable behavior). Coordinator and gateway storage are implementation concerns named in the future BUILD spec, per the §7.3 deferral.
| Surface | Field | Type | Notes |
|---|---|---|---|
| Provider Keychain | com.malibu.provider.receipt-key/<provider_id> |
32-byte raw ed25519 private key | kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, Synchronizable=false |
| Coordinator memory | Provider.ReceiptPubkey []byte |
32 bytes | populated on auth, lifetime tied to WS session unless the BUILD spec adds durable storage |
| Audit log | receipt_issued event |
JSON | per response, fields per §11 |
The coordinator and gateway MUST NOT store the receipt value (the
X-MacProvider-Receipt header bytes) server-side under v0.1.x. The
receipt is buyer-held proof; persisting it server-side would defeat
the offline-verifiability property and create a server-side trove
of prompt/output digests the operator does not need. There is no
exception in v0.1.x: streaming receipts are out of scope (§6.3), so
no server-side retention is needed for any v0.1.x receipt path. A
future v0.2+ streaming-receipt design that needs server-side
storage MUST name its own retention contract and re-establish the
buyer-held-proof posture or accept the v0.1.x divergence
explicitly.
v0.4 accepts that divergence for settlement-capable receipts. The coordinator MUST ingest and store parsed verifier-safe receipt fields for internal settlement. Raw receipt retention, if enabled, is governed by §N.9 and MUST be segregated from audit, telemetry, verdict, and operator rows.
Each AC is independently verifiable from outside this SPEC.
AC-1. A v1.6 phase3-binary serve process on first launch
generates an ed25519 keypair, stores it in macOS Keychain at
service com.malibu.provider.receipt-key account
<provider_id>, and on a fresh launch with the same provider_id
reads the same private key bytes from Keychain (verify by computing
the public key from the stored private key and comparing against the
expected pubkey).
AC-2. A v1.6 binary's v2 auth_request initial-stage frame
carries provider_receipt_public_key as a 44-character base64
string. Decoding it yields exactly 32 bytes.
AC-3. A v1.5 binary (pre-v1.6) does NOT carry
provider_receipt_public_key on the auth frame; the coordinator
admits it successfully and its /poolz row shows
receipt_pubkey: null.
AC-4. For a v1.6 provider serving a non-streaming
POST /v1/chat/completions with a fixed model, prompt, and
temperature: 0, the response carries an X-MacProvider-Receipt
header. The value parses as <base64>.<base64>. The first base64
decodes to UTF-8 JSON containing exactly the seven SPEC-015 §3.1
keys; the second base64 decodes to exactly 64 bytes.
AC-5. For the same request as AC-4, recomputing the canonical
prompt object per §4 and hashing it yields a 64-character lowercase
hex string identical to receipt.prompt_hash.
AC-6. For the same request as AC-4, recomputing the canonical
output object per §5 from the response body and hashing it yields a
64-character lowercase hex string identical to receipt.output_hash.
AC-7. For the same request as AC-4,
ed25519_verify(receipt.provider_pubkey, base64_decode(b64_tuple), base64_decode(b64_sig)) returns true.
AC-8. For a streaming POST /v1/chat/completions, the response
carries NO X-MacProvider-Receipt header AND NO additional
X-MacProvider-* response header beyond what SPEC-006 v0.8.3 §17
already allowlists. The SSE stream itself is exactly what SPEC-001
v1.5 and SPEC-006 v0.8.3 already specify (no extra event: blocks,
no non-OpenAI-shaped data: payloads). Receipts for streaming
requests are out of scope in v0.1.x.
AC-9. The OpenAI Python SDK ≥ v1.0 and the OpenAI JavaScript
SDK ≥ v4.0, with base_url pointing at the SPEC-006 gateway, MUST
complete chat.completions.create(...) (non-streaming) AND
chat.completions.create(stream=True) successfully against a v1.6
provider. The non-streaming response carries an
X-MacProvider-Receipt header (which the SDK ignores transparently);
the streaming response carries no SPEC-015 wire changes. The SDK
MUST NOT raise on either request shape.
AC-10. Running macprovider rotate-key on a connected
v1.6 binary causes the binary to close its current WS connection
and reconnect with a freshly-generated keypair in the v2
auth_request initial-stage provider_receipt_public_key field.
On successful reconnect, the coordinator's /poolz row for this
provider reflects the new pubkey under receipt_pubkey and the old
pubkey under receipt_pubkey_prev with
rotated_at = the reconnect time. The next response after rotation
is signed with the new key. If reconnect fails (coordinator rejects
auth or network failure), the CLI exits non-zero, the Keychain
state is unchanged, and the binary continues signing with the old
key on its restored WS session.
AC-11. During the 7-day rotation grace window, a buyer who
fetches /poolz, sees receipt_pubkey_prev.expires_at in the
future, and verifies a receipt against receipt_pubkey_prev.pubkey
succeeds for receipts whose unix_ts is between
receipt_pubkey_prev.rotated_at - 60 and
receipt_pubkey_prev.expires_at. The −60 s slack covers in-flight
requests on the old key at rotation time (a provider may have begun
signing a receipt with the old key up to ~60 s before the
reconnect-based rotation was accepted by the coordinator).
AC-12. A SPEC-001 null-usage error response (e.g.
error_model_not_loaded) on a v1.6 provider carries an
X-MacProvider-Receipt header with tokens_out: 0,
output_hash equal to the sha256 of the canonical output object
{"content":"","tool_calls":null,"finish_reason":"error"}, and
verifies cleanly against the provider pubkey.
AC-13. A request that the gateway rejects before reaching any
provider (auth failure, quota exhausted, kill switch on) does NOT
carry an X-MacProvider-Receipt header.
AC-14. A non-streaming request routed to a coordinator-recorded
provider whose receipt_pubkey is null (pre-v1.6 binary) does
NOT carry an X-MacProvider-Receipt header.
AC-15. The X-MacProvider-Receipt header value is ≤ 4096
ASCII bytes for the v0.1.3 tuple shape; nginx between gateway and
buyer MUST be configured (or already configured) to forward headers
of this size without truncation.
AC-16. The receipt-issuing path MUST NOT introduce >5 ms p95 overhead over the existing SPEC-001 v1.5 baseline for a 1024-output-token completion on the smallest supported model. The overhead is dominated by SHA-256 + ed25519_sign on a payload of ≤ 600 bytes; on Apple Silicon, both are sub-millisecond.
AC-17. The SPEC-001 v1.6 candidate annotation
(provider_receipt_public_key field on auth_request initial-stage
ONLY) MUST be parser-optional on the coordinator: a v1.6 binary
that omits the field due to keypair-generation failure MUST still
admit successfully, the coordinator MUST log
receipt_omitted: reason=no_keypair, and the provider MUST be
flagged in its /poolz row as receipt_pubkey: null until a
subsequent reconnect with the field present.
AC-18. macprovider verify --bundle <fresh-receipt-bundle.json>
MUST exit 0 with result: "valid" for a bundle whose receipt
was issued by a v1.6 binary against a matching prompt/response,
where GET /v1/receipt-keys/<bundle.provider_id> (§10.7) on the
configured coordinator returns the issuing pubkey as
receipt_pubkey (current key) at the time of verification.
AC-19. Flipping a single byte in
response.choices[0].message.content of the bundle and re-running
macprovider verify --bundle ... MUST exit 1 with
result: "invalid", details.field: "output_hash", and the
non-matching computed/receipt hash pair populated.
AC-20. Flipping a single character in
request.messages[0].content (e.g. a single Unicode codepoint
change) MUST exit 1 with result: "invalid" and
details.field: "prompt_hash".
AC-21. Mutating any byte of the base64-decoded signed tuple
(e.g. flipping the last digit of unix_ts) without re-signing
MUST exit 1 with result: "invalid" and reason referencing
signature verification failure (the signature check fails before
any field-level mismatch is reported).
AC-22. With GET /v1/receipt-keys/<provider_id> unreachable
(configured coordinator host returns connection refused, 5xx, or
timeout within the §10.5 5-second budget) AND no fresh cached
entry for (coordinator_host, provider_id, receipt_pubkey) AND no
--pubkey argument, macprovider verify --bundle <bundle.json>
MUST exit 2 with result: "inconclusive",
trust_source: "none", and a warnings[] entry of kind
live_check_skipped with reason: "network_unreachable".
AC-23. macprovider verify --offline --pubkey <correct-44-char-base64> --provider-id <id> --bundle <bundle.json>
MUST exit 0 with result: "valid" AND emit ZERO network traffic
to any host. (Test this by running in a sandbox that denies all
egress; observe exit 0 and no DNS / TCP attempts.) JSON output
MUST include a warnings[] entry of kind live_check_skipped
with reason: "offline_flag".
AC-24. macprovider verify --json output MUST be exactly one
line of JSON conforming to the §10.4.2 field table. The verifier
implementation's release artifact MUST include a JSON-Schema
document covering valid, invalid, and inconclusive outputs
(including the details and warnings[] shapes), and the
verifier's test suite MUST validate every output across its
acceptance fixtures against that schema. The schema document MUST
be addressable from the release (e.g. published alongside the
binary) so independent buyer-side automation can validate verifier
output without re-deriving the schema from this spec.
AC-25. Each of the five normative exit codes (0, 1, 2,
64, 65) MUST be reachable by a concrete invocation pinned in
the verifier's acceptance test suite. 64 is reachable e.g. via
macprovider verify --unknown-flag or macprovider verify --pubkey badbase64== --bundle good.json (malformed flag value).
65 is reachable e.g. via macprovider verify --bundle <malformed.json>, a bundle with bundle_version: 99, a bundle
with an unknown top-level key, or a receipt header value that
fails to split on .. The 64 vs 65 boundary defined in
§10.4.3 MUST hold across all paths.
AC-26. A cache entry whose fetched_at is more than 7 days
before the verifier's wall clock MUST trigger a fresh
GET /v1/receipt-keys/<provider_id> fetch on the next
verification attempt that would use it. The acceptance test suite
MUST verify this by mocking the cache fetched_at and asserting
an outgoing HTTP GET /v1/receipt-keys/... call is made against
the configured coordinator host. If the live fetch fails AND no
fresh source remains, the verifier MUST exit 2
(inconclusive); the stale entry MUST NOT be used to produce
valid per §10.2.
AC-27. A receipt issued during the §7.5.2 7-day rotation
grace window verifies valid if and only if ALL of the following
hold simultaneously: (a) the resolved /v1/receipt-keys/<provider_id>
response contains a non-null receipt_pubkey_prev block whose
pubkey field matches the receipt's provider_pubkey, AND (b)
the receipt's unix_ts satisfies rotated_at - 60s ≤ unix_ts ≤ expires_at per the previous-key block. A previous-key match
OUTSIDE this interval MUST verify invalid with
reason: "previous_key_outside_grace_window". A receipt whose
provider_pubkey appears in neither receipt_pubkey nor
receipt_pubkey_prev.pubkey for the resolved provider_id MUST
verify invalid with reason: "pubkey_not_endorsed" (not
inconclusive).
These are flagged for v0.x audit cycles and are NOT resolved in v0.1. Implementers MUST NOT pin behavior in v0.1 that pre-decides these.
Q1: Stronger trust root. Should the buyer-facing
GET /v1/receipt-keys/<provider_id> endpoint (SPEC-015 v0.2 §10.7
candidate annotation) eventually be signed by an offline operator
key (TUF-style) or anchored to an external registry (AntFeed
provider listing, an on-chain Cluster D-token registry)? v0.2
inherits v0.1's honest acknowledgement that the coordinator-
returned pubkey set is operator-mutable; v0.2 narrows the
buyer-exposed surface from the operator-only /poolz to the
public /v1/receipt-keys endpoint, but does NOT add a signature
or anchor on top. The §10.7 endpoint is the natural foundation
for the v0.3+ work — TUF / on-chain anchoring would sign the
response shape pinned in §10.7. v0.3+ candidate.
Q2: Replay-resistance and request-id binding. RESOLVED in v0.4.
§N.1 and §N.3 require account_scope, request_id, monotonic
attempt_n, provider_id, provider_receipt_key_id, and
route_snapshot_digest binding. Replay onto a different account,
request, attempt, provider, key, route snapshot, or terminal state
maps to quarantined.
Historical note: before v0.4 the receipt did
NOT bind request_id. A malicious replay of the response body to a
different buyer would yield the same output_hash for the same
prompt. Should the receipt commit to request_id or a buyer-supplied
nonce? If so, where does the buyer obtain its expected request_id?
v0.2 §10.6 (trust boundary) named replay-resistance as explicitly
NOT proven by a valid result.
Q3: Cross-provider routing. Once Cluster F sharding lands, a single response may span multiple provider segments. Receipt-per- segment with a buyer-side concatenation rule, or receipt-per-response with an embedded route list signed by an aggregating coordinator? v0.4+ candidate.
Q4: Timestamp trust. RESOLVED for settlement in v0.4. §N.3 defines
the settlement timestamp/window policy. Provider issued_at_unix_ms
and terminal-state timestamps are checked against the exact
account/request/attempt settlement window; skew must be explicit and
fail closed for positive settlement.
Historical note: unix_ts is provider-reported. Should the
buyer cross-check against the coordinator's response timestamp, and
what skew window is acceptable? Partially addressed in v0.2:
§10.6 names timestamp honesty as explicitly NOT proven by a valid
result; §10.0 step 9 removes the v0.1-sketch optional skew check
to avoid implying timestamp attestation. Full normative skew-check
(buyer-recorded received-at vs unix_ts with operator-set window)
remained open before v0.4.
Q5: Streaming receipt delivery mechanism. RESOLVED for settlement in
v0.4. §N.5 chooses coordinator-internal streaming receipt delivery:
provider terminal frame, post-stream internal receipt submission, or
another SDK-safe internal channel. Buyer retrieval is optional and not
a settlement dependency. The client-facing SSE stream remains
OpenAI-compatible and does not require receipt-only non-standard
data: events.
Historical note: v0.1's terminal
event: receipt SSE block was rejected in the round-1 audit (C1)
because the OpenAI Python and JavaScript SDKs JSON-parse every
non-[DONE] data: payload and would raise on a base64 receipt
string. v0.1.1's X-MacProvider-Receipt-Pending correlator header
was rejected in the round-2 audit (C1) because it added a second
buyer-visible X-MacProvider-* response header outside the single
SPEC-006 v0.9 candidate allowlist annotation. v0.1.2 therefore drops
streaming receipt delivery entirely.
Earlier candidates were:
(a) An OpenAI-shape extra field on the final chat-completion chunk
(e.g. x_macprovider_receipt on the last data: {...} payload).
Requires verifying that both SDKs' Pydantic / zod parsers
tolerate the extra field across pinned versions.
(b) A separate GET /v1/receipts/<request_id> endpoint on the
gateway, with a clearly-bounded retention contract and
buyer-correlator delivery via an SPEC-006 v0.x candidate
response header annotation.
(c) An HTTP trailer when the buyer SDK supports it (rare today).
(d) Acceptance that streaming requests never carry receipts — the
buyer who needs a receipt issues a non-streaming equivalent.
v0.4 leaves the envelope format §3.4 unchanged but moves streaming delivery to the internal settlement channel described by §N.5.
Q6: Model-hash binding (SPEC-011 cross-cut). RESOLVED in v0.3.
Folding heartbeat.model_hash (SPEC-011 v0.5 §3.3.1) into the
receipt tuple is the v0.3 §M work. v0.3 extends the tuple from 7
fields to 9 (adding model_hash and receipt_version), pins the
provenance semantics (§M.2), defines a catalog-verifier
extension (§M.3), and adds a /poolz catalog surface (§M.4).
This Q6 closure is ORTHOGONAL to coordinator-side enforcement.
Tier2Config.RequireHashVerified remains at its false default
per beta/DECISION_CRITERIA.md 2026-06-22 Entry 80; v0.3 makes
the receipt BIND the hash, but the operator-side decision to
REJECT providers on hash mismatch is independent and unchanged.
The Entry 80 deferral triggers (pool size growth, catalog
pipeline ergonomics, buyer demand) still gate that flip;
v0.3 receipts are usable by buyers who want catalog-match
attestation REGARDLESS of how the operator routes.
Q7: Multi-hash receipts for swap-spanning streaming responses. CLOSED
AS OUT OF SCOPE for v0.4 settlement.
v0.3 §M.2.2 NORMATIVELY REFUSES the shape of a single receipt
binding two model_hash values for one response. v0.4+ may
introduce a multi-hash receipt to represent legitimately
swap-spanning streaming responses (Q5 streaming + a swap mid-
stream). v0.4 settlement continues the one-request-attempt,
request-start model-hash rule from §N.2 and SPEC-022. Future design
questions remain: should a later receipt profile commit
to (first_hash, last_hash) or to (hash_per_chunk_range)?
How does the verifier compose multiple catalog lookups under
a single tuple? Is the wire-shape extension a new
receipt_version: "5" or a separate multi-segment profile?
Q8: Compute-integrity request-start state digest binding. RESOLVED for
v0.4 by #1010. §N.10.1 keeps SPEC-036 request-start state digests outside
the v0.4 signed tuple and strict usage object. The accepted compatible path is
a separate SPEC-036 audit artifact keyed to the same request attempt and digest
of the provider-signed SPEC-015 tuple. A future SPEC-015 successor MAY reference
that artifact digest only through a new receipt_version; v0.4 MUST NOT grow
optional compute-integrity fields. External v0.4 verifiers continue to reject
extra fields and to classify unknown future versions as inconclusive.
The README §"Roadmap" block at lines 117–128 sketches a v1 receipt schema. SPEC-015 v0.1.1 changes several field names and conventions relative to that sketch. The differences are deliberate; the audit M8 finding required explicit per-field justification.
| README sketch field | SPEC-015 v0.1.1 field | Change | Why |
|---|---|---|---|
model |
model_id |
Renamed | Matches SPEC-001 v1.5 §6.4 and SPEC-002 v1.3.5 naming; model_id is the canonical identifier in the rest of the corpus. |
prompt_hash: "sha256:7c3f..." |
prompt_hash: "<64 lowercase hex>" |
Prefix stripped | The receipt only ever uses sha256; embedding the algorithm name doubles the payload and invites parser ambiguity. Verifiers know the algorithm from the SPEC version. |
output_hash: "sha256:9b2a..." |
output_hash: "<64 lowercase hex>" |
Prefix stripped | Same as prompt_hash. |
provider_id: "m1-anon" |
(NOT in tuple; in /poolz only) |
Field removed from receipt | The cryptographic identity is the pubkey; provider_id is an operator-mutable label and is intentionally out-of-band via /poolz. See §3.1 "Why provider_id is NOT in the tuple". |
provider_pubkey: "ed25519:..." |
provider_pubkey: "<44-char base64>" |
Algorithm prefix stripped | Same reasoning as the hash prefixes; v0.1.1 pins ed25519. Algorithm agility is v0.x+. |
ttft_ms: 646 |
ttft_ms: <int64> |
Unchanged semantics | Pinned as int64. |
tokens_out: 142 |
tokens_out: <int64> |
Unchanged semantics | Reused from SPEC-005 §4 effective_completion_tokens. |
ts: "2026-06-04T12:34:56Z" |
unix_ts: <int64 Unix seconds UTC> |
Renamed + integerized | RFC3339 strings introduce a canonicalization surface (decimal subseconds, timezone offsets, separator characters) that doesn't add value; integer Unix seconds is unambiguous. |
sig: "ed25519:..." |
(transported as the post-. segment of the X-MacProvider-Receipt header value, not as a tuple field) |
Moved out of tuple, prefix stripped | The signature MUST NOT be inside the signed payload. v0.1.1's <base64-tuple>.<base64-sig> envelope keeps the two cleanly separated. |
| "issued by the gateway" (README §"Roadmap" prose) | Issued by the PROVIDER | Architectural change | The gateway does not know the provider's private key, by design. Provider-side signing is what makes the receipt verifiable against /poolz's receipt_pubkey without trusting the operator. The README will be updated when v0.1.1 lands to reflect provider-side issuance. |
- README.md:22 — the verifiable-inference vapor claim this SPEC closes.
- README.md:117–128 — the v1 receipt schema sketch (compatibility table above explains each deviation).
audits/2026-06-10/REPO_AUDIT.md— Open Question 1 (receipts unimplemented) the audit raised.beta/DECISION_CRITERIA.mdEntries 79–81 — operator context for the 2-person beta posture in which v0.1 ships.- SPEC-001 v1.5 §6.7 — v2
auth_requesthandshake, which v0.1 annotates with theprovider_receipt_public_keyfield. - SPEC-002 v1.3.5 §7 —
/poolzshape, which v0.1 annotates withreceipt_pubkey. - SPEC-005 v0.3 §3 X-1 row — null-usage settlement, which v0.1's §7.6 receipt for null-usage errors composes with.
- SPEC-005 v0.3 §4 —
effective_completion_tokensderivation, whichtokens_outreuses. - SPEC-006 v0.8.3 §17 — header allowlist; SPEC-015 v0.1 adds
X-MacProvider-Receiptto the response pass-through allowlist as a SPEC-006 v0.9 candidate. - SPEC-008 v0.3 §5.3, §6 — Pillar A model-hash and Pillar B encrypted-leg semantics; v0.1 is orthogonal to both.
- SPEC-011 v0.5 §3.2 (warm-swap state machine), §3.3
(heartbeat extension, R-3.3.0 opt-in gating, R-3.3.1 raw
64-hex format), §3.4 (drain semantics, R-3.4.1 in-flight
tracking, R-3.4.2 drain timeout) —
model_hashheartbeat and warm-swap drain; v0.1's §7.4 invariant relies on §3.4 drain semantics. (v0.1.3 originally cited "§3.3.1 and §3.8"; v0.3 audit-round-1 A5 normalized this to §3.2/§3.3/§3.4 which are the sections v0.3 §M.2 actually rests on.) - SPEC-013 v0.3 —
autotunesubcommand; this SPEC reusesRFC8785JCS.swiftfrom SPEC-013's implementation. - RFC 8785 — JSON Canonicalization Scheme.
- RFC 8032 — EdDSA / ed25519.
phase3-binary/Sources/macprovider-cli/RFC8785JCS.swift— in-house JCS implementation.
v0.3 additional references:
scripts/sign-catalog.go(line 31 —sha256field name; line 42-49 — canonical-body key order; line 145 — RawURLEncoding signature) — the catalog signing tool; v0.3 verifier's catalog parse + verify path consumes the output of this tool.phase4-coordinator/internal/tier2/catalog.go(line 22 — 64-hex regex; line 45 —ParsedCatalog.CatalogID; line 64 —catalogFileschema; line 164-237 — catalog reload/swap semantics) — the existing catalog parser/verifier; v0.3 re-implements the parse + verify path in pure Go inphase7-verify/rather than importing this package.phase4-coordinator/internal/config/config.golines 142, 335 — current state ofTier2Config.RequireHashVerified(default false, observation mode); v0.3 §M.6 #1 preserves this default unchanged.beta/DECISION_CRITERIA.mdEntry 80 (2026-06-22) — operator ruling onRequireHashVerifieddeferral; v0.3 §M.6 #1 inherits this deferral verbatim.- SPEC-008 v0.3 §5.3-5.6 — Pillar A model-hash semantics + the five-state HashStatus enum the coordinator uses; v0.3 verifier's catalog-check path is a buyer-side mirror of the coordinator-side check, NOT a replacement for it.
- SPEC-010 v1.5 —
supported_models[]/publishes_supported_modelssemantics; informs which providers participate in hash attestation orthogonal to v0.3 receipt issuance (a provider may serve a model it has not declared insupported_models; the receipt'smodel_hashstill binds whatever container is loaded). - SPEC-011 v0.5 §3.2 — warm-swap state machine
(
ready/loading/draining/readyper §3.2 transitions); v0.3 §M.2.2 enforces the mid-swap refusal via this state. - SPEC-011 v0.5 §3.3 (heartbeat extension) and R-3.3.0
(opt-in gating:
--enable-warm-swap=trueis the precondition formodel_hashreporting); v0.3 §M.2.3 inherits the opt-in nature. - SPEC-011 v0.5 §3.4 drain semantics (R-3.4.1 in-flight tracking, R-3.4.2 drain timeout); v0.3 §M.2.2 enforceability rests on these rules.
- Live infrastructure —
coordinator.malibu.tech/healthz+ Pearl journaldmodel_hash_verifiedevents; v0.3 composes on this production observation surface. specs/BUILD_SPEC_015_RECEIPTS_v0_3_MODELHASH_PROMPT.md— the spec-writing brief that authored v0.3.specs/BUILD_SPEC_015_v0_3_MODELHASH_IMPL_PROMPT.md— the staged implementation brief for the next session.