For Technicians
What to verify if you're auditing the attestation layer, what the canonical encoding actually specifies, where the honest limitations are — and how to hook Corobate into a supply chain management application that is already in production.
Start here. An attestation layer that doesn't state its threat model isn't auditable.
| Adversary | Capability assumed | Defended? | Mechanism |
|---|---|---|---|
| Operator, retroactively | Full write access to the receipt store after the fact | Yes | Hash chain + external RFC 3161 anchor. Editing entry n invalidates every entry > n. The chain alone does not defeat this adversary — an operator who controls the log chooses the leaves. Only the anchor, against an authority the operator does not control, does. Stated plainly: the public demo's token is self-signed and labelled; anchor against your own trusted TSA before relying on this row. |
| Operator, at issue time | Chooses which inputs to submit and how to classify them | Partially | Classes can be downgraded by the engine but never upgraded; VERIFIED requires a resolvable external reference. Omitting an input entirely is visible as a named coverage gap, not a silent absence — but the policy (which fields are critical) is the operator's, and it is sealed so that changing it is detectable across a population of receipts. |
| Vendor (us) | Controls the engine binary | Yes | Verification requires none of our code. The verifier is ~200 lines of arithmetic; a CPython port is shipped in parity/ and reproduces the JS implementation byte-for-byte across twelve seeded vectors, with a negative control. Read that as a battery, not a proof over all inputs: two open findings record where it breaks. The canonicalizer stringifies numbers with each language's own float formatting, so the two runtimes disagree on values like 0.000001 — every nonzero finite float from 1e-9 up to 1e-4 encodes differently. And row sort order diverges when an astral-plane name meets a name in U+E000–U+FFFF, because JavaScript sorts by UTF-16 code unit and CPython by code point. The seeded vectors are built to keep those cases apart. Fixing either changes every hash ever issued, so it is a versioned break rather than a patch, and neither has been made. |
| Data source | Supplies a wrong-but-well-formed value | No — by design | Corobate attests provenance and process, not ground truth. A verified lab report that is itself fraudulent produces a verified input. What you get is an unforgeable record of who asserted what, when, and under what class — which is what makes the fraud attributable. |
| AI model in the pipeline | Emits fluent, confident, fabricated content | Yes, structurally | Model output is admitted at MODELED and cannot be promoted. Any identifier it cites is submitted for external resolution; unresolved citations are recorded by name and do not contribute to the class. |
| Network adversary | Blocks or degrades a data source | Yes | Freshness-aware cache with circuit breaker. On breaker-open with no valid cache, the source emits a named OUTAGE abstention rather than silently substituting stale data — and the verifier reproduces that abstention from the sealed entry. |
This is exactly what the browser verifier at /verify executes. Run it on a receipt you did not generate.
| # | Check | Operation | Passes when |
|---|---|---|---|
| 1 | Content integrity | Strip receipt_hash, chain, anchor, _trust_anchors_pem; canonicalize the remainder; SHA-256 | Result equals the stated receipt_hash |
| 2 | Chain linkage | SHA-256( prev_entry_hash ‖ content_digest ), genesis prev = 64 zero hex chars | Result equals chain.entry_hash |
| 3 | Gate re-execution | Recompute min() (or the recorded aggregation) over the critical subset from the recorded inputs | Recomputed confidence and bound_by match the record |
| 3b | Verdict rule | Apply the recorded thresholds to the recomputed confidence | Recomputed verdict matches the stated verdict |
| 4 | Horizon | Assert every input's observed_at ≤ data_horizon | No input post-dates the sealed cutoff |
| 5 | Anchor coverage | Confirm the RFC 3161 token's message imprint binds chain.entry_hash | Imprint equals the chain head |
| 5b | Token signature | Validate the TSA's CMS signature and certificate chain | Informational. See limitations |
Checks 1–4 require no network access and no trust in anyone. Check 5 requires only the token bytes already in the receipt. Check 5b is the only one that reaches outside — and it is scoped as informational precisely so that the audit's core is offline-completable.
// The whole of check 1, in Node. No dependencies.
import { createHash } from 'node:crypto'
const jcs = o => Array.isArray(o) ? '[' + o.map(jcs).join(',') + ']'
: (o && typeof o === 'object')
? '{' + Object.keys(o).sort().map(k => JSON.stringify(k) + ':' + jcs(o[k])).join(',') + '}'
: JSON.stringify(o)
const r = JSON.parse(await readFile('receipt.json', 'utf8'))
const { receipt_hash, chain, anchor, _trust_anchors_pem, ...content } = r
const computed = 'sha256:' + createHash('sha256').update(jcs(content), 'utf8').digest('hex')
console.log(computed === receipt_hash ? 'PASS' : 'FAIL')
1.0 and another as 1 produces different bytes and therefore a different hash, while every unit test inside one runtime passes. Corobate addresses this by representing numeric values as integer quantities in fixed minor units (basis points), and ships a CPython parity harness with a deliberately corrupted negative control so the check is demonstrably non-vacuous. Verify the negative control fails.Version identifier attest-canon/2, sealed into every receipt.
Each value is encoded as a type tag, the byte length of its literal form, and the literal, joined by a delimiter:
| Property | Requirement | Why |
|---|---|---|
| Injective | No two distinct input sets produce identical canonical bytes | Without it, a receipt hash commits to an ambiguous preimage — you can prove the bytes, but not what they meant |
| Length-prefixed | Byte length precedes every literal | A value containing the delimiter cannot be arranged to imitate a different field structure |
| Type-tagged | String "7" and number 7 encode differently | Prevents type-confusion collisions |
| Duplicate-rejecting | A repeated field name is an error, not a last-wins overwrite | Parser-differential attacks: two parsers disagreeing about which value counts |
| Integer minor units | Reliability is carried as integer basis points (0–10000) on the attestation path | Where it holds, it removes IEEE-754 formatting divergence. It does not hold everywhere: the canonicalizer stringifies numbers with each language's own float formatting, and the reweight/recalibrate receipts carry rounded floats. That is the open divergence noted in the row above, and the integer-minor-units discipline is the fix, not yet the state. |
| Key-ordered | Object keys sorted by code point | Map iteration order is not a stable property of any runtime |
The browser-facing receipt JSON is additionally RFC 8785 (JCS) canonicalizable, so a verifier can be written with nothing but a sort and JSON.stringify — which is what makes the ~200-line independent verifier possible.
| Parameter | Default | Notes |
|---|---|---|
| Provenance tiers | VERIFIED 9500 · MODELED 8000 · SELLER-ASSERTED 5000 · MISSING 0 (bp) | Configurable downward per domain; an input may never exceed its class ceiling |
| Stale cap | 4000 bp (0.40) | Applied when age > max_age(field); a named reason is sealed |
| Aggregation | minimum | Alternatives: product, soft-min. All monotone non-increasing in each argument |
| Thresholds | approve 0.80 · caution 0.50 | Per-decision, sealed into the receipt before the verdict |
| Point-in-time | cfg.period_end | Inputs with observed_at > period_end are rejected, not down-weighted |
| Market kinds | action · model · passport | Different WITHHOLD rules; e.g. an action decision withholds on any absent critical input regardless of the others |
| Engine version | Sealed into every entry | Old receipts remain reproducible under the version that issued them |
Determinism requirements. The engine must be a pure function of (inputs, policy, engine version). Concretely, that rules out: wall-clock reads during evaluation (the horizon is passed in, not read), map/set iteration order dependence, locale-sensitive formatting, floating-point accumulation in the aggregation path, and any network call inside the scoring path. Source resolution happens before evaluation and its results — including outages — are recorded as inputs.
// verify() re-executes gating over the recorded inputs — it does not trust the stated verdict. const { ok, checks, recomputed } = verify(receipt) // checks: { content, chain, gate, verdict, horizon, anchor } // recomputed: { confidence_bp, bound_by, verdict }
If these aren't in the document, the document isn't an audit aid.
| Limitation | Current state | What it means for you |
|---|---|---|
| RFC 3161 token validation depth | The message imprint binding to the chain head is cryptographically verified. Full CMS signature and certificate-chain validation against a trust store is an operational step performed against a live TSA, not inside the browser verifier | Check 5 proves the token covers this head. Check 5b — is the token genuinely from that authority — should be run in your own environment with your own trust anchors. Treat the in-browser result as informational. |
| Ground truth | Not attempted | A verified input can still be false. You get attribution and reproducibility, not correctness. |
| Policy capture | The critical-field set and thresholds are the operator's | A weak policy produces confidently-sealed weak decisions. The defense is cross-sectional: the policy is sealed on every receipt, so drift across a population is detectable. Audit the policy, not just the receipts. |
| Register resolution coverage | Depends on the issuing body exposing a queryable register | Where no register exists, a cited ID cannot be resolved and the input cannot reach VERIFIED on that basis alone. This is a real coverage gap in some domains and is recorded as one. |
| Backtest statistical power | FDR control over a lifetime hypothesis set is conservative by construction | Genuine weak signals will fail to promote for a long time. That is the intended trade: no promotion is safer than a noise promotion. |
| Pattern | Where the engine runs | Data egress | Best for |
|---|---|---|---|
| Embedded SDK (default) | In-process inside your application | None. No input value leaves your network | Regulated environments, ERP/SCM add-ins, anything where data residency is a blocker |
| Sidecar service | A container alongside your app, same trust boundary | None beyond your perimeter | Polyglot estates; a Java SCM calling a Node engine over localhost |
| Batch attestation | Scheduled job over a decision table | None | Retro-attesting an existing decision history; nightly supplier scorecards |
| Hosted | Our infrastructure | Yes — inputs transit | Pilots and proofs of concept only. We recommend against it for production evidence. |
Only the anchoring step touches the network in the embedded pattern, and it transmits a single hash — the chain head — to the timestamping authority. No field value, no product identifier, no counterparty name.
Assume a functioning supply chain management system: supplier master, PO workflow with an approval step, goods receipt, and a document store. Nine steps, none of which require changing the SCM's data model.
Not "attest everything." Choose the specific gate where a human or an automated rule currently says yes/no and where being wrong is expensive. In an SCM that is usually one of: supplier onboarding approval, PO release above a threshold, goods receipt acceptance, or compliance document sign-off. Start with exactly one.
For each: where it currently comes from, its natural provenance class, its acceptable age, and whether the decision is genuinely insufficient without it. That last column is the one people get wrong — most teams mark far too many fields critical on the first pass, which is why the backtest matters.
// policy/po_release.json — the whole policy is data, versioned in your repo
{
"domain": "scm.po_release",
"weight_version": "wv-2026-01",
"thresholds": { "approve": 0.80, "caution": 0.50 },
"aggregation": "minimum",
"fields": {
"supplier_sanctions_screen": { "critical": true, "max_age_days": 30 },
"quality_cert_current": { "critical": true, "max_age_days": 365 },
"coc_material_origin": { "critical": true, "max_age_days": 180 },
"insurance_certificate": { "critical": true, "max_age_days": 365 },
"financial_health_score": { "critical": false, "max_age_days": 90 },
"prior_defect_rate": { "critical": false, "max_age_days": 180 }
}
}
The adapter layer consumes a declarative field map: source, path, class, and timestamp path. Adding a source is a config change, not a code change, and the field map version is sealed into the receipt so a mapping change is visible in the audit trail. See §08.
npm install @corobate/sdk # or the sidecar image export COROBATE_DATA_DIR=/var/lib/corobate/data # defaults to ~/.corobate/data
This ordering is not stylistic. The receipt must be sealed before the outcome is observed; that is what makes it evidence rather than a summary.
import { attest, verify } from '@corobate/sdk'
import policy from './policy/po_release.json' with { type: 'json' }
async function onPurchaseOrderRelease(po, actor) {
// 1. resolve sources -> inputs (outages become MISSING, with a named reason)
const inputs = await resolveInputs(po.supplier_id, policy)
// 2. seal BEFORE the approval is written
const receipt = await attest({
decision_id : `dcn_po_${po.id}`,
question : `Release PO ${po.id} to supplier ${po.supplier_id}?`,
domain : policy.domain,
actor : `actor:human:${actor.email}`,
period_end : po.decision_time, // point-in-time gate
policy, inputs
})
// 3. persist the receipt alongside the PO, and index by hash
await db.receipts.insert({ po_id: po.id, hash: receipt.receipt_hash, body: receipt })
// 4. branch on the verdict
switch (receipt.verdict) {
case 'APPROVE':
return { action: 'RELEASE', receipt }
case 'CAUTION':
return { action: 'RELEASE_WITH_CONDITIONS',
conditions: conditionsFor(receipt.bound_by), receipt }
case 'WITHHELD':
return { action: 'HOLD',
evidence_request: requestFor(receipt.coverage_gaps), receipt }
}
}
A receipt nobody acts on is a log line. Wire each verdict to a distinct workflow state so the distinction is operational, not advisory.
| Verdict | SCM workflow state | Automated side effect |
|---|---|---|
| approve | Released | Receipt attached to the PO record; hash written to the supplier's evidence index |
| caution | Released — conditional | Condition set generated from bound_by: holdback percentage, added inspection, or a contract clause template |
| withheld | Held — evidence requested | Evidence request auto-drafted from coverage_gaps, referencing the receipt hash, with an SLA timer; supplier portal task created |
Anchoring every receipt individually is wasteful and rate-limited by the TSA. Anchor the chain head periodically — hourly is typical — which covers every receipt written since the last anchor. Anchor immediately for decisions above a materiality threshold.
Publish the receipt with the shipment, the invoice, or the compliance packet. Your customer's auditor should be able to verify it without an account on your system. Media type application/attestation-receipt+json.
Do not tune the policy by intuition in month one. Accumulate sealed decisions, record outcomes as provenance-classed entries, then let the FDR-controlled recalibration adjust weights forward-only. Details on the backtest page.
The zero-code-change path. A field map declares how an upstream payload becomes classed inputs.
// fieldmaps/supplier_master.json { "map_version": "fm-scm-3", "source_id": "actor:sys:sap-supplier-master", "fetch": { "url": "https://erp.internal/api/v2/supplier/{id}/compliance", "allowlist": true }, "fields": { "supplier_sanctions_screen": { "path": "$.screening.status", "class": "VERIFIED", "class_requires": { "resolve": "$.screening.provider_ref" }, "observed_at": "$.screening.checked_at" }, "quality_cert_current": { "path": "$.certs[?(@.type=='ISO9001')].valid_until", "class": "VERIFIED", "class_requires": { "resolve": "$.certs[?(@.type=='ISO9001')].cert_no" }, "observed_at": "$.certs[?(@.type=='ISO9001')].issued_at" }, "coc_material_origin": { "path": "$.declarations.material_origin", "class": "SELLER-ASSERTED", // supplier says so; classed accordingly "observed_at": "$.declarations.submitted_at" } } }
Three properties worth noting. class_requires.resolve means the declared class is conditional — if the referenced identifier does not resolve externally, the input is demoted and the demotion is recorded. observed_at is mandatory; a field with no timestamp cannot be freshness-checked and is treated as stale. And map_version is sealed into every receipt built through it, so a mapping change is a visible event in the audit trail rather than a silent reinterpretation of history.
| Concern | Guidance |
|---|---|
| Storage | Receipts are small — typically 2–8 KB. Store the canonical JSON, not a rendering of it. Index on receipt_hash, decision_id, and chain.entry_hash. Never normalize the body into columns and reconstruct it later; you will lose byte fidelity and with it the whole property. |
| Retention | Align to the longest applicable obligation, not the shortest. For EU AI Act Article 12 record-keeping and product-passport regimes, plan on the product lifetime plus the limitation period. Receipts compress well and deduplicate poorly — budget for the full set. |
| Key management | There are no signing keys to manage in the core path. Integrity comes from hashing plus the external anchor, deliberately — key compromise is the most common failure mode of signature-based audit trails, and this design has no key to compromise. If you add your own signature layer, treat it as additive, not load-bearing. |
| Performance | Gating and sealing are microseconds. The cost is source resolution. Resolve in parallel, cache with explicit freshness windows, and never let a slow source silently degrade to stale — the circuit breaker exists to make that failure loud. |
| Clock | Your clock determines period_end and is therefore part of the policy, not part of the proof. The proof of when comes from the TSA. Do not conflate them; do keep NTP disciplined anyway, since a wrong period_end produces a correct receipt about the wrong window. |
| Failure modes | Engine unavailable → the gate must fail closed into HOLD, never fail open into release. TSA unavailable → seal and chain normally, queue the anchor, and record the anchor as pending; the receipt is still content- and chain-verifiable, and the anchor lands on the next successful call covering all receipts since. |
| Migration | Retro-attesting historical decisions is legitimate only if the receipt is honest about it: set period_end to the original decision time, admit only evidence that provably existed then, and expect most historical decisions to come back WITHHELD because the contemporaneous evidence was never captured. That result is informative, not a failure of the tool. |
| ✓ | Item |
|---|---|
| 1 | Independent verifier reimplemented in a second language; parity vectors pass; the corrupted negative control fails |
| 2 | A receipt generated on one architecture verifies on a different one, running none of the issuing code |
| 3 | Tamper test: flip one byte in a stored receipt; confirm check 1 fails and every downstream chain link breaks |
| 4 | Backdate test: insert an input dated after period_end; confirm rejection, not down-weighting |
| 5 | Outage test: block a source; confirm a named OUTAGE abstention appears and reproduces from the sealed entry |
| 6 | Hallucination test: submit an AI-supplied input citing a non-existent certificate; confirm it cannot reach VERIFIED and the unresolved citation is named |
| 7 | Fail-closed test: kill the engine mid-workflow; confirm the gate holds rather than releases |
| 8 | Anchor test: verify the TSA token's imprint binds the chain head, in your environment, against your trust store |
| 9 | Policy review: confirm the critical-field set reflects sufficiency, not wishful completeness |
| 10 | Retention and export: confirm a receipt can be handed to an outside party who can verify it with no access to your systems |