Corobate

For Technicians

Technical audit & integration guide

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.

Contents

  1. Threat model — what this does and does not defend against
  2. The audit: five checks, in dependency order
  3. Canonical encoding specification
  4. Gating semantics and determinism
  5. Known limitations, stated plainly
  6. Integration architectures
  7. Hooking into a live SCM application — step by step
  8. Field maps: integration as configuration, not code
  9. Operational concerns: retention, keys, performance, failure modes
  10. Pre-production checklist

01Threat model

Start here. An attestation layer that doesn't state its threat model isn't auditable.

AdversaryCapability assumedDefended?Mechanism
Operator, retroactivelyFull write access to the receipt store after the factYesHash 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 timeChooses which inputs to submit and how to classify themPartiallyClasses 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 binaryYesVerification 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+E000U+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 sourceSupplies a wrong-but-well-formed valueNo — by designCorobate 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 pipelineEmits fluent, confident, fabricated contentYes, structurallyModel 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 adversaryBlocks or degrades a data sourceYesFreshness-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.
The one-sentence version Corobate is a provenance and reproducibility layer, not a truth oracle. It makes the shape of your evidence unforgeable and independently checkable; it does not make weak evidence strong, and it is explicitly designed to refuse to make weak evidence look strong.

02The audit: five checks, in dependency order

This is exactly what the browser verifier at /verify executes. Run it on a receipt you did not generate.

#CheckOperationPasses when
1Content integrityStrip receipt_hash, chain, anchor, _trust_anchors_pem; canonicalize the remainder; SHA-256Result equals the stated receipt_hash
2Chain linkageSHA-256( prev_entry_hash ‖ content_digest ), genesis prev = 64 zero hex charsResult equals chain.entry_hash
3Gate re-executionRecompute min() (or the recorded aggregation) over the critical subset from the recorded inputsRecomputed confidence and bound_by match the record
3bVerdict ruleApply the recorded thresholds to the recomputed confidenceRecomputed verdict matches the stated verdict
4HorizonAssert every input's observed_atdata_horizonNo input post-dates the sealed cutoff
5Anchor coverageConfirm the RFC 3161 token's message imprint binds chain.entry_hashImprint equals the chain head
5bToken signatureValidate the TSA's CMS signature and certificate chainInformational. 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')
Auditor's trap to check for Reimplement the canonicalizer in a different language before you sign off. The single most common defect in systems of this kind is numeric serialization drift: an integer-valued float that one runtime emits as 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.

03Canonical encoding specification

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:

Encoding form<typeTag> ":" <utf8ByteLength> ":" <literal>
PropertyRequirementWhy
InjectiveNo two distinct input sets produce identical canonical bytesWithout it, a receipt hash commits to an ambiguous preimage — you can prove the bytes, but not what they meant
Length-prefixedByte length precedes every literalA value containing the delimiter cannot be arranged to imitate a different field structure
Type-taggedString "7" and number 7 encode differentlyPrevents type-confusion collisions
Duplicate-rejectingA repeated field name is an error, not a last-wins overwriteParser-differential attacks: two parsers disagreeing about which value counts
Integer minor unitsReliability is carried as integer basis points (0–10000) on the attestation pathWhere 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-orderedObject keys sorted by code pointMap 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.

04Gating semantics and determinism

ParameterDefaultNotes
Provenance tiersVERIFIED 9500 · MODELED 8000 · SELLER-ASSERTED 5000 · MISSING 0 (bp)Configurable downward per domain; an input may never exceed its class ceiling
Stale cap4000 bp (0.40)Applied when age > max_age(field); a named reason is sealed
AggregationminimumAlternatives: product, soft-min. All monotone non-increasing in each argument
Thresholdsapprove 0.80 · caution 0.50Per-decision, sealed into the receipt before the verdict
Point-in-timecfg.period_endInputs with observed_at > period_end are rejected, not down-weighted
Market kindsaction · model · passportDifferent WITHHOLD rules; e.g. an action decision withholds on any absent critical input regardless of the others
Engine versionSealed into every entryOld 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 }

05Known limitations, stated plainly

If these aren't in the document, the document isn't an audit aid.

LimitationCurrent stateWhat it means for you
RFC 3161 token validation depthThe 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 verifierCheck 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 truthNot attemptedA verified input can still be false. You get attribution and reproducibility, not correctness.
Policy captureThe critical-field set and thresholds are the operator'sA 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 coverageDepends on the issuing body exposing a queryable registerWhere 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 powerFDR control over a lifetime hypothesis set is conservative by constructionGenuine weak signals will fail to promote for a long time. That is the intended trade: no promotion is safer than a noise promotion.

06Integration architectures

PatternWhere the engine runsData egressBest for
Embedded SDK (default)In-process inside your applicationNone. No input value leaves your networkRegulated environments, ERP/SCM add-ins, anything where data residency is a blocker
Sidecar serviceA container alongside your app, same trust boundaryNone beyond your perimeterPolyglot estates; a Java SCM calling a Node engine over localhost
Batch attestationScheduled job over a decision tableNoneRetro-attesting an existing decision history; nightly supplier scorecards
HostedOur infrastructureYes — inputs transitPilots 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.

07Hooking into a live SCM application

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.

1

Pick the decision boundary

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.

2

Enumerate the fields that gate that decision

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 }
  }
}
3

Write the field map — do not write adapters

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.

4

Install the SDK and pin the engine version

npm install @corobate/sdk        # or the sidecar image
export COROBATE_DATA_DIR=/var/lib/corobate/data   # defaults to ~/.corobate/data
5

Call the engine at the gate — synchronously, before the approval commits

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 }
  }
}
6

Make the verdict do work in the workflow

A receipt nobody acts on is a log line. Wire each verdict to a distinct workflow state so the distinction is operational, not advisory.

VerdictSCM workflow stateAutomated side effect
approveReleasedReceipt attached to the PO record; hash written to the supplier's evidence index
cautionReleased — conditionalCondition set generated from bound_by: holdback percentage, added inspection, or a contract clause template
withheldHeld — evidence requestedEvidence request auto-drafted from coverage_gaps, referencing the receipt hash, with an SLA timer; supplier portal task created
7

Anchor on a schedule, not per receipt

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.

8

Expose verification outward

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.

9

Turn on the backtest after one full outcome cycle

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.

08Field maps: integration as configuration

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.

Government and registry sources A source on the trusted-registry list — a national register, a regulator's public dataset, an accreditation body's register — normalizes directly to VERIFIED without a per-field resolve step, because the fetch itself is the resolution. The URL and fetch time are sealed so the reviewer can repeat it.

09Operational concerns

ConcernGuidance
StorageReceipts 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.
RetentionAlign 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 managementThere 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.
PerformanceGating 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.
ClockYour 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 modesEngine 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.
MigrationRetro-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.

10Pre-production checklist

Item
1Independent verifier reimplemented in a second language; parity vectors pass; the corrupted negative control fails
2A receipt generated on one architecture verifies on a different one, running none of the issuing code
3Tamper test: flip one byte in a stored receipt; confirm check 1 fails and every downstream chain link breaks
4Backdate test: insert an input dated after period_end; confirm rejection, not down-weighting
5Outage test: block a source; confirm a named OUTAGE abstention appears and reproduces from the sealed entry
6Hallucination test: submit an AI-supplied input citing a non-existent certificate; confirm it cannot reach VERIFIED and the unresolved citation is named
7Fail-closed test: kill the engine mid-workflow; confirm the gate holds rather than releases
8Anchor test: verify the TSA token's imprint binds the chain head, in your environment, against your trust store
9Policy review: confirm the critical-field set reflects sufficiency, not wishful completeness
10Retention and export: confirm a receipt can be handed to an outside party who can verify it with no access to your systems
Request an integration → Run the reference implementation → The conceptual version →