Hedronite · Curriculum · UIUX · Rust · Sat 2026-11-07

Never paint healthy after gave_up A UI status contract that refuses a lying banner

Last readable event wins. Assertive on gave_up. Promote stays disabled until healthy.

Lesson Class: Curriculum · B-UIUX · rung A
Tested with: Rust 1.98 · serde_json 1
Lab: lab/ · sh check.sh (10 RESULT lines) · CPU only, no network
Source: WCAG 2.2 SC 4.1.3 · WAI-ARIA Alert Pattern · MDN aria-live · DOP-C02 4.2 · CKA Troubleshooting · SRE ch.6

Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Rust 1.85 or newer. One online cargo vendor vendor. The zip has no vendored crates. Offline build after that command was checked for this lab on Rust 1.99. All lab requirements.

Never Greenwash Failure
Terminal failure events never yield state healthy.
One State, Every Field
Banner, can_act, and aria-live fan out from the same state.
Count the Unreadable
Readable plus unreadable equals every physical line.
A banner that can lie is a hole in the self-healing loop.

Author: Evan Eugene Pincham

B-UIUX · A · arc: self-healing-edge · langs: rust, typescript · DOP-4.2, CKA-TROUBLE-2

Concept

A service that restarts itself, rolls itself back, or refuses to publish a bad model writes down what it did. That record is usually an operator log: one JSON object per line, each with an event name such as restarting, rolled_back, gave_up, or promoted. People and screen readers do not read the log. They read a status surface, a banner and a button that sits on top of it. If that surface can say "healthy" while the log says the supervisor gave up, the self-healing loop has a hole at the one place a human looks.

The fix is to put a small, typed contract between the log and the page. The contract is a function of the log and nothing else. It has four ideas:

  1. The log is the only input. The banner is derived from the last readable event. No second source of truth, no cached "last known good".
  2. Healthy is earned by an exact match. Only completed and promoted yield healthy. A terminal failure (gave_up, job_failed, drift_failed, rolled_back) never does. An event name the reader has never seen falls through to degraded, so a new failure vocabulary cannot paint green by accident.
  3. One state fans out to every field. Banner colour, whether the user may act, and how urgently assistive technology announces the change all come from the same state value, so they cannot disagree with each other.
  4. Count what you cannot read. Blank lines, truncated JSON, objects without an event, and bytes that are not UTF-8 are tallied, and lines_read always equals readable plus unreadable.

The contract the tool prints:

state: healthy|recovering|degraded|gave_up
banner: ok|warn|error
headline: <one short sentence>
can_act: true|false
aria_live: off|polite|assertive
last_event: <event or none>
lines_read: N
lines_unreadable: M
statebannercan_actaria_livelast events that produce it
healthyoktrueoffcompleted, promoted
recoveringwarnfalsepoliterestarting, resumed, backoff_retry, candidate_started, killed, checkpointed, job_started, artifact_written
degradederrorfalseassertiverolled_back, any unrecognised event, no readable events
gave_uperrorfalseassertivegave_up, job_failed, drift_failed

The aria_live column follows how the W3C defines status messages: a change in content, without a change of context, that reports success, a waiting state, progress, or an error, and that must be programmatically determinable so assistive technology can present it without moving focus. MDN describes the three values: off does not announce unless focused, polite notifies without interrupting, and assertive presents immediately and may clear the speech queue. MDN also warns against assertive unless the interruption is imperative. That is why a restart in progress is polite and a gave-up supervisor is assertive: the first is information, the second changes what the user must do next.

The same shape applies to monitoring work on a cloud platform, where a dashboard panel built from logs and metrics has to detect issues rather than hide them (the DevOps Professional exam guide, task statement 4.2). It also matches the Kubernetes troubleshooting habit of reading a container's output stream and keeping malformed output visible instead of silent. The Site Reliability Engineering book's chapter on monitoring distributed systems separates symptoms from causes; the banner is a symptom surface, and a symptom surface that lies is worse than none.

What is real in this lesson: the state machine, the never-healthy-after-failure rule, can_act, the aria_live mapping, the unreadable-line counts, and the exit codes. What is simulated: the page that would bind the contract, the screen reader that would announce it, and any cloud dashboard. The Rust tool is a printer over fixture logs on a CPU, with no browser and no assistive-technology runtime in the checker.

Worked example

Two fixture logs. The first ends in promoted:

{"ts":1,"event":"candidate_started","version":"v2","detail":"probe"}
{"ts":2,"event":"promoted","version":"v2","detail":"live"}

The second is a supervisor that spent its restart budget:

{"ts":1,"event":"job_started","detail":"pid 1"}
{"ts":2,"event":"restarting","detail":"1/3"}
{"ts":3,"event":"restarting","detail":"2/3"}
{"ts":4,"event":"gave_up","detail":"3 restarts; escalating to a human"}

Running uistatus derive and uistatus allow on each produces this output from the reference build:

=== walkthrough 2026-11-07 B-UIUX ===
--- success derive ---
state: healthy
banner: ok
headline: Service reports ready (last event: promoted).
can_act: true
aria_live: off
last_event: promoted
lines_read: 2
lines_unreadable: 0
exit:0
--- success allow ---
can_act: true
state: healthy
exit:0
--- gaveup derive ---
state: gave_up
banner: error
headline: Service gave up (last event: gave_up).
can_act: false
aria_live: assertive
last_event: gave_up
lines_read: 4
lines_unreadable: 0
exit:0
--- gaveup allow ---
can_act: false
state: gave_up
exit:1

Two things to read off it. derive exits 0 in both cases: the log was readable, and "the service gave up" is data, not a tool failure. allow is the gate a promote button would call before enabling itself, so it carries the verdict in its exit code: 0 when can_act is true, 1 when false, 2 for a missing file or bad usage.

The whole decision is three small functions. They take a string and return a string or bool, and main builds every contract field from the same state value.

fn state_for(last: Option<&str>) -> &'static str {
    match last {
        Some("completed") | Some("promoted") => "healthy",
        Some("gave_up") | Some("job_failed") | Some("drift_failed") => "gave_up",
        Some("rolled_back") => "degraded",
        Some("restarting")
        | Some("resumed")
        | Some("backoff_retry")
        | Some("candidate_started")
        | Some("killed")
        | Some("checkpointed")
        | Some("job_started")
        | Some("artifact_written") => "recovering",
        Some(_) | None => "degraded",
    }
}

fn can_act_for(state: &str) -> bool {
    state == "healthy"
}

fn aria_live_for(state: &str) -> &'static str {
    match state {
        "healthy" => "off",
        "recovering" => "polite",
        "degraded" | "gave_up" => "assertive",
        _ => "polite",
    }
}

Reading state_for from the bottom up: the last arm, Some(_) | None, is what makes the function fail closed. can_act_for fails closed the same way, because it compares against the single word healthy and everything else, including a misspelled state, is false.

The counting happens in derive:

for raw in input.split(b'\n') {
    let raw = raw?;
    lines_read += 1;
    match read_line(&raw) {
        Ok(rec) => last_event = Some(rec.event),
        Err(()) => lines_unreadable += 1,
    }
}

lines_read is incremented before parsing, so every physical line is counted exactly once, and splitting on raw bytes means a line that is not UTF-8 becomes one unreadable line rather than aborting the read. read_line rejects non-UTF-8 bytes, blank lines, and anything serde_json cannot turn into a record with an event string. Other keys on the line are ignored, so a writer can add fields without breaking this reader. Order in the file is the clock: the last readable line wins, and ts is not consulted.

The damaged fixture mixes three readable lines with a truncated object, a blank line, plain text, and two bytes of binary. The reference run reports:

damaged: lines_read=7 unreadable=4 last=completed state=healthy

Seven physical lines, four unreadable, three readable, and the totals add up. The same output also shows a limit worth knowing. The last line of that file is unreadable and sits after the completed event, so the newest thing the service wrote may be a truncated gave_up. The contract counts the damage and still paints green, because the rule is "last readable event". A page binding this contract should display lines_unreadable next to the banner, and the lab below asks you to decide whether the contract itself should treat a trailing unreadable line as degraded.

Reading the code also settles two inputs the fixtures do not cover: a log with no readable events, and an event name the table has never seen. Both reach the Some(_) | None arm, so both are degraded, and banner_for and aria_live_for turn that into error and assertive.

Lab

The starter has the same file with three deliberately wrong functions:

/// BUG: always reports healthy. Fix: map last event to the real state table.
fn state_for(_last: Option<&str>) -> &'static str {
    "healthy"
}

/// BUG: always allows action. Fix: only healthy may act.
fn can_act_for(_state: &str) -> bool {
    true
}

/// BUG: never announces. Fix: off / polite / assertive from state.
fn aria_live_for(_state: &str) -> &'static str {
    "off"
}

Task. Implement the three functions so uistatus derive LOG and uistatus allow LOG match the table in the Concept section. The crates are vendored, so the build needs no network. Copy check.sh next to the crate's Cargo.toml and run sh check.sh. It extracts the vendored crates, builds offline, writes five small fixture logs plus a damaged one, and prints ten checker verdicts in all (build plus nine behaviours). Each verdict names the check and whether it succeeded.

Here is what the starter gets and what the reference gets:

RESULT build PASS
success derive: exit=0 state=healthy can_act=true aria_live=off last=promoted
RESULT success_is_healthy PASS
RESULT allow_success_exit_0 PASS
gaveup derive: exit=0 state=healthy can_act=true aria_live=off
RESULT never_healthy_after_gave_up FAIL
RESULT allow_gaveup_exit_1 FAIL
drift derive: state=healthy can_act=true last=drift_failed
RESULT drift_failed_never_healthy FAIL
recovering derive: state=healthy aria_live=off can_act=true
RESULT recovering_is_polite FAIL
RESULT rollback_is_degraded FAIL
RESULT damaged_totals PASS
RESULT missing_file_exit_2 PASS
RESULT build PASS
success derive: exit=0 state=healthy can_act=true aria_live=off last=promoted
RESULT success_is_healthy PASS
RESULT allow_success_exit_0 PASS
gaveup derive: exit=0 state=gave_up can_act=false aria_live=assertive
RESULT never_healthy_after_gave_up PASS
RESULT allow_gaveup_exit_1 PASS
drift derive: state=gave_up can_act=false last=drift_failed
RESULT drift_failed_never_healthy PASS
recovering derive: state=recovering aria_live=polite can_act=false
RESULT recovering_is_polite PASS
RESULT rollback_is_degraded PASS
RESULT damaged_totals PASS
RESULT missing_file_exit_2 PASS

The gate passes when the reference is all PASS and the starter fails. Notice which starter checks pass: the happy-path log, the damaged-log totals, and the missing file. A suite that only exercised success would have shipped the always-green stub. The failing checks are the ones that feed a failure into the surface and ask whether it stayed honest. The drift_failed fixture is the case where a training job refused to publish a model whose holdout score fell from 18/20 to 0/20; the banner must not call that healthy any more than it would a supervisor that gave up.

Stretch, no checker for these:

  1. Make a trailing unreadable line (one after the last readable event) downgrade healthy to degraded. Decide what the headline should say, and whether lines_unreadable alone is a sufficient signal.
  2. Replace the &'static str states with an enum State. The wildcard arms in aria_live_for and banner_for exist only because a string can hold any value; with an enum the compiler lists every state you forgot.
  3. Add an event name to the log that the table has never seen, predict the contract, then run it.

Other paths

The same contract can be expressed in another language and bound into markup without changing the rules. Both sketches below are labelled not run: they teach the mapping, and the lab still grades the Rust binary.

TypeScript sketch (not run).

The same mapping in TypeScript, as a page build step would hold it. This block is a sketch and has not been compiled or executed. Using Record<State, ...> means adding a fifth state is a compile error until every table is updated, which is the enum exercise above done for free.

// sketch, not run
type State = "healthy" | "recovering" | "degraded" | "gave_up";
type Aria = "off" | "polite" | "assertive";

const HEALTHY = new Set(["completed", "promoted"]);
const GAVE_UP = new Set(["gave_up", "job_failed", "drift_failed"]);
const RECOVERING = new Set([
  "restarting", "resumed", "backoff_retry", "candidate_started",
  "killed", "checkpointed", "job_started", "artifact_written",
]);

function stateFor(last: string | null): State {
  if (last === null) return "degraded";
  if (HEALTHY.has(last)) return "healthy";
  if (GAVE_UP.has(last)) return "gave_up";
  if (last === "rolled_back") return "degraded";
  if (RECOVERING.has(last)) return "recovering";
  return "degraded"; // unknown event: fail closed
}

const ARIA: Record<State, Aria> = {
  healthy: "off",
  recovering: "polite",
  degraded: "assertive",
  gave_up: "assertive",
};

const BANNER: Record<State, "ok" | "warn" | "error"> = {
  healthy: "ok",
  recovering: "warn",
  degraded: "error",
  gave_up: "error",
};

const canAct = (s: State): boolean => s === "healthy";

Binding the contract in HTML (not run).

The page should copy contract fields into the DOM and nothing else; it should not recompute state. Writing aria-live explicitly, from the aria_live field, lets a build step compare the attribute against the contract and fail on a mismatch.

<!-- sketch, not run: gave_up, 4 lines read, 0 unreadable -->
<section class="banner banner--error" role="alert" aria-live="assertive">
  <p>Service gave up (last event: gave_up).</p>
</section>
<button type="button" disabled>Promote</button>
<p>Log lines: 4 read, 0 unreadable.</p>

<!-- sketch, not run: recovering -->
<section class="banner banner--warn" role="status" aria-live="polite">
  <p>Service is recovering (last event: resumed).</p>
</section>

Three rules come from the sources. WCAG 2.2 success criterion 4.1.3 puts errors and warnings in the case where role="alert" or a live region is the way to make them programmatically determinable. The ARIA Authoring Practices alert pattern says dynamically rendered alerts are announced by most screen readers, do not move keyboard focus, and should not disappear on their own, so the banner must be updated in place and stay until the log changes. And disabled on the promote button follows can_act, because the same state that sets the banner colour decides whether the action exists. None of this has been tried with a screen reader; the lab checks the contract, not the page.

Citations