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:

  1. Object keys are sorted lexicographically by UTF-16 code unit, recursively, at

every depth.

  1. Arrays preserve element order; each element is canonicalized recursively.
  2. 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 INSERTSELECT digests identically on both sides.

  1. null, booleans, strings follow standard JSON encoding. NaN/±Infinity are not

valid JSON numbers and are encoded as null (matching JSON.stringify).

  1. 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):

FieldTypeNotes
vintegerCommitment struct version. Currently 1.
decision_classstring"route" \"deny" \"would_deny".
policy_context_digeststring \nullsha256hex(deepCanonicalJson(policy_context)), or null for allows.
trace_idstring \null32-hex W3C trace id, or null.
span_idstring \null16-hex BR span id (BR's span always exists when traced), or null.
parent_span_idstring \nullCaller/parent span, or null.
edge_provenancestring"server_minted" \"caller_asserted" \"none".
trace_inbound_trustedbooleanCommitted — cannot be flipped post-write.
caused_by_request_idstring \nullInternal causal parent (swarm/agentic), or null.
lineage_digeststring \nullsha256hex(deepCanonicalJson(lineage.decision)), or null.
request_fingerprintstring \nullHMAC-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: 23). 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:

  1. 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).

  1. If a lineage row is present, recomputes `lineage_digest = sha256hex(deepCanonicalJson(

lineage.decision))` and asserts it equals the stored/committed value.

  1. Recomputes the commitment struct and provenanceDigest, and asserts equality with the

chain-committed value.

Per-row outcomes:

StateMeaning
okRecomputed digest equals the committed digest.
redactedThe lineage row is a tombstone AND a matching chained redaction event exists.
missing_lineageA non-null lineage_digest was committed but no row exists (in-window).
expired_by_retentionThe committed row aged past the tenant retention horizon (partition dropped).
sampled_outThe committed lineage_digest is null on a v2 row — provably sampled out.
failedAny 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_lineage table, 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