Decision Provenance — Chain Payload v2
Normative spec for BrainstormRouter's provenance commitment: the deepCanonicalJson encoder, the provenanceDigest struct, the chain payload v2 version bump, per-row verify dispatch, and reproducible test vectors so an external verifier computes the identical digest BR commits.
Status: Draft 1 · published 2026-06-20 · canonical URL https://docs.brainstormrouter.com/specs/decision-provenance-v1
Authors: BrainstormRouter License: CC BY 4.0 (specification)
This specification is normative. The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used per RFC 2119.
---
1. Abstract
BrainstormRouter's completion audit trail is a per-tenant hash chain of completion_audit rows. Chain payload v2 adds exactly one hashed field, provenanceDigest, that commits the WHY of a decision — the deny verdict, the W3C trace identity, the causal edge provenance, and a digest of the durable decision-lineage row — into the chain that already proves the WHAT. This spec defines the deepCanonicalJson byte scheme the digest covers, the commitment struct, the version-bump procedure, the per-row verify dispatch, and reproducible test vectors. A third-party verifier following this spec MUST compute the identical provenanceDigest BR commits.
2. Chain payload versioning
Each completion_audit row carries chain_schema_version smallint NOT NULL DEFAULT 1.
- v1 — the legacy flat payload of canonical fields. A v1 row MUST verify byte-
identically forever; the v2 builder MUST NOT alter v1 bytes.
- v2 — the v1 payload PLUS a single flat top-level field
provenanceDigest(a
64-character lowercase hex string). The payload remains flat: provenanceDigest is a hex string, never nested content.
A verifier MUST dispatch the payload builder by the row's chain_schema_version. Mixed- version chains (v1 and v2 rows interleaved) are valid and MUST verify. The lower-edge continuity probe is version-agnostic and unchanged.
3. deepCanonicalJson — the byte scheme
provenanceDigest is computed over the canonical bytes produced by deepCanonicalJson, an RFC 8785-style deterministic JSON encoder:
- Object keys are sorted lexicographically by UTF-16 code unit, recursively, at
every depth.
- Arrays preserve element order; each element is canonicalized recursively.
- Numbers are normalized to a fixed decimal scale of 6 fractional digits
(Number(n.toFixed(6))), which drops trailing zeros and collapses -0 to 0. Integers are emitted as-is. This mirrors BR's numeric(.,6) persistence so a value that round-trips through a jsonb INSERT→SELECT digests identically on both sides.
null, booleans, strings follow standard JSON encoding.NaN/±Infinityare not
valid JSON numbers and are encoded as null (matching JSON.stringify).
- No insignificant whitespace is emitted.
The digest is sha256hex(deepCanonicalJson(value)), lowercase hex.
The repo's pre-existing shallow canonicalize (a top-level-keys-only replacer) MUST NOT be handed nested content — it serializes nested objects as {}. Only deepCanonicalJson is used for the provenance digest, and only for digesting. This is why a single mutation of any nested field flips the digest.
4. The commitment struct
The value digested for provenanceDigest is the following struct (field semantics):
| Field | Type | Notes | ||
|---|---|---|---|---|
v | integer | Commitment struct version. Currently 1. | ||
decision_class | string | "route" \ | "deny" \ | "would_deny". |
policy_context_digest | string \ | null | sha256hex(deepCanonicalJson(policy_context)), or null for allows. | |
trace_id | string \ | null | 32-hex W3C trace id, or null. | |
span_id | string \ | null | 16-hex BR span id (BR's span always exists when traced), or null. | |
parent_span_id | string \ | null | Caller/parent span, or null. | |
edge_provenance | string | "server_minted" \ | "caller_asserted" \ | "none". |
trace_inbound_trusted | boolean | Committed — cannot be flipped post-write. | ||
caused_by_request_id | string \ | null | Internal causal parent (swarm/agentic), or null. | |
lineage_digest | string \ | null | sha256hex(deepCanonicalJson(lineage.decision)), or null. | |
request_fingerprint | string \ | null | HMAC-SHA256(tenant key, body) hex (oracle-resistant), or null. |
Insertion order is documentation only — deepCanonicalJson sorts keys. Every field is also stored as a plain non-hashed column for queryability; the commitment makes a post-hoc edit of any of them chain-detectable.
A row captured with the feature on but nothing to record (sampled out, off-mode trace) MUST still commit an honest struct — null ids, null lineage digest, edge_provenance: "none". "Nothing was captured" is itself committed, so an on-era row can never silently claim absent provenance.
5. Test vectors
The following vectors are reproducible. A verifier MUST produce the identical canonical bytes and provenanceDigest for each. All digests are SHA-256, lowercase hex.
5.1 Vector A — empty / off-mode commitment
A routed allow with nothing captured (the honest default-off commitment).
struct = {
"v": 1,
"decision_class": "route",
"policy_context_digest": null,
"trace_id": null,
"span_id": null,
"parent_span_id": null,
"edge_provenance": "none",
"trace_inbound_trusted": false,
"caused_by_request_id": null,
"lineage_digest": null,
"request_fingerprint": null
}
canonical = {"caused_by_request_id":null,"decision_class":"route","edge_provenance":"none","lineage_digest":null,"parent_span_id":null,"policy_context_digest":null,"request_fingerprint":null,"span_id":null,"trace_id":null,"trace_inbound_trusted":false,"v":1}
provenanceDigest = 0f19a14cd4afac7434c70277bcae3af3f3ffcf10dd7190c86cc81f378dfaa970
5.2 Vector B — denied commitment with trace + lineage
A denial that committed a policy_context digest, a caller-asserted trace edge, a lineage-row digest, and an HMAC body fingerprint. The component digests are:
policy_context = {"reason_code":"scope_denied","deny_rule":"no-gpt4","mode":"enforce"}
policy_context_digest = 37ec3b1c14139ec186159c27783abe59dd21e410ce8c5ab41cafb67f2ee3f624
lineage.decision = {"v":1,"strategy":"quality","governance":{"rules_applied":2}}
lineage_digest = 0760595da46b4cd098c8576b5b6486f1ee676f9d6bc18ac76761fb3a70185e09
request_fingerprint = 3a8faf46b25a412061530b9ca81e2de36026cf8f37d84982cc853e20a77ca13b
(= sha256hex("hmac-placeholder") — a real deployment uses
HMAC-SHA256(tenant key, body))
struct = {
"v": 1,
"decision_class": "deny",
"policy_context_digest": "37ec3b1c14139ec186159c27783abe59dd21e410ce8c5ab41cafb67f2ee3f624",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"span_id": "b7ad6b7169203331",
"parent_span_id": null,
"edge_provenance": "caller_asserted",
"trace_inbound_trusted": false,
"caused_by_request_id": null,
"lineage_digest": "0760595da46b4cd098c8576b5b6486f1ee676f9d6bc18ac76761fb3a70185e09",
"request_fingerprint": "3a8faf46b25a412061530b9ca81e2de36026cf8f37d84982cc853e20a77ca13b"
}
canonical = {"caused_by_request_id":null,"decision_class":"deny","edge_provenance":"caller_asserted","lineage_digest":"0760595da46b4cd098c8576b5b6486f1ee676f9d6bc18ac76761fb3a70185e09","parent_span_id":null,"policy_context_digest":"37ec3b1c14139ec186159c27783abe59dd21e410ce8c5ab41cafb67f2ee3f624","request_fingerprint":"3a8faf46b25a412061530b9ca81e2de36026cf8f37d84982cc853e20a77ca13b","span_id":"b7ad6b7169203331","trace_id":"0af7651916cd43dd8448eb211c80319c","trace_inbound_trusted":false,"v":1}
provenanceDigest = b96591a0fdbe93f955859baff9889c861805cb1b1e6f24b75f69743c4f018e1e
5.3 Negative vector — nested mutation flips the digest
Take Vector B's lineage.decision and change a single nested value (governance.rules_applied: 2 → 3). The recomputed lineage_digest differs, so the recomputed provenanceDigest MUST differ from b96591a0…. A verifier that produces the same digest after a nested mutation is using a shallow encoder and is non-conformant.
6. Verification
Given a v2 completion_audit row and (optionally) its bound decision_lineage row, a verifier:
- Reads the stored non-hashed columns (
policy_context,trace_id,span_id,
parent_span_id, edge_provenance, trace_inbound_trusted, caused_by_request_id, lineage_digest).
- If a lineage row is present, recomputes `lineage_digest = sha256hex(deepCanonicalJson(
lineage.decision))` and asserts it equals the stored/committed value.
- Recomputes the commitment struct and
provenanceDigest, and asserts equality with the
chain-committed value.
Per-row outcomes:
| State | Meaning |
|---|---|
ok | Recomputed digest equals the committed digest. |
redacted | The lineage row is a tombstone AND a matching chained redaction event exists. |
missing_lineage | A non-null lineage_digest was committed but no row exists (in-window). |
expired_by_retention | The committed row aged past the tenant retention horizon (partition dropped). |
sampled_out | The committed lineage_digest is null on a v2 row — provably sampled out. |
failed | Any mismatch — including a tombstone with no matching redaction event. |
The provenance recompute is best-effort and never flips the bare chain result; it is an additional integrity signal layered on top of chain continuity.
7. Migration & compatibility
- The migration is additive: a partitioned
decision_lineagetable, nullable
completion_audit columns, and chain_schema_version DEFAULT 1. No backfill.
- v1 rows verify forever. External verifiers built on the v1 spec MUST add v2 dispatch
using §2 + the vectors in §5 before encountering v2 rows.
- The sign side is gated by
BR_CHAIN_PAYLOAD_V2(default off) and is enabled only after
the verify side is confirmed in production. Per-row versioning makes rollback safe.
Related
- Committed Lineage — the conceptual overview.
- Capability Grant Chain — v1 — the orthogonal authority axis.