A journal export can remain balanced after someone changes a beneficiary. Recomputing its hashes will produce a consistent new chain. The change becomes detectable when that new chain is compared with an independently retained old commitment.
This tutorial makes that comparison explicit. It first validates a full accounting history, then hashes exact whole-group bytes, then binds the result to expected scope, journal head, group count and digest. Missing expected evidence remains indeterminate.
Preserve the accounting story
Cedar's synthetic ledger begins with 100000 USD minor units: cash debit and Ada deposit credit. A mistaken 2500 transfer debits Ada and credits Ben. Later, an atomic correction reverses that exact transfer and replaces it with a 2400 transfer to Cora.
The final cash balance is still 100000. Ada's credit-normal deposit is 97600, Ben's is 0 and Cora's is 2400. The original transfer remains in history. Its exact reversal and replacement are added together; neither is allowed to become independently visible.
There are four journals but three committed groups. Group 1 is funding, group 2 is the wrong transfer and group 3 contains journal sequences 3 and 4. A hash node uses the group index, while coverage separately retains the journal head. Treating both counters as interchangeable would obscure correction completeness.
The history validator checks strict commands, balance, chart scope, identities, contiguous sequences, clocks and correction links. A closed current account remains valid historical evidence. These checks establish bounded structural/accounting consistency. They cannot prove that the supplied beneficiary was economically intended.
Define bytes before hashing
SHA256 maps bytes to a 256-bit digest. The selected implementation calls Python hashlib or native WebCrypto, rather than reimplementing the compression function. NIST's current FIPS 180-4 page identifies the August 2015 final publication and a March 2023 revision planning note; the note is not a withdrawal. NIST publication status, FIPS 180-4
That standard does not decide what a journal means or which fields we commit. Cedar's authored frame does. Each string becomes four unsigned big-endian length octets followed by its UTF-8 bytes. Empty text becomes 00000000; é becomes 00000002c3a9; visually similar e plus combining accent becomes 0000000365cc81. No normalization makes those equal.
G starts with D30-COMMITTED-GROUP-V1 and a zero octet. It includes stream and group scope, observer availability, every strict journal field, ordered legs, journal sequence and recording clock, adjustment text, and an explicit correction marker/link. Unknown fields reject; nothing is silently left outside the commitment.
The correction's two recording timestamps are both retained. Its group available_at is the trusted observer's receipt after the entire earlier write committed. It is not a physical COMMIT timestamp inferred from a function return or recording clock.
JSON property delivery order has no effect because framing reads named fields in the frozen order. Reordering the leg array changes semantic bytes. Hashing a convenient pretty-printed JSON string would introduce a different, undocumented contract.
Build a scoped genesis and three nodes
The genesis is not an all-zero placeholder. It hashes a separate genesis tag plus the length-prefixed stream, ledger and entity. For the canonical scope it is:
0556493a8c0203ca969f4005d8007cebbe1594dab31fafebec95d30270db5bfc
Each next head commits the node tag, raw previous 32-byte digest, one-based group index, raw group byte length and G. The diagnostic group_sha 256 is useful for inspection, but the node does not replace G with that diagnostic.
| Group | Journal sequences | G byte length | Chain head prefix for display |
|---|---|---|---|
| Funding | 1 | 408 | be2ef02b… |
| Wrong transfer | 2 | 424 | ecfdac65… |
| Atomic correction | 3–4 | 829 | 8af37be6… |
These prefixes are display labels only. The actual comparison uses all 32 digest bytes. The final lowercase hexadecimal head is:
8af37be6b7835169129ae57b561d98c0fe36e231921c76029f5aff8c9540beea
The complete result includes every G byte, intermediate head and sequence boundary. Independent literal vectors preceded the core implementation. Comparing exact bytes catches shared framing mistakes that a language-agreement test alone might miss.
Compare the retained commitment
The request names stream, ledger, entity and verification_at. Context supplies an independently captured source head and an independently retained expected commitment. The word independently is a host trust obligation: the kernel cannot authenticate the meaning of an opaque receipt_ref.
The retained commitment names the same scope, journal head 4, group count 3 and final digest. Exact match yields CHAIN_MATCHES_INDEPENDENT_COMMITMENT. A missing expectation still permits local calculation but returns EXPECTED_COMMITMENT_MISSING. A future expectation is not used before available_at and its future payload does not appear in mismatch diagnostics.
Now change a financially balanced transfer or reorder its valid legs. The history can still pass accounting validation. The chain changes, and binding fails with chain_head in mismatched_fields. That failure says the candidate differs from the retained commitment. It does not automatically identify a legitimate correction, malicious actor or economically correct beneficiary.
If the same actor replaces both history and the alleged retained expectation, the inputs can match again. Source authentication, signer/key trust and external retention are separate topics precisely because hashes alone do not remove that authority boundary.
Distinguish a prefix from a missing tail
An empty complete source has head 0 and the scoped genesis. A declared historical prefix can also be checked against an independently retained prefix commitment. It cannot certify a newer tail.
Two truncation cases differ. If delivered journals stop at 2 while the independent source head still says 4, source validation is indeterminate INCOMPLETE_EXPORT; no partial commitment is returned. If the candidate is explicitly declared complete through 2 but the retained expectation says 4, the source prefix can validate locally and binding fails on head, count and digest. Reducing the candidate's own declared head cannot make the retained newer expectation disappear.
This profile requires all supplied whole groups visible at verification_at. It validates delivered history before applying availability checks and does not silently filter a current source into an old prefix. Use a separately declared historical head for historical verification.
Use the same kernel in the lab
Call Python verify_hash_chain(request, groups, accounts, context) or TypeScript await verifyHashChain(...). TypeScript uses native WebCrypto in a compatible secure browser or Node runtime. An unavailable primitive produces indeterminate CRYPTO_UNAVAILABLE, with no partial commitment; unexpected programming errors remain visible.
result = verify_hash_chain(request, groups, accounts, context)
assert result["checks"]["history"]["status"] == "pass"
assert result["commitment"]["group_count"] == "3"
Open the guided chain lab. Step through genesis and all three groups, inspect the two journals that share the correction node, and expand the exact byte view. Switch to a balanced rewrite, leg-order change, truncated prefix or missing/future expectation. The lab executes the canonical kernel; Back and Reset reconstruct teaching views without changing a real ledger.
The full profile caps groups at 4096, journal head at 8192 and chart accounts at 256. Input money remains positive canonical decimal strings of up to 30 digits. Adjustment evidence is bounded to 240 Unicode scalars in this integrity-export version; a legacy longer reason needs another version and must not be truncated. Time and retained trace size are O(B) in framed bytes B.
Run python tests/run_tests.py to compare 49 complete independent outputs and seven Python suites with strict native-SHA TypeScript parity. Tests include closed historical accounts, malformed/missing input, exact correction membership, Unicode variants, detached results and a real primitive-refusal simulation. Informal vector checks do not confer CAVP validation or certify this package. NIST CAVP
Continue to Merkle-Batched Journal Commitment for inclusion proofs and Signed Ledger Checkpoint for signature trust. Neither membership nor a signature substitutes for complete current source coverage. Validation evidence records the local implementation and actual visual checks.
Rendered from the canonical Mermaid sources linked by this article.
Validate history before retained binding
Caption: the atomic correction remains one group; missing expected evidence withholds independent correspondence even when the local chain is internally valid.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
- NIST FIPS 180-4 publication status. Accessed: 2026-10-25. Supports: August 2015 final publication and March 2023 revision planning note. Limitations: no withdrawal or implementation certification inferred.
- Secure Hash Standard PDF. Accessed: 2026-10-25. Supports: SHA256, section 6.2 and 256-bit digest. Limitations: this tutorial calls standard primitives and defines its own ledger framing; no original compression-function implementation or FIPS validation claim.
- NIST CAVP secure hashing. Accessed: 2026-10-25. Supports: public test-vector purpose. Limitations: informal vector checks do not replace CAVP validation or certify the tutorial.
- Python 3.11 hashlib. Accessed: 2026-10-25. Supports: standard SHA256 API. Limitations: native primitive availability is a runtime requirement; a digest match does not authenticate the expectation.
- W3C Web Cryptography Recommendation, 26 January 2017. Accessed: 2026-10-25. Supports: asynchronous native digest interface. Limitations: secure context/compatible runtime required; unavailable cryptography cannot become pass.
- RFC9162. Accessed: 2026-10-25. Supports: context for neighboring Merkle commitment. Limitations: this authored chain is not the Certificate Transparency protocol; membership/completeness are different questions.
The three group/four journal Cedar scenario, exact wire framing, source bounds and independent fixtures are authored synthetic material. No licensed historical ledger or external receipt is copied. The pinned Stage2 history validator's exact byte provenance and local adapter exports are recorded in research/history-validator-provenance.json.
Full dependency-light reference implementations in both supported languages.
/** Complete historical validation, explicit framing, native SHA256 and retained binding. */
import {catalog,validateHead,validateHistory,FoldError} from './history-validation.js';
type Json=any;
const REQUEST=['schema_version','stream_id','ledger_id','entity_id','verification_at'];
const EXPECTED=['schema_version','stream_id','ledger_id','entity_id','head_sequence','group_count','chain_head','available_at','receipt_ref'];
const id=/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
const dec=/^(0|[1-9][0-9]{0,29})$/;
const hex=/^[0-9a-f]{64}$/;
class Invalid extends Error{}
function need(ok:unknown,code:string):asserts ok{if(!ok)throw new Invalid(code);}
function exact(value:Json,keys:string[]):boolean{return value!==null&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===keys.length&&keys.every(k=>Object.hasOwn(value,k));}
function full(pattern:RegExp,value:string):boolean{return pattern.exec(value)?.[0]===value;}
function identifier(value:Json):boolean{return typeof value==='string'&&full(id,value);}
function count(value:Json,limit:number):boolean{return typeof value==='string'&&full(dec,value)&&BigInt(value)<=BigInt(limit);}
function timestamp(value:Json):boolean{if(typeof value!=='string'||!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)||value.startsWith('0000'))return false;const time=Date.parse(value);return Number.isFinite(time)&&new Date(time).toISOString()===value;}
function copy<T>(value:T):T{return JSON.parse(JSON.stringify(value));}
function pick(value:Json,keys:string[]):Json{return Object.fromEntries(keys.map(k=>[k,value[k]]));}
export function u32(value:number):Uint8Array{need(Number.isInteger(value)&&value>=0&&value<=4294967295,'INTEGER_OUT_OF_RANGE');const out=new Uint8Array(4);new DataView(out.buffer).setUint32(0,value,false);return out;}
function concat(parts:Uint8Array[]):Uint8Array{const n=parts.reduce((n,p)=>n+p.length,0),out=new Uint8Array(n);let offset=0;for(const part of parts){out.set(part,offset);offset+=part.length;}return out;}
export function stringBytes(value:string):Uint8Array{need(typeof value==='string'&&Array.from(value).every(c=>{const p=c.codePointAt(0)!;return p<0xD800||p>0xDFFF;}),'INVALID_UNICODE');const raw=new TextEncoder().encode(value);return concat([u32(raw.length),raw]);}
function tag(value:string):Uint8Array{return new TextEncoder().encode(value+'\0');}
function toHex(raw:Uint8Array):string{return Array.from(raw,x=>x.toString(16).padStart(2,'0')).join('');}
async function digest(raw:Uint8Array):Promise<Uint8Array>{try{if(!globalThis.crypto?.subtle)throw new Error();return new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256',raw as BufferSource));}catch{throw new Invalid('CRYPTO_UNAVAILABLE');}}
/** Precondition: complete source already passed verifyHashChain validation. */
export function encodeValidatedGroup(group:Json,streamId:string):Uint8Array{
const parts=[tag('D30-COMMITTED-GROUP-V1'),stringBytes(streamId)];
for(const k of ['schema_version','group_id','kind','ledger_id','entity_id','available_at'])parts.push(stringBytes(group[k]));parts.push(u32(group.journals.length));
for(const journal of group.journals){const command=journal.command;for(const k of ['schema_version','ledger_id','entity_id','journal_id','business_operation_id','currency','effective_at','source_available_at','booking_period'])parts.push(stringBytes(command[k]));parts.push(u32(command.legs.length));for(const leg of command.legs)for(const k of ['account_id','side','amount_minor'])parts.push(stringBytes(leg[k]));for(const k of ['sequence','recorded_at','adjustment_reason'])parts.push(stringBytes(journal[k]));}
const correction=group.correction;parts.push(u32(correction===null?0:1));if(correction!==null)for(const k of ['correction_id','original_journal_id','reversal_journal_id','replacement_journal_id','reason'])parts.push(stringBytes(correction[k]));return concat(parts);
}
function validateExpected(value:Json):void{
if(value===null)return;need(exact(value,EXPECTED),'INVALID_EXPECTED_COMMITMENT');need(value.schema_version==='d30-expected-chain-v1'&&['stream_id','ledger_id','entity_id','receipt_ref'].every(k=>identifier(value[k])),'INVALID_EXPECTED_COMMITMENT');need(count(value.head_sequence,8192)&&count(value.group_count,4096),'INVALID_EXPECTED_COMMITMENT');const n=Number(value.head_sequence),g=Number(value.group_count);need(n===0&&g===0||g>0&&g<=n&&n<=2*g,'INVALID_EXPECTED_COMMITMENT');need(typeof value.chain_head==='string'&&hex.test(value.chain_head)&×tamp(value.available_at),'INVALID_EXPECTED_COMMITMENT');
}
function failure(code:string,status='rejected'):Json{return{status,code,scope:null,coverage:null,commitment:null,entries:[],checks:{history:{status:status==='indeterminate'?'indeterminate':'fail',code},binding:{status:'not_evaluated',code:'NOT_EVALUATED',mismatched_fields:[]}}};}
export async function verifyHashChain(request:Json,groups:Json,accounts:Json,context:Json):Promise<Json>{
try{
need(exact(request,REQUEST),'INVALID_REQUEST');need(request.schema_version==='d30-hash-chain-verification-v1'&&['stream_id','ledger_id','entity_id'].every(k=>identifier(request[k]))&×tamp(request.verification_at),'INVALID_REQUEST');
need(exact(context,['head','expected_commitment']),'INVALID_CONTEXT');const head=context.head;validateHead(head);need(['stream_id','ledger_id','entity_id'].every(k=>head[k]===request[k]),'SCOPE_MISMATCH');need(count(head.head_sequence,8192),'INVALID_HEAD_BOUND');validateExpected(context.expected_commitment);
need(Array.isArray(accounts)&&accounts.length<=256,'INVALID_ACCOUNTS');const lookup=catalog(accounts);validateHistory(groups,head,lookup);
for(const group of groups)for(const journal of group.journals){const chars=Array.from(journal.adjustment_reason as string);need(chars.length<=240&&chars.every(c=>{const p=c.codePointAt(0)!;return p<0xD800||p>0xDFFF;}),'INVALID_ADJUSTMENT_EVIDENCE');}
need(head.observed_at>=request.verification_at,'KNOWLEDGE_BEYOND_SOURCE');need(groups.every((g:Json)=>g.available_at<=request.verification_at),'HISTORY_NOT_AVAILABLE');
let prior=await digest(concat([tag('D30-CHAIN-GENESIS-V1'),...['stream_id','ledger_id','entity_id'].map(k=>stringBytes(head[k]))]));const entries:Json[]=[];
for(let i=0;i<groups.length;i++){const group=groups[i],raw=encodeValidatedGroup(group,head.stream_id),current=await digest(concat([tag('D30-CHAIN-NODE-V1'),prior,u32(i+1),u32(raw.length),raw]));entries.push({group_index:String(i+1),group_id:group.group_id,first_sequence:group.journals[0].sequence,last_sequence:group.journals.at(-1).sequence,byte_length:String(raw.length),bytes_hex:toHex(raw),group_sha256:toHex(await digest(raw)),previous_chain_head:toHex(prior),chain_head:toHex(current)});prior=current;}
const commitment={schema_version:'d30-chain-commitment-v1',...pick(head,['stream_id','ledger_id','entity_id','head_sequence']),group_count:String(groups.length),chain_head:toHex(prior)},expected=context.expected_commitment;let status:string,code:string,mismatches:string[]=[];
if(expected===null){status='indeterminate';code='EXPECTED_COMMITMENT_MISSING';}else if(expected.available_at>request.verification_at){status='indeterminate';code='EXPECTED_COMMITMENT_UNAVAILABLE';}else{mismatches=['stream_id','ledger_id','entity_id','head_sequence','group_count','chain_head'].filter(k=>expected[k]!==commitment[k]).sort();status=mismatches.length?'fail':'pass';code=mismatches.length?'EXPECTED_COMMITMENT_MISMATCH':'EXPECTED_COMMITMENT_MATCH';}
return copy({status,code:status==='pass'?'CHAIN_MATCHES_INDEPENDENT_COMMITMENT':code,scope:pick(request,['stream_id','ledger_id','entity_id','verification_at']),coverage:{...pick(head,['observed_at','receipt_ref','head_sequence']),validated_journal_count:head.head_sequence,group_count:String(groups.length),last_available_at:groups.at(-1)?.available_at??null},commitment,entries,checks:{history:{status:'pass',code:'COMPLETE_HISTORY_VALIDATED'},binding:{status,code,mismatched_fields:mismatches}}});
}catch(error){if(!(error instanceof Invalid)&&!(error instanceof FoldError))throw error;const code=error.message;return failure(code,['INCOMPLETE_EXPORT','KNOWLEDGE_BEYOND_SOURCE','HISTORY_NOT_AVAILABLE','CRYPTO_UNAVAILABLE'].includes(code)?'indeterminate':'rejected');}
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.
