Guides

Audit trail

A decision is reproducible: the same contract and facts always give the same verdict and clause. Record enough to reproduce it, and every decision can be checked later.

What to record

FieldWhy
contract_sha256identifies the exact contract, by its canonical text
facts_sha256identifies the facts, hashed over canonical JSON
action, verdict, clause_idthe decision itself
atwhen; supplied by the application, never by the contract

Keep the facts themselves wherever your retention rules allow: the hash proves they were not altered.

src/audit.tsexample project
// Decision records you can replay: which contract decided what, from which facts.
import { createHash } from 'node:crypto';
import { contractDigest, parse } from '../vendor/ulang-lite/src/index.ts';
import { loadPolicy } from './policy.ts';

export interface DecisionRecord {
  readonly at: string;
  readonly action: string;
  readonly verdict: 'PERMIT' | 'DENY';
  readonly clause_id: string;
  readonly contract: string;
  readonly contract_sha256: string;
  readonly facts_sha256: string;
}

/** JSON with keys sorted at every level, so equal facts always hash the same. */
export function canonical(value: unknown): string {
  if (value === null || typeof value !== 'object') return JSON.stringify(value);
  if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
  const object = value as Record<string, unknown>;
  return `{${Object.keys(object).sort().map((k) => `${JSON.stringify(k)}:${canonical(object[k])}`).join(',')}}`;
}
const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');

export function record(policy: ReturnType<typeof loadPolicy>, action: string, facts: unknown, at: string): DecisionRecord {
  const outcome = policy.decide(action, facts);
  if ('error' in outcome) throw new Error(outcome.error);
  return {
    at, action,
    verdict: outcome.verdict, clause_id: outcome.clause_id,
    contract: policy.name, contract_sha256: outcome.contract_sha256,
    facts_sha256: sha256(canonical(facts)),
  };
}

/** Re-decide a record from the stored facts and a contract source; true only if everything matches. */
export function replay(entry: DecisionRecord, facts: unknown, source: string): boolean {
  const contract = parse(source).contracts.find((c) => c.name === entry.contract);
  if (!contract || contractDigest(contract) !== entry.contract_sha256) return false;
  if (sha256(canonical(facts)) !== entry.facts_sha256) return false;
  const again = record(loadPolicy(source), entry.action, facts, entry.at);
  return again.verdict === entry.verdict && again.clause_id === entry.clause_id;
}
scripts/audit-demo.tsexample project
// Record two decisions, then replay them: once against the same contract, once against an edited one.
import { readFileSync } from 'node:fs';
import { loadPolicy } from '../src/policy.ts';
import { record, replay } from '../src/audit.ts';

const source = readFileSync(new URL('../policy/deploy.ul', import.meta.url), 'utf8');
const policy = loadPolicy(source, 'policy/deploy.ul');
const facts = JSON.parse(readFileSync(new URL('../facts/green.json', import.meta.url), 'utf8'));
const frozen = JSON.parse(readFileSync(new URL('../facts/freeze.json', import.meta.url), 'utf8'));

const log = [
  { entry: record(policy, 'deploy', facts, '2026-10-15T09:30:00Z'), facts },
  { entry: record(policy, 'deploy', frozen, '2026-10-15T09:31:00Z'), facts: frozen },
];
for (const { entry } of log) console.log(JSON.stringify(entry));

const edited = source.replace('min_approvals: int == 2', 'min_approvals: int == 1');
for (const { entry, facts } of log) {
  console.log(`${entry.at} replay: same contract ${replay(entry, facts, source)}, edited contract ${replay(entry, facts, edited)}`);
}
example projectreal output
$ node scripts/audit-demo.ts
{"at":"2026-10-15T09:30:00Z","action":"deploy","verdict":"PERMIT","clause_id":"ProductionDeploy.deploy.permit","contract":"ProductionDeploy","contract_sha256":"686358ddc154b77b5b7b3a24870b8dd6f79b23d376ed039d6824a923284e4059","facts_sha256":"8f0d1a7feaa98af888484dbce8207f5aff537b889f082cc6b39e2f02dc9d5aa1"}
{"at":"2026-10-15T09:31:00Z","action":"deploy","verdict":"DENY","clause_id":"ProductionDeploy.change_freeze","contract":"ProductionDeploy","contract_sha256":"686358ddc154b77b5b7b3a24870b8dd6f79b23d376ed039d6824a923284e4059","facts_sha256":"033610318d2318b48fcb1f796e4521e4f868ef40a846056971908f2b3cc83149"}
2026-10-15T09:30:00Z replay: same contract true, edited contract false
2026-10-15T09:31:00Z replay: same contract true, edited contract false

Replaying against the same contract succeeds. Replaying against a contract with one constant changed fails because the digest no longer matches, even though for these particular facts the verdict would be the same.