A ledger audit page that never shows a contradicted release as verified A static export in Bun and TypeScript
Eight deploys in, eight pages out: the ledger's claim is shown, the auditor decides the status, and an audit whose counts do not add up stops the build.
Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Bun 1.3 or newer. package.json has no dependencies to install. All lab requirements.
F-Data · A · arc: self-healing-edge · langs: ts, gleam · DOP-4.2
Concept
The situation. A release tool keeps a log of what it did. Call that log the ledger: an append-only file of JSON objects, one per line (this format is called JSONL, "JSON Lines"). A release is one version of a service, such as v1 or v2. After a new release is switched on, the tool watches it for a fixed soak window, here 30 000 milliseconds. If the release crashes more than a set restart budget (here 2) inside the window, the tool performs a rollback: it switches back to the last good release and writes a rolled_back line. If the window ends quietly, the tool writes a confirmed line.
A person reading a status page wants one question answered: can I believe what the ledger says happened?
The trap. The easy page reads the ledger and shows "verified" for every release that has a confirmed or rolled_back line. That page repeats the ledger's own words. Anyone who edits the ledger edits the page.
Plan, ledger and auditor. This lesson uses three inputs.
- The plan is a second JSONL file that records what actually happened to the service:
serving(the baseline release),promoted(a new release goes live),exited(the release crashed),tick(time passed). It does not depend on the ledger. - The ledger is what the release tool claims it did in response.
- The auditor is a small Rust program from this unit's backend lesson,
ledger-audit PLAN LEDGER(binary nameaudit). It re-derives from the plan alone what the ledger must contain, then compares in both directions. It prints one line per violation (a place where ledger and derivation disagree) and a summary line. This lesson does not re-run the auditor. It consumes the auditor's text output, which the lab stores next to each fixture. Those.auditfiles are the auditor's real output: the reference auditor was run over every plan and ledger pair in a clean container on 2026-11-10, and the run is kept aslab/audit-outputs.run.log.
The derivation rule is short. For each promoted release, if the plan shows the third exit inside the soak window, the ledger must contain exactly one rolled_back at that exit's timestamp (ts, in milliseconds). Otherwise the first plan event at or after promotion + 30 000 closes the window, and the ledger must contain exactly one confirmed. The rollback target must be the last release whose derived outcome was confirmed, or the baseline.
Why re-derive. Martin Kleppmann describes an event log as the source of truth from which current state is computed. The computation "should be deterministic, so that you can run it again and derive the same application state from the event log." If the computation is deterministic, replaying the log is a check: derive the state again and compare. The same chapter explains why accountants keep an append-only ledger. A mistake is corrected by adding a compensating entry, and the incorrect entry stays "because it might be important for auditing reasons." An audit is therefore a legitimate thing to do to a ledger. Editing an entry silently is the thing an audit exists to catch.
Two directions. Comparing both ways matters, because forgery takes two shapes.
- A claim without support: a line was added or edited. Example: a
confirmedstamped at 12 000 when the plan's window only closes at 31 000. - A derived fact without a claim: a line was deleted. The plan says a rollback must have happened, and the ledger has none.
A deleted line leaves no line number to point at, so the auditor prints line=none and the page shows "omission (no line)". Tools that only look at lines that exist cannot see this case. The Amazon CloudWatch Logs documentation says of its filter patterns that they "only return the log events that contain the terms you define", which is a good description of a tool that is blind to what is missing.
A schema check is not an audit. A schema says what shape a line must have. The Rust auditor's schema rule checks that ts is an unsigned integer and that event and version are strings, using Value::as_u64 and Value::as_str from the serde_json library, which return None when the value is another type. A forged ledger can pass that check on every line. The lab is built around that failure: the ledger is well formed and false.
The three statuses. The page assigns each release exactly one status, and the status comes from the auditor's output, never from the ledger's claim. The claim is shown beside it for the reader.
| Status | When |
|---|---|
contradicted | at least one violation names this release |
unverified | no violation names it, but a damaged line with no readable version (version=-) could have concerned it, or the ledger makes no outcome claim yet |
verified | otherwise (the baseline, the plan's starting release, is verified this way when nothing contradicts it) |
Two build errors. The page is a static export: a build step writes plain HTML files, and there is no server to run. That means mistakes have to be caught at build time, and the build is where we make them impossible to ship.
- A page that would show
verifiedfor a release a violation names is a build error. - An audit whose counts do not add up is a build error. The summary line says how many ledger lines were read and how many violations were found. If the listed violations or the ledger's actual line count disagree with it, or a violation names a line past the end of the ledger or a release the plan never served, the audit file is not trustworthy and no page is written for it.
What this does not do. This is not tamper-proof. It detects a ledger that is inconsistent with the plan. A person who edits the plan, the ledger and the audit output consistently is not caught. The page also trusts that the audit file really came from running the auditor. The check is about inconsistency, and it is only as strong as the independence of the plan.
Worked example
Everything starts from three small deploys. Each has a plan and an honest ledger. The honest ledgers are exactly what the release tool printed for the same plans.
| Fixture | Plan | Honest ledger |
|---|---|---|
flap | v2 promoted at 1 000, exits at 3, 6 and 9 seconds | two restarting lines, then rolled_back to v1 at 9 000 |
stable | v2 promoted, one exit at 3 000, a tick at 31 000 | one restarting line, then confirmed at 31 000 |
chain | v2 is rolled back; v3 is promoted with a stale previous of v2; v3 is rolled back | rollbacks of v2 and then v3, both to v1 |
The promoted line carries a previous field the auditor never reads. In chain it is deliberately stale (v3 says previous=v2, but v2 was rolled back). The auditor derives the rollback target from outcomes, so it must still say v1.
Four forgeries are built from these, plus damaged and unfinished cases:
early:stablewithconfirmedmoved from 31 000 to 12 000.deleted:flapwith therolled_backline removed.badtarget:chainwith the second rollback'stochanged fromv1tov2.damaged: astableledger with one line cut off mid-write and a timestamp that goes backwards.soaking: a deploy still inside its window, so no outcome exists yet.
What the auditor says
Each fixture has a recorded auditor output. This is the content of the early and deleted audit files, exactly as the checker writes them:
VIOLATION line=2 rule=confirmed_before_window version=v2 confirmed at 12000 but the window closes at 31000
ledger: lines=2 violations=1
VIOLATION line=none rule=missing_rollback version=v2 the plan rolls it back at 9000
ledger: lines=2 violations=1
The format is VIOLATION line=<n|none> rule=<rule> version=<v> <detail>, followed by exactly one summary line ledger: lines=<n> violations=<k>. The none marks an omission. The damaged fixture shows the schema case: a line that cannot be parsed is counted, named, and the audit continues. Its first violation reads VIOLATION line=2 rule=schema version=- ..., with version=- because nothing in the cut line could be trusted to name a release.
The reader module
The module has one job: turn (plan, ledger, audit text) into a list of releases with statuses and a list of violations. It does three things in order.
1. List the releases from the plan. The baseline is the serving event's version, then each promotion in order. The ledger is not consulted for this. A release cannot appear on the page because the ledger mentions it.
2. Validate the audit before believing it. These are the lines that turn a miscount into a build error:
if (!summary) throw new Error("audit has no summary line");
const [lines, count] = [Number(summary[1]), Number(summary[2])];
// Counts that do not add up are a build error, not a page.
if (count !== violations.length) throw new Error(`summary says ${count} violations, audit lists ${violations.length}`);
if (lines !== ledgerLines.length) throw new Error(`summary says ${lines} ledger lines, ledger has ${ledgerLines.length}`);
for (const v of violations) {
if (v.line !== "none" && (v.line < 1 || v.line > lines)) throw new Error(`violation names line ${v.line}, ledger has ${lines}`);
if (v.version !== "-" && !order.includes(v.version)) throw new Error(`violation names ${v.version}, which the plan never served`);
}
Note what these catch. A summary that undercounts (short), a summary placed before a violation (hidden, where the single summary line says zero and a violation follows), an audit run against the wrong ledger (wrongfile), and a violation about a release nobody served (stranger).
3. Take the status from the violations. The ledger's claim is read only to display it:
const unattributed = violations.some((v) => v.version === "-");
const releases = order.map((version): Release => {
const mine = violations.filter((v) => v.version === version);
const claim = claims.get(version) ?? null;
const isBase = version === baseline;
if (mine.length > 0) return { version, baseline: isBase, claim, status: "contradicted", why: `${mine.length} violation(s) name this release` };
if (unattributed) return { version, baseline: isBase, claim, status: "unverified", why: "a damaged ledger line could not be attributed to a release" };
if (!isBase && claim === null) return { version, baseline: isBase, claim, status: "unverified", why: "the ledger makes no outcome claim yet" };
return { version, baseline: isBase, claim, status: "verified", why: isBase ? "the plan's starting release" : "the audit re-derived this claim from the plan" };
});
The order of the tests is the policy. Contradiction wins over everything. A damaged line that cannot be attributed makes every release in that deploy at most unverified, because it could have concerned any of them. Only then do we ask whether there is a claim at all.
The static export
build.ts writes one HTML page per deploy using Bun. Bun's file API reads a file with Bun.file(path).text() and writes one with Bun.write(dest, data), so the build needs no dependencies. Every value that came from a file goes through esc, which escapes &, <, > and ", because a ledger is untrusted text and could contain markup. The banner uses role="alert" when there are violations, following the WAI-ARIA Authoring Practices "Alert Pattern", which describes an alert as "a brief, important message", and role="status" when the audit is clean. One diagnostic worth knowing: the same pattern warns that "screen readers do not inform users of alerts that are present on the page before page load completes." A static page is complete at load, so the role here labels the banner rather than announcing it. The banner is the first thing after the heading, which is what actually puts it in front of a screen-reader user.
build.ts, in full:
// Static export: one ledger-audit page per deploy. No server, no client JS.
// Usage: bun build.ts <fixtures-dir> <out-dir>. A deploy whose audit does not add up stops the build.
import { readdirSync, mkdirSync } from "node:fs";
import { readDeploy, type Deploy } from "./audit.ts";
const [inDir = "fixtures", outDir = "dist"] = process.argv.slice(2);
const esc = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
function page(name: string, d: Deploy): string {
const rows = d.releases.map((r) =>
`<tr class="${r.status}" data-version="${esc(r.version)}" data-status="${r.status}"><td>${esc(r.version)}${r.baseline ? " (baseline)" : ""}</td>` +
`<td>${esc(r.claim ?? "no outcome claim")}</td><td>${r.status}: ${esc(r.why)}</td></tr>`).join("\n");
const items = d.violations.map((v) =>
`<li data-line="${v.line}" data-rule="${v.rule}" data-violation-version="${esc(v.version)}">` +
`${v.line === "none" ? "omission (no line)" : `line ${v.line}`} · ${v.rule} · ${esc(v.version)} · ${esc(v.detail)}</li>`).join("\n");
const clean = d.violations.length === 0;
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>ledger audit: ${esc(name)}</title>
<link rel="stylesheet" href="audit.css"></head>
<body><main data-lines="${d.lines}" data-violations="${d.violations.length}">
<h1>Ledger audit</h1>
<p class="banner ${clean ? "ok" : "error"}" role="${clean ? "status" : "alert"}">${clean
? `The audit re-derived all ${d.lines} ledger lines from the plan and found no violations.`
: `${d.violations.length} violation(s) in ${d.lines} ledger lines; the ledger's own words are not evidence.`}</p>
<table><thead><tr><th>Release</th><th>Ledger claims</th><th>Audit</th></tr></thead><tbody>
${rows}
</tbody></table>
<h2>Violations</h2>
${clean ? `<p data-field="violations">none</p>` : `<ol data-field="violations">\n${items}\n</ol>`}
</main></body></html>
`;
}
// The page must never say verified next to a release a violation names. Check the HTML we wrote.
function selfCheck(html: string, d: Deploy) {
for (const v of d.violations) {
if (html.includes(`data-version="${esc(v.version)}" data-status="verified"`))
throw new Error(`page shows verified for ${v.version}, which a violation names`);
}
}
mkdirSync(outDir, { recursive: true });
let bad = 0;
for (const file of readdirSync(inDir).filter((f) => f.endsWith(".plan")).sort()) {
const name = file.replace(/\.plan$/, "");
const read = (ext: string) => Bun.file(`${inDir}/${name}.${ext}`).text();
try {
const d = readDeploy(await read("plan"), await read("ledger"), await read("audit"));
const html = page(name, d);
selfCheck(html, d);
await Bun.write(`${outDir}/${name}.html`, html);
console.log(`${name} -> ${outDir}/${name}.html ` + d.releases.map((r) => `${r.version}=${r.status}`).join(" "));
} catch (e) {
console.error(`build error: ${name}: ${(e as Error).message}`);
bad++;
}
}
await Bun.write(`${outDir}/audit.css`, await Bun.file("audit.css").text());
if (bad > 0) {
console.error(`build failed: ${bad} deploy(s) whose audit does not add up`);
process.exit(1);
}
selfCheck is a second line of defense. It inspects the HTML that was just built and refuses to write it if a violation-named release is marked verified. It is not the main control. If it were, a bug that marks a release unverified when it should be contradicted would pass it. The lab shows exactly that case.
package.json and audit.css, both unchanged between starter and reference:
{
"name": "ledger-audit-page",
"private": true,
"type": "module",
"scripts": { "build": "bun build.ts fixtures dist" }
}
body { font: 16px/1.5 system-ui, sans-serif; max-width: 46rem; margin: 2rem auto; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
td, th { border-bottom: 1px solid #ccc; padding: .4rem; text-align: left; }
.banner { padding: .6rem; border-left: 4px solid; }
.banner.ok { border-color: #2e7d32; } .banner.error { border-color: #c62828; }
tr.verified td:last-child::before { content: "✓ "; }
tr.contradicted td:last-child::before { content: "✗ "; }
tr.unverified td:last-child::before { content: "? "; }
The CSS prefixes each status with a mark (✓, ✗, ?), so the status does not rely on colour alone.
The checker
The lab ships a POSIX shell script, check.sh. It builds every fixture itself, runs the build, and prints one RESULT line per check. The eight checks:
| Check | What it requires |
|---|---|
static_build | the export builds and writes 8 pages |
honest_ledgers_verified | flap, stable, chain: every release verified, claims shown, "none" under violations |
edited_claim_contradicted | early and badtarget: the edited release is contradicted, the line is listed by number |
omission_named | deleted: contradicted, listed as "omission (no line)" with its release |
damaged_ledger_unverified | damaged: all four violations listed, v2 contradicted, v1 unverified |
no_claim_unverified | soaking: the release with no outcome claim is unverified, not verified |
never_verified_with_violation | across every built page, no violation-named release is verified |
miscount_fails_build | the four hand-edited audits (short, hidden, wrongfile, stranger) fail the build and write no page |
The continuous-integration gate ran the checker twice, once on the reference solution and once on the starter, in the oven/bun:1.3-debian container image with networking off. This is the reference section, copied from the gate log:
## reference (rc=0, PASS)
badtarget -> dist/badtarget.html v1=verified v2=verified v3=contradicted
chain -> dist/chain.html v1=verified v2=verified v3=verified
damaged -> dist/damaged.html v1=unverified v2=contradicted
deleted -> dist/deleted.html v1=verified v2=contradicted
early -> dist/early.html v1=verified v2=contradicted
flap -> dist/flap.html v1=verified v2=verified
soaking -> dist/soaking.html v1=verified v2=unverified
stable -> dist/stable.html v1=verified v2=verified
built 8 pages
RESULT static_build PASS
honest: flap v2=verified stable v2=verified chain v2=verified v3=verified
RESULT honest_ledgers_verified PASS
early: v2=contradicted; badtarget: v2=verified v3=contradicted
RESULT edited_claim_contradicted PASS
deleted: v2=contradicted
RESULT omission_named PASS
damaged: v1=unverified v2=contradicted listed=4
RESULT damaged_ledger_unverified PASS
soaking: v1=verified v2=unverified
RESULT no_claim_unverified PASS
violation-named releases checked: 5
RESULT never_verified_with_violation PASS
RESULT miscount_fails_build PASS
$ bun build.ts fixtures dist
build error: hidden: summary says 0 violations, audit lists 1
build error: short: summary says 2 violations, audit lists 1
build error: stranger: violation names v9, which the plan never served
build error: wrongfile: summary says 6 ledger lines, ledger has 3
build failed: 4 deploy(s) whose audit does not add up
Read the badtarget line closely: v2=verified v3=contradicted. The forgery edited v3's rollback, so only v3 is contradicted. v2 is still verified, because the audit found nothing wrong with it.
Lab
Goal. Starting from a page generator that trusts the ledger, make it take every status from the audit and make a miscounted audit a build error. Success is sh check.sh printing eight PASS lines.
Setup. You need Bun and a shell. Create an empty folder and put the lab's starter files in it: package.json, audit.css, build.ts (the three files shown above, identical to the starter's), the starter audit.ts, and check.sh. No packages are installed and no network is needed.
Step 1: see it fail. Run:
sh check.sh
The starter's audit.ts marks a release verified whenever the ledger claims an outcome. This is the starter section of the gate log. The RESULT lines in your run should match it; the interleaving of build messages can differ, because the build writes errors to stderr and page lines to stdout:
## starter (rc=1, FAIL)
chain -> dist/chain.html v1=verified v2=verified v3=verified
damaged -> dist/damaged.html v1=verified v2=unverified
deleted -> dist/deleted.html v1=verified v2=unverified
flap -> dist/flap.html v1=verified v2=verified
soaking -> dist/soaking.html v1=verified v2=unverified
stable -> dist/stable.html v1=verified v2=verified
RESULT static_build FAIL
honest: flap v2=verified stable v2=verified chain v2=verified v3=verified
RESULT honest_ledgers_verified PASS
early: v2=; badtarget: v2= v3=
RESULT edited_claim_contradicted FAIL
deleted: v2=unverified
RESULT omission_named FAIL
damaged: v1=verified v2=unverified listed=4
RESULT damaged_ledger_unverified FAIL
soaking: v1=verified v2=unverified
RESULT no_claim_unverified PASS
violation-named releases checked: 3
RESULT never_verified_with_violation FAIL
stranger -> dist-rejected/stranger.html v1=verified v2=verified
wrongfile -> dist-rejected/wrongfile.html v1=verified v2=verified
rejected: build failed but wrote: dist-rejected/stranger.html dist-rejected/wrongfile.html
RESULT miscount_fails_build FAIL
$ bun build.ts fixtures dist
build error: badtarget: page shows verified for v3, which a violation names
build error: early: page shows verified for v2, which a violation names
build failed: 2 deploy(s) whose audit does not add up
error: script "build" exited with code 1
build error: hidden: page shows verified for v2, which a violation names
build error: short: page shows verified for v2, which a violation names
build failed: 2 deploy(s) whose audit does not add up
Notice three things before you edit anything.
earlyandbadtargetnever get a page, becauseselfCheckcaught the lie. That is the backstop working.deletedanddamageddo get pages, with the wrong status.deletedshowsv2=unverifiedwhere it should becontradicted. The backstop only stopsverified, not wrong.strangerandwrongfilegot pages even though their build was supposed to fail. The starter never validates the summary.
Step 2: validate the audit. The starter's loop over the audit text contains the two places to change:
let lines = ledgerLines.length;
for (const l of nonEmpty(audit)) {
const v = l.match(VIOLATION);
const s = l.match(SUMMARY);
if (v) violations.push({ line: v[1] === "none" ? "none" : Number(v[1]), rule: v[2], version: v[3], detail: v[4] });
if (s) lines = Number(s[1]); // TODO: check the summary against the audit and the ledger
}
Replace this with a loop that accepts only violation lines and exactly one summary line, and throws on anything else. Then throw if there is no summary, if the summary's violation count differs from the number of violations listed, if its line count differs from the number of non-empty lines in the ledger, if a violation names a line outside 1..lines, or if a violation names a version (other than -) that the plan never served. Any message will do for check 8, which tests that the build fails and writes no page; the messages in the gate log above are the reference's, and matching them makes your output easy to compare.
Step 3: take the status from the audit. The starter's second TODO:
// TODO: a violation that names this release must win over the ledger's claim.
if (isBase || claim !== null) return { version, baseline: isBase, claim, status: "verified", why: "the ledger says so" };
Write the four-way decision from the Concept section, in that order: a violation names it → contradicted; any version=- violation exists → unverified; a non-baseline release with no claim → unverified; otherwise verified. Keep the claim in the returned object: the page still displays it, it just no longer decides anything.
Step 4: run it again. sh check.sh should print the same eight passing RESULT lines as the reference section above and exit 0.
Step 5 (extension, no solution given). Two exercises:
- Add a ninth fixture to
check.shwhose ledger claimsconfirmedfor a release the plan never promoted. The auditor reportsconfirmed_unsupportedfor that case. Write the.auditfile the auditor would print, and decide what status the page should show for the release. - Hand-edit one
.auditfile so that it saysviolations=0for a forged ledger, and run the build. Does the page catch it? It should not, and that is the point of the honesty line below: the page can only compare the audit with the ledger on counts and line numbers, not re-derive anything.
Reference solution. Try the steps first. This is the finished audit.ts:
// Read one deploy: the plan, the ledger, and the auditor's text output.
// The page never trusts the ledger's own words; a release's status comes from the audit.
export type Status = "verified" | "unverified" | "contradicted";
export interface Violation { line: number | "none"; rule: string; version: string; detail: string }
export interface Release { version: string; baseline: boolean; claim: string | null; status: Status; why: string }
export interface Deploy { releases: Release[]; violations: Violation[]; lines: number }
const VIOLATION = /^VIOLATION line=(\d+|none) rule=([a-z_]+) version=(\S+) ?(.*)$/;
const SUMMARY = /^ledger: lines=(\d+) violations=(\d+)$/;
const nonEmpty = (s: string) => s.split("\n").filter((l) => l.trim() !== "");
export function readDeploy(plan: string, ledger: string, audit: string): Deploy {
// Releases come from the plan: the serving baseline, then each promotion in order.
const order: string[] = [];
let baseline = "";
for (const l of nonEmpty(plan)) {
const e = JSON.parse(l);
if (e.event === "serving") baseline = e.version;
if ((e.event === "serving" || e.event === "promoted") && !order.includes(e.version)) order.push(e.version);
}
if (!baseline) throw new Error("plan has no serving baseline");
// The ledger's claims, read leniently: a damaged line is the audit's to name, not ours.
const ledgerLines = nonEmpty(ledger);
const claims = new Map<string, string>();
for (const l of ledgerLines) {
let e: any;
try { e = JSON.parse(l); } catch { continue; }
if (e.event === "confirmed") claims.set(e.version, "confirmed");
if (e.event === "rolled_back") claims.set(e.version, `rolled_back to ${e.to}`);
}
// The audit's text output: every VIOLATION line, then exactly one summary line.
const violations: Violation[] = [];
let summary: RegExpMatchArray | null = null;
for (const l of nonEmpty(audit)) {
const v = l.match(VIOLATION);
const s = l.match(SUMMARY);
if (v) violations.push({ line: v[1] === "none" ? "none" : Number(v[1]), rule: v[2], version: v[3], detail: v[4] });
else if (s && !summary) summary = s;
else throw new Error(`audit line not understood: ${l}`);
}
if (!summary) throw new Error("audit has no summary line");
const [lines, count] = [Number(summary[1]), Number(summary[2])];
// Counts that do not add up are a build error, not a page.
if (count !== violations.length) throw new Error(`summary says ${count} violations, audit lists ${violations.length}`);
if (lines !== ledgerLines.length) throw new Error(`summary says ${lines} ledger lines, ledger has ${ledgerLines.length}`);
for (const v of violations) {
if (v.line !== "none" && (v.line < 1 || v.line > lines)) throw new Error(`violation names line ${v.line}, ledger has ${lines}`);
if (v.version !== "-" && !order.includes(v.version)) throw new Error(`violation names ${v.version}, which the plan never served`);
}
// A damaged line with no readable version could have concerned any release.
const unattributed = violations.some((v) => v.version === "-");
const releases = order.map((version): Release => {
const mine = violations.filter((v) => v.version === version);
const claim = claims.get(version) ?? null;
const isBase = version === baseline;
if (mine.length > 0) return { version, baseline: isBase, claim, status: "contradicted", why: `${mine.length} violation(s) name this release` };
if (unattributed) return { version, baseline: isBase, claim, status: "unverified", why: "a damaged ledger line could not be attributed to a release" };
if (!isBase && claim === null) return { version, baseline: isBase, claim, status: "unverified", why: "the ledger makes no outcome claim yet" };
return { version, baseline: isBase, claim, status: "verified", why: isBase ? "the plan's starting release" : "the audit re-derived this claim from the plan" };
});
return { releases, violations, lines };
}
Lab verification: the reference and the starter were both run by the lab's gate in the oven/bun:1.3-debian image with networking off, on 2026-11-10. The reference passed all eight checks; the starter failed six of them, as designed.
Limits of this lab, stated plainly. The audit files are recorded outputs. The page checks that an audit is internally consistent and consistent with the ledger's line count. It cannot check that the audit was honestly produced. That is a property of how the auditor is run and who can change its inputs.
Other paths
Gleam with Lustre (sketch, not run). Gleam is a statically typed language that compiles to JavaScript or to the Erlang VM. Lustre is a Gleam library for HTML; its docs (v5.7.1) say it can be used "as a HTML templating library, without using its runtime features", which is exactly a static export. attribute.data(key, value) adds a data-* attribute, element.text turns a string into a text node, and element.to_string renders the tree. The function names below were checked against the v5.7.1 module docs; the sketch itself has not been compiled or run.
// SKETCH, NOT RUN. Names checked against the Lustre v5.7.1 docs
// for lustre/attribute, lustre/element and lustre/element/html.
import gleam/list
import lustre/attribute
import lustre/element
import lustre/element/html
pub type Status {
Verified
Unverified
Contradicted
}
pub type Release {
Release(version: String, claim: String, status: Status)
}
fn status_name(s: Status) -> String {
case s {
Verified -> "verified"
Unverified -> "unverified"
Contradicted -> "contradicted"
}
}
fn row(r: Release) -> element.Element(msg) {
html.tr(
[
attribute.data("version", r.version),
attribute.data("status", status_name(r.status)),
],
[
html.td([], [element.text(r.version)]),
html.td([], [element.text(r.claim)]),
html.td([], [element.text(status_name(r.status))]),
],
)
}
pub fn table(releases: List(Release)) -> String {
html.table([], [html.tbody([], list.map(releases, row))])
|> element.to_string
}
What the type buys you: Status has exactly three constructors, so a misspelt status is a compile error, and a case over it must handle all three. What it does not buy you: the type says nothing about who decides the status. The audit-derived rule (violations first, then unattributed damage, then missing claims) still has to live in one function that is the only place a Status is created. The build-time rule ("no verified next to a violation-named release") and the count checks would be ported the same way as in TypeScript. On escaping: the Lustre docs single out unsafe_raw_html as the function that "will not escape the HTML string provided", so ledger text belongs in element.text, never in that function.
CloudTrail log file integrity validation (prose contrast). AWS CloudTrail records API activity as log files. Its log file integrity validation feature, in the AWS CloudTrail User Guide's "Validating CloudTrail log file integrity", exists, in the guide's words, "To determine whether a log file was modified, deleted, or unchanged after CloudTrail delivered it". It does this with hashes and signatures: SHA-256 for hashing and SHA-256 with RSA for digital signing, with hourly digest files that are each signed and contain the signature of the previous digest file. That chains the digests together. The contrast with this page is the question each asks. CloudTrail validation detects modified or deleted log files: whether the bytes are what was delivered. This page detects inconsistent logs: whether the content agrees with an independent record of what must have happened. They are complementary. A tamper-evident ledger can still be false when it is written, and a consistent ledger can still be silently replaced if nothing signs it.
Citations
- Designing Data-Intensive Applications, Martin Kleppmann, O'Reilly, 1st edition (2017), chapter 11 "Stream Processing", section "Event sourcing", subsections "Deriving current state from the event log" (the transformation "should be deterministic, so that you can run it again and derive the same application state from the event log") and "Advantages of immutable events" (the accounting ledger, where an incorrect entry stays "because it might be important for auditing reasons"). Quoted from the pre-release text; no page numbers are cited.
- AWS CloudTrail User Guide, "Validating CloudTrail log file integrity" (https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-intro.html). Source of the SHA-256, SHA-256 with RSA, and hourly signed digest-file description, used only in Other paths.
- Amazon CloudWatch Logs User Guide, "Filter pattern syntax for metric filters, subscription filters, filter log events, and Live Tail" (https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html), introduction: "Filter patterns only return the log events that contain the terms you define". Fetched 2026-11-03.
- serde_json 1.x documentation (https://docs.rs/serde_json/1/serde_json/enum.Value.html),
Value::as_u64andValue::as_str: both returnNonewhen the value is another type, which the auditor reports as aschemaviolation. - AWS Certified DevOps Engineer - Professional (DOP-C02) Exam Guide, version 1.6 (https://d1.awsstatic.com/training-and-certification/docs-devops-pro/AWS-Certified-DevOps-Engineer-Professional_Exam-Guide.pdf), Domain 4 Task Statement 4.2 (this lesson's objective) and Domain 1 Task Statement 1.4. Cited for objective mapping only; the task-statement wording is not quoted here.
- Bun documentation, "File I/O" (https://bun.sh/docs/api/file-io):
Bun.file(path).text()reads a file;Bun.write(destination, data)writes one;node:fsfor directories. - Lustre documentation, v5.7.1 (https://hexdocs.pm/lustre/), modules
lustre/element(text,to_string,unsafe_raw_html) andlustre/attribute(data). Basis for the Gleam sketch, which was not run. - W3C WAI-ARIA Authoring Practices Guide, "Alert Pattern" (https://www.w3.org/WAI/ARIA/apg/patterns/alert/), "About This Pattern".