Hedronite · Curriculum · AI · Frontend · Thu 2026-11-05

A model release page that turns green only when the log verifies A static export in Bun and TypeScript

Three logs in, three pages out: v1 still serving after a 12/20 rollback, v3 serving at 19/20, and an edited log that never turns green.

Lesson Class: Curriculum · F-AI · rung A
Tested with: Bun 1.3 (lab image, v1.3.14)
Lab: lab/ · sh check.sh (7 checks) · no network, no key
Source: Kubernetes Probes and Deployments · Google ML Crash Course, accuracy · Bun File I/O · Lustre element · DOP-C02 v1.6 · CKA v1.37
Two Files, No Key
The page reads the deploy log and the status report; the chain verdict comes from the report, so the build never holds the secret.
Green Means Verified
One function, one condition: the green banner appears only when every line of the log verified.
Numbers That Agree
A status report that disagrees with the log, or a verified line that contradicts its own gate counts, stops the build.
A release page that shows plausible numbers over an edited log is telling the forger's story; the banner is the verdict.

F-AI · A · arc: self-healing-edge · langs: ts, gleam · DOP-5.3, CKA-WL-1

By Evan Eugene Pincham

You need to know TypeScript a little and have Bun installed. You do not need to have seen any other lesson. Everything you need about the tool this page reports on is below.

Honesty line. The "model" behind all of this is a toy: a three-weight linear classifier, not a language model. The eval set is 20 generated samples. Everything runs on a CPU with no downloads. What is real is the shape of the system: a swap that is gated, a decision log that is tamper-evident, and a page that refuses to look healthy when it cannot show that the log is intact. What is simulated is the model, the eval set and the traffic. This lesson is a content page: a static export built with Bun, with no accounts, no data store, no server and no client JavaScript.

1. Concept

Someone edits a model deploy log so a rejected version looks promoted. A release page that trusts that file will show the forger's story in the same calm layout operators use every day. Humans read the page, not the raw log, so the page is where the lie becomes policy. The danger is not exotic cryptography; it is a green banner over numbers that came from an untrusted file.

A small program called modelgate decides which version of a model is allowed to serve. A new version (the candidate) is scored on a fixed set of 20 labelled samples. If it clears the gate it is promoted and starts serving; if not it is rolled back and the last good version keeps serving. Every decision lands in a sealed log. Your job is the page people read: show what is serving, show the last decision, and never paint green unless that log still verifies.

The idea in plain words

A model release page answers one question for a human: which model version is serving right now, and why you should believe that answer. The page takes the chain verdict from a status report, computes serving version, last decision and rollback count from the log itself, and refuses a green banner unless the report says the whole chain verified. It never holds DEPLOY_LOG_KEY, because a build host with the key could forge lines as well as check them.

The sealed log and the status report

Every decision is appended to a log, one JavaScript Object Notation (JSON) object per line:

{"ts":1793903286,"event":"promoted","version":"v3","detail":"gate pass: 19/20 correct (min 90%); sha256=4880d855d5fba031e54281f374014a504892319f390d28a761524ecaf9208077; replaced v1","prev":"96dfe0ac601bbd4f767a23e545d46267504afcd2fe5e86575362e286e44e6494","mac":"fee411d8091a7055378702d1b8ccf6f36c1ee89d5b5094f16dd9d0fbbac48d0c"}

The fields ts, event, version and detail are the human-readable record (event is candidate_started, promoted or rolled_back). The fields prev and mac make the log tamper-evident. mac is a Hash-based Message Authentication Code over SHA-256 (HMAC-SHA-256) of the line, and prev is the previous line's mac, so lines form a chain. Only someone holding the secret key (DEPLOY_LOG_KEY) can produce a line whose mac checks out. Edit or delete a line and the chain breaks at that line.

modelgate status LOG, run by someone who has the key, prints a short report:

lines: 6
verified: 6
broken: 0
unreadable: 0
first_break: none
serving: v3

and exits 0 only if the whole chain is trusted. When it is not, it names the first line that does not belong (for example first_break: line 4 (mac)).

Read the report as six claims, not as decoration. lines is how many log rows the verifier looked at. verified, broken and unreadable must add up to that total, or the report itself is inconsistent. first_break is either none or a line number with a reason, such as a bad mac. serving is the version the verifier thinks is live. The exit code is the short form of the same claim: zero only when every line checked out. The page you build in this lesson never recomputes those HMACs; it reads this report as the chain verdict and then checks that its own counts from the log agree with what the report said.

A common mistake is to treat the status report as optional chrome and render the log alone. Without the report the page has no chain verdict, so it cannot know whether green is allowed. Another mistake is to re-implement HMAC checks inside the page and then ship the key to the build host; that collapses the split this design depends on.

The job of this lesson

Build the page people read to learn "what model is serving and what was the last decision". The danger is obvious once said out loud: a release page is the thing humans trust. If someone edits the log to turn a rejected model into an accepted one, a naive page happily renders the edited story. Build a page with three properties:

  1. It shows the facts: the serving version, the last decision with its counts, the rollback count.
  2. Its verdict banner is never green unless the chain verified.
  3. A page whose numbers disagree with the log is a build error. No page is better than a page that lies.

Why a model swap is a deploy

A model swap asks the same questions as shipping a binary: what is the readiness test (here, the eval gate), what keeps serving while the candidate is judged (the last-good version), and who records the decision (a sealed log). Kubernetes has the same split. A pod whose containers are not ready "does not receive traffic through Kubernetes Services", and a failed liveness probe is different: "the kubelet kills the container and restarts it". A model that loads but answers badly is a readiness problem, not a liveness one. For rollbacks, a Deployment can be rolled back "when the Deployment is not stable, such as crash looping", and the controller "stops the bad rollout automatically". But a stalled rollout is only reported: "Kubernetes takes no action on a stalled Deployment other than to report a status condition with reason: ProgressDeadlineExceeded. Higher level orchestrators can take advantage of it and act accordingly, for example, rollback the Deployment to its previous version." modelgate is that higher-level piece: it decides and records. This page is the part that shows the record to a human.

The gate is arithmetic on counts (ML)

The gate measures accuracy, "the proportion of all classifications that were correct, whether positive or negative": correct / total. The gate here never divides. It compares integers: a candidate passes when correct * 100 >= total * min. With 19 correct of 20 and a minimum of 90, that is 1900 >= 1800, so it passes. Floats differ across machines and libraries; integers do not.

Two cautions from the same source and from the numbers themselves:

The design decision (Sec)

The page reads two files per release: the deploy log itself, and the text that modelgate status printed for that log. It never holds DEPLOY_LOG_KEY. That is deliberate: a build host holding the key could forge lines as well as check them, so the key stays with whoever runs modelgate status, and the page takes the chain verdict from that report.

What the page does compute itself, from the log alone:

It then cross-checks its own numbers against the status report. The build throws (so no page is written) when:

An unverified log still builds. Its banner says it did not verify, nothing on the page is green, and any self-contradicting line is listed.

The honest limit

The page checks that two files agree with each other and with modelgate's rules. It does not check authenticity. That is the status report's job. Concretely:

2. Worked example

All code below is the lab's reference solution.

Step 1: parse the status report

The report is plain key: value lines. Two small helpers pull a field out, and the second insists on a whole number:

function field(text: string, key: string): string {
  const m = new RegExp(`^${key}: (.*)$`, "m").exec(text);
  if (!m) throw new Error(`status has no \`${key}:\` line`);
  return m[1].trim();
}
function num(text: string, key: string): number {
  const v = field(text, key);
  if (!/^\d+$/.test(v)) throw new Error(`\`${key}:\` must be a whole number, got "${v}"`);
  return Number(v);
}

field anchors at the start of a line, so a key name buried inside a value cannot match by accident. If the line is missing, it throws immediately: a status report with no serving: line is not something to guess at. num reuses field, then demands a whole-number string with ^\d+$ before calling Number. That matters because Number("3.0") and Number("") are easy to accept by mistake, and a floating lines value would break every later cross-check. Together the helpers turn free text into typed fields the rest of the build can trust, or they stop the build before any HTML is written.

parseStatus builds a Status and then refuses reports that are internally inconsistent:

export function parseStatus(text: string): Status {
  const fb = field(text, "first_break");
  const sv = field(text, "serving");
  const s: Status = {
    lines: num(text, "lines"), verified: num(text, "verified"), broken: num(text, "broken"),
    unreadable: num(text, "unreadable"), firstBreak: fb === "none" ? null : fb, serving: sv === "none" ? null : sv,
  };
  if (s.verified + s.broken + s.unreadable !== s.lines)
    throw new Error(`status does not add up: ${s.verified} verified + ${s.broken} broken + ${s.unreadable} unreadable != ${s.lines} lines`);
  if ((s.firstBreak === null) !== (s.verified === s.lines))
    throw new Error(`status says first_break ${fb} but ${s.verified} of ${s.lines} lines verified`);
  return s;
}

Notice null standing for "none": the type string | null makes you handle the missing case, rather than comparing against the string "none" later. The two throws are the why of this function. Without the add-up check, a report could say lines: 6 with verified: 6 and broken: 1 and still look mostly fine on a page that only prints serving. Without the first_break agreement check, you could show first_break: none next to a partial verify and teach readers the wrong lesson about trust.

A common mistake is to leave serving and first_break as the literal strings "none" and then branch with === "none" in five places. That works until one call site forgets the comparison and prints the word none into the banner. Mapping to null once, at parse time, forces every later reader to handle absence explicitly.

Step 2: parse the log, and read each decision from its counts

export function parseLog(text: string): Entry[] {
  return text.split("\n").filter((l) => l.trim() !== "").map((raw, i) => {
    const o = JSON.parse(raw); // a line that is not JSON stops the build
    return { line: i + 1, event: String(o.event), version: String(o.version), detail: String(o.detail) };
  });
}

The page keeps only the four human-readable fields. It cannot use prev or mac without the key, so it does not pretend to. JSON.parse throws on a line that is not JSON, and that is allowed to stop the build. Note what that means: a log with a line that is not even JSON never reaches the "unverified" page; it is a build error instead. The "unverified but builds" path is for logs that parse but fail the chain.

The counts live inside the detail text, so a regular expression reads them:

const GATE = /^gate (pass|fail): (\d+)\/(\d+) correct \(min (\d+)%\)/;

function decision(e: Entry): Decision | null {
  if (e.event !== "promoted" && e.event !== "rolled_back") return null;
  const m = GATE.exec(e.detail);
  if (!m) return null; // e.g. "nothing to serve" or "cannot load": no counts to show
  return { line: e.line, event: e.event, version: e.version, gate: m[1] as "pass" | "fail", correct: Number(m[2]), total: Number(m[3]), minPct: Number(m[4]) };
}

// The gate rule modelgate applies: correct * 100 >= total * min (counts, never floats).
export const passes = (d: Decision) => d.correct * 100 >= d.total * d.minPct;

A rolled-back line can also have a detail such as nothing to serve: no last-good version or cannot load: .... Those have no counts, so decision returns null and they are not shown as a "last decision" (they still count as rollbacks, because the rollback count looks at the event name). The GATE regular expression is the only place counts enter the page, so if detail drifts from that shape, the line simply has no decision to show rather than inventing numbers. passes restates the integer rule the deployer used, because otherwise you cannot spot a line whose event says promoted while its own counts fail the minimum.

A common mistake is to write correct / total >= 0.9 with floating point and treat that as identical to the integer form. On twenty samples it often agrees, which is why the bug hides; the lab's challenge question asks you to look for a count pair where the two forms disagree. Prefer the integer test in any log you expect another machine to re-check.

Step 3: join everything and decide what is allowed to build

export function buildRelease(logText: string, statusText: string): Release {
  const status = parseStatus(statusText);
  const log = parseLog(logText);
  if (log.length !== status.lines) throw new Error(`log has ${log.length} lines but status says ${status.lines}`);
  const decisions = log.map(decision).filter((d): d is Decision => d !== null);
  const contradictions = decisions.filter(
    (d) => (d.event === "promoted") !== (d.gate === "pass") || (d.gate === "pass") !== passes(d),
  );
  const promotedLines = log.filter((e) => e.event === "promoted");
  const serving = promotedLines.length ? promotedLines[promotedLines.length - 1].version : null;
  if (serving !== status.serving) throw new Error(`log says serving ${serving ?? "none"} but status says ${status.serving ?? "none"}`);
  const trusted = status.broken === 0 && status.unreadable === 0 && status.verified === status.lines;
  // A verified log that contradicts itself cannot come from modelgate: the page refuses to build it.
  if (trusted && contradictions.length) throw new Error(`verified log contradicts itself at line ${contradictions[0].line}`);
  return {
    status, trusted, serving, last: decisions.length ? decisions[decisions.length - 1] : null,
    promoted: promotedLines.length, rolledBack: log.filter((e) => e.event === "rolled_back").length, contradictions,
  };
}

Read it as three groups of statements:

The trusted expression is stricter than it needs to be: parseStatus has already forced verified + broken + unreadable === lines, so verified === lines alone would imply the rest. Saying all three costs nothing and keeps the intent obvious at the one place that decides green.

Step 4: the static export

build.ts is a script, not a server. It reads every fixtures/*.log with its matching .status, builds the release, and writes dist/<name>.html. For directory listing and mkdir it imports node:fs; Bun's own file application programming interface (API) covers the reading and writing. Bun's documentation says Bun.file and Bun.write "are the recommended way to work with files in Bun" and that "for operations they don't cover, such as mkdir or readdir, use Bun's nearly complete implementation of the node:fs module". That is exactly the split in the script.

Every string from the log or the status report that reaches the HTML (the version names and first_break) goes through an escape function first, so a hostile version string is printed as text instead of becoming markup. The lab has no check for this; read the code to confirm it:

const esc = (s: string) =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

A common mistake is to sprinkle the ok class on the heading or the serving line as well as the banner. Then a page can look healthy in the wrong place while the banner is red, or the reverse. Keep the green text in exactly one place, behind exactly one condition:

function banner(r: Release): string {
  if (r.trusted)
    return `<p class="banner ok" data-banner="verified">Log verified: all ${r.status.lines} lines check out, so the numbers below are the record.</p>`;
  return `<p class="banner bad" data-banner="not-verified">This log did not verify: ${r.status.broken} broken and ${r.status.unreadable} unreadable of ${r.status.lines} lines, first break ${esc(r.status.firstBreak ?? "unknown")}. Do not trust the numbers below.</p>`;
}

The page also carries data-verdict, data-banner, data-counts, data-rollbacks and data-contradiction-line attributes. They are for the checker, not for styling: a test can read a stable attribute instead of scraping prose.

The build is run with bun run build, which runs the build script bun build.ts. The stylesheet colours the ok banner green (#2f6b3a on #e3f1e5) and the bad banner red.

Step 5: what the three pages say

The build prints one summary line per page (Bun 1.3.14):

after-v2.log -> dist/after-v2.html verdict=verified serving=v1 rolled_back=1 last=rolled_back v2 12/20
clean.log -> dist/clean.html verdict=verified serving=v3 rolled_back=1 last=promoted v3 19/20
edited.log -> dist/edited.html verdict=not-verified serving=v3 rolled_back=0 last=promoted v3 19/20

The edited page, in full:

<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>modelgate release</title>
<link rel="stylesheet" href="release.css"></head>
<body><main data-verdict="not-verified">
<h1>modelgate release</h1>
<p class="banner bad" data-banner="not-verified">This log did not verify: 1 broken and 0 unreadable of 6 lines, first break line 4 (mac). Do not trust the numbers below.</p>
<dl>
<dt>Serving</dt><dd data-serving>serving v3</dd>
<dt>Last decision</dt><dd><span data-last-event>promoted</span> v3: <span data-counts>19/20</span> correct (min 90%)</dd>
<dt>Rollbacks</dt><dd data-rollbacks="0">0 rolled back</dd>
<dt>Promotions</dt><dd data-promotions="3">3 promoted</dd>
</dl>
<h2>Lines that contradict themselves</h2>
<ul class="contradictions">
<li data-contradiction-line="4">Line 4: event promoted but its detail says gate fail at 12/20</li>
</ul>
</main></body></html>

Look at what is true and false here. serving v3 is accurate. 0 rolled back and 3 promoted are wrong (the real record has 1 and 2), because the forged line changed the counts. The only reliable things on this page are the red banner and the line-4 contradiction. That is the lesson in miniature: numbers that look plausible are not the verdict.

The page also refuses inputs it cannot reconcile. These are four deliberately bad inputs and what the build says:

status counts 5 lines, log has 6: threw: log has 6 lines but status says 5
status says serving v1, log says v3: threw: log says serving v3 but status says v1
verified log promotes at 12/20: threw: verified log contradicts itself at line 6
status does not add up: threw: status does not add up: 6 verified + 1 broken + 0 unreadable != 6 lines

Putting it together

Return to the opening problem: an edited log that turns a rollback into a promotion. The clean fixture is the honest path (green banner, serving v3, one rollback, last decision 19/20). The edited fixture is the same log with line 4 rewritten. Because the page never holds the key, it cannot re-seal the chain; it can only read the status report that says broken: 1 and first_break: line 4 (mac), paint the red banner, list the self-contradiction, and keep every number on that page under a "do not trust" label. The four throw cases close the other hole: if the two files disagree, no page is written at all. That is the whole design in one path: parse both inputs, compute your own counts, cross-check, and let green appear in exactly one branch of one function.

3. Lab

The lab is simulated and local: it needs Bun and a Portable Operating System Interface (POSIX) shell, and nothing else. No network, no key, no modelgate binary, no cloud account, so there is no cost and nothing to tear down. You turn a starter that shows only the serving version into the reference page from the worked example, and a checker tells you which property is still missing.

What you are given

A starter directory with release.ts, build.ts, release.css and a package.json whose build script is bun build.ts, plus a checker, check.sh (POSIX shell). The checker writes the three fixture pairs itself (after-v2, clean, edited: a .log and a .status each, taken from real modelgate output) so you do not need modelgate, a key, a network or anything beyond Bun.

The starter finds the serving version (the last promoted line) and writes a page that shows only serving <v>. It has no banner, no counts, no cross-check, and it never throws. Its output for the edited log is a page with a heading and serving v3, indistinguishable from a healthy one. That is the bug this lab is about.

Run it

Copy the starter to a scratch directory, copy check.sh into it, and run:

sh check.sh

The checker deletes and recreates dist/ and writes fixtures/ and consistency-probe.ts in the current directory, so run it only inside your copy. It prints one RESULT line per check, naming the check and ending in PASS or FAIL, and exits 1 if any fails.

On the starter you should see the first check pass and the other six fail. The captured gate run, with Bun 1.3.14 in a container with networking off, shows exactly that for the starter and seven passes for the reference:

## reference (rc=0, PASS)
RESULT static_build PASS
RESULT clean_log_page PASS
RESULT last_good_still_serving PASS
RESULT edited_log_not_verified PASS
RESULT contradiction_listed PASS
RESULT no_green_unless_verified PASS
RESULT disagreeing_numbers_fail_build PASS
## starter (rc=1, FAIL)
RESULT static_build PASS
RESULT clean_log_page FAIL
RESULT last_good_still_serving FAIL
RESULT edited_log_not_verified FAIL
RESULT contradiction_listed FAIL
RESULT no_green_unless_verified FAIL
RESULT disagreeing_numbers_fail_build FAIL

Read the two blocks as a before-and-after. The starter can still run bun run build, so static_build is green even when the page is dishonest; a passing compile is not a passing release page. Every other check fails on the starter because it never paints a banner, never shows counts, never lists the forged line, and never throws when status and log disagree. The reference flips that pattern: same three fixture pairs, seven passes, exit 0. If your run matches the starter block after you thought you finished, the missing property is almost always one of checks 4 to 7 rather than the HTML shell.

The seven checks

  1. static_build: bun run build succeeds and writes three pages.
  2. clean_log_page: the clean page says serving v3, 1 rolled back, shows 19/20, and carries the verified banner.
  3. last_good_still_serving: the after-v2 page says serving v1, last event rolled back, counts 12/20, 1 rolled back, verified banner.
  4. edited_log_not_verified: the edited page's banner is the not-verified one, says did not verify, names line 4 (mac), and has no verified banner.
  5. contradiction_listed: the edited page lists line 4 as contradicting itself.
  6. no_green_unless_verified: the two verified pages carry the green banner; the edited page carries none.
  7. disagreeing_numbers_fail_build: buildRelease throws on each of the four bad inputs above (it also confirms the good input does not throw).

Your tasks, in a sensible order

  1. Parse the status report. Add parseStatus with its two consistency throws. Then have buildRelease use it. Check 7 is still red, because nothing is cross-checked yet.
  2. Parse the whole log and compute promoted, rolledBack, last. Give the page the counts and the rollback line (checks 2 and 3).
  3. Add the banner. One function, one condition, the only place the ok class appears (checks 2, 4 and 6). Escape anything that came from a file.
  4. List contradictions (check 5). Use the integer rule; do not divide.
  5. Add the cross-checks that throw (check 7). Write the error messages so a person reading continuous integration (CI) output can tell which disagreement it was.

Expected state when done: sh check.sh prints seven PASS lines and exits 0.

A diagnostic the checker does not catch: an empty log turns green

The seven checks all pass on the reference, and the reference still has a gap. Feed it an empty log with a status report that agrees with it (a status that says lines: 0, verified: 0, broken: 0, unreadable: 0, first_break: none, serving: none). The two files agree, nothing contradicts anything, and the build succeeds:

empty.log -> dist/empty.html verdict=verified serving=null rolled_back=0 last=none
exit=0

The page it writes carries the green banner:

<p class="banner ok" data-banner="verified">Log verified: all 0 lines check out, so the numbers below are the record.</p>
<dl>
<dt>Serving</dt><dd data-serving>serving nothing</dd>

Every one of zero lines did verify, so the banner is not false, but a green page over "serving nothing" is the opposite of what an operator needs to see. The cause is that trusted only asks whether the chain checked out, not whether there is anything to trust. Stretch task: make the banner green only when the log verified and something is serving, give the empty case its own neutral banner, and add a check for it to check.sh so the starter-versus-reference gate keeps catching it.

Check yourself

Question 1
The edited page still says serving v3. Why is that correct, and why must you still not trust the page?
Click to reveal
Serving still comes from the last promoted line; the forged line is not that last promotion, so the version string can be right while rollback and promotion counts are wrong. The red banner and the line-4 contradiction are what you trust.
Question 2
Why does the build throw for a verified log that contradicts itself, but only list the contradiction for an unverified one?
Click to reveal
A verified-yet-contradicting log cannot come from modelgate, so the page refuses to publish it. An unverified log is already under a red banner; listing the bad line teaches the reader what failed without pretending the record is sealed.
Question 3
Which of these would the page catch, and which would only modelgate status with the key catch? (a) a line rewritten from rolled_back to promoted with its detail unchanged; (b) the same rewrite with the detail also changed to gate pass: 19/20 correct (min 90%); (c) a status file copied from a different log with the same line count and serving version. (a) the page: event disagrees with detail, and the status report should show a broken mac. (b) only modelgate status with the key: careful forgery of event and detail leaves no contradiction on the page. (c) neither cross-check catches it as written; bind status production to the same log in the same job.
Click to reveal
Question 4
Why is the integer gate correct * 100 >= total * min preferable to correct / total >= 0.9 in a log another machine will re-check?
Click to reveal
Floats differ across languages and libraries; integer compares on whole counts do not. Even when you cannot find a disagreeing pair on twenty samples, the integer form is the one two verifiers can reproduce bit-for-bit.
Question 5
An empty log with a matching empty status builds a green "all 0 lines" banner over "serving nothing". What did trusted forget to ask?
Click to reveal
It asked only whether the chain checked out, not whether anything is serving. Green should require verified and a real serving version; give empty its own neutral banner.

4. Other paths

The same page could be written in another language, and the same release record already exists in other systems. Two paths teach something here: Gleam, where the "never green unless verified" rule can be carried by a type, and a read-only mapping onto Kubernetes Deployments.

Gleam with Lustre (hypothetical sketch, not run)

The same page could be built in Gleam, with Lustre used purely as an HTML templating library. Lustre's documentation says: "It is also possible to use Lustre as a HTML templating library, without using its runtime features, by passing elements to functions like to_string_tree or to_document_string." That fits a static export: build the element tree, turn it into a string, write a file.

No Gleam version of the page was written or run for this lesson. What follows is only the decision logic, which has no Lustre in it, to show how the "never green unless verified" rule can be given a type of its own. Treat it as a sketch:

pub type Status {
  Status(lines: Int, verified: Int, broken: Int, unreadable: Int, first_break: String)
}

pub type Verdict {
  Verified(lines: Int)
  NotVerified(broken: Int, unreadable: Int, lines: Int, first_break: String)
}

pub fn verdict(s: Status) -> Verdict {
  case s.broken == 0 && s.unreadable == 0 && s.verified == s.lines {
    True -> Verified(s.lines)
    False -> NotVerified(s.broken, s.unreadable, s.lines, s.first_break)
  }
}

The banner code would then take a Verdict and produce the green markup only for Verified. TypeScript can model the same idea with a union of two object shapes and a kind field; the reference solution uses a plain boolean, which is simpler but lets any code read trusted without being forced to look at the failure details.

Two parts of Lustre's documentation matter for the rendering step. text turns "a Gleam string into a text node", and none is for "rendering nothing ... useful for conditional rendering", which is how you would omit the contradictions list when there is none. The page does not say whether text escapes its input, so check that before putting log-derived strings through it. unsafe_raw_html states plainly that "The provided HTML will not be escaped automatically", so never route log text through that one. This is the same duty esc performs in the TypeScript version.

Kubernetes (read-only mapping)

This page is the equivalent of reading a Deployment's rollout history and conditions, not of acting on them. In Kubernetes, kubectl rollout undo deployment/nginx-deployment rolls a Deployment back; the controller stops a bad rollout itself, bounded by maxUnavailable, which "Kubernetes by default sets ... to 25%"; and .spec.progressDeadlineSeconds only marks a stalled rollout. Compared with that:

Kubernetesmodelgate
readiness probe keeps a pod out of Serviceseval gate keeps a candidate from serving
old ReplicaSet (the controller object that owns a set of identical pods) keeps serving during a bad rolloutlast-good version keeps serving
ProgressDeadlineExceeded is reported, acting is left to a higher-level orchestratormodelgate decides and records the rollback itself
rollout history and conditionsthe sealed log, and this page

One thing the mapping does not cover: a swap in this lab is all-at-once. There is no canary and no traffic shifting.

For exam preparation, this lesson touches DOP-C02 Task Statement 5.3, "Troubleshoot system and application failures", and its skill "Analyzing failed deployments" (reading the log to explain why a swap was rejected) and the CKA topic of rolling updates and rollbacks.

Code in sections 2 and 3 is the lab's reference solution; the checker output is from a gate run with Bun 1.3.14. The Gleam sketch in this section was not run.

5. Citations