Skip to content

Release: the board becomes an instrument, and a machine can always rejoin - #86

Merged
aenawi merged 7 commits into
mainfrom
develop
Sep 13, 2026
Merged

aenawi merged 7 commits into
mainfrom
develop

Conversation

@aenawi

@aenawi aenawi commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Target

  • This is going into develop: the normal case for a fix, a chapter, a
    lab change, a typo
  • This is a release: develop → main, shipping to the published guide
  • This is a hotfix: straight into main, because published advice is
    currently locking readers out or leaving them exposed

What this changes

Seven commits since #76. Six of them are one sequence — the board stops being a
fixed picture drawn by one page and becomes an instrument that can be themed,
fed, docked, recorded and played back with no lab behind it. The seventh is the
bug that sequence kept tripping over: once Tailscale SSH had been switched on,
no machine in the lab could rejoin the tailnet.

#77 The board's nine colours become --board-*, and a theme is a folder the server finds at startup. Two themes ship: chapter overrides nothing, warroom is amber on dark.
#78 Four SSE endpoints become one wire, /api/stream/hud, in one envelope — {seq, at, type, data}. Every Result the API returns is published on it.
#79 Four docks around the board, and a card in one is a widget: a folder with widget.json, widget.js, widget.css. Where they go is the theme's business.
#80 A recording is a .jsonl of that wire, and ui/replay.html plays one back on the same board with the same set-pieces, asking no server anything.
#83 The HUD overlay measured three sides and assumed the fourth, so #79's bottom dock drew the verdict behind a card and hid the transcript. Closes #81.
#84 The frame→drawing mapping existed in two pages. hud/driver.js is now the one copy, and the board reads the feed. Closes #82.
#85 Every tailscale up carries --reset, so a preference left in a ts-* volume can no longer refuse every later join. Closes #74.

No chapter, no page under assets/, no stylesheet a reader loads and no
navigation entry is touched. Nothing on the published guide's pages changes.
What moves is the lab: lab/control (37 files), lab/checks, lab/Makefile,
lab/images/node/entrypoint.sh, lab/README.md and lab/design/five-gates.html.

Kind

  • Prose: clarity, typo, dead link
  • The advice was wrong, unsafe, or out of date — Say the whole command, so a machine can always rejoin #85, the lab's own join path
  • Lab or sandbox: code, checks, a new scenario
  • A divergence between the lab and the sandbox, closed or newly documented
  • Tests
  • Something else

What shipped, in detail

#77 — give the board a palette of its own. readTokens() in hud/scene.js
already painted from nine custom properties read off the page, which is how the
light/dark button moves the drawing with it. What was missing was somewhere for
a theme to stand. The nine become --board-*, declared once in style.css and
each defaulting to the page token it used to name, so the light/dark path is
unchanged — a var() resolves where it is used, not where it is declared. A
theme is a folder under ui/hud/themes/ with theme.json and theme.css;
themes.go walks the embedded UI at startup and serves what it found at
/api/themes, the same shape panels.go already had. A theme that does not
parse, or whose id disagrees with its folder, is skipped with a line at the
console and the rest of the store still loads. A test fails if chapter ever
starts overriding anything, because a second copy of those nine colours is the
copy that goes stale.

#78 — put the whole lab on one wire. The live half was four SSE endpoints —
status, tcpdump, logs, stats — and each consumer opened the one it wanted and
parsed it for itself. Now there is /api/stream/hud and one envelope, with ten
types listed in an opening hello so a consumer can ask rather than assume.
seq and the timestamp are both there for replay, and every data: line is
complete on its own, so a .jsonl of them is a recording with no framing to
reconstruct. Two things are carried that were not on a stream before: every
Result the API returns is published as verdict (action, set, preset) or
flow (probe), with the id of what ran beside it — the set-piece a replay has
to pick is chosen by id and cannot be inferred from a Result's shape. The
board still draws from the POST reply, so the feed is not in the drawing path
and cannot break it. The two sources that cost a process inside a container are
asked for in the URL (?capture=evil-box, ?logs=lab-ubuntu) rather than
turned on by a POST, so there is no subscription state on the server and a
recording's URL says what was being watched. On the page, ui/feed.js owns the
one EventSource; want() is reference counted, so two things wanting the
capture is one capture.

#79 — dock cards around the board. The page's furniture was in
index.html, so adding a readout meant editing markup, stylesheet and app.js
— three files and a fork for anybody who wants a different instrument on the
same lab. There are four docks now, and a card in one is a widget: a folder
under ui/hud/widgets/ with widget.json, widget.js and a widget.css
scoped to its own card. widgets.go walks the embedded UI at startup and serves
the registry at /api/widgets. What a widget is handed is the whole seam — a
div, the feed, and the options its placement carried. No board object: the board
is twenty-five coupled methods over 1300 lines of three.js, and publishing it as
a plugin API would freeze hud/scene.js the day somebody wrote against it.
theme.json grows a layout, the server prunes it against the registry at
startup and says why at the console, so the page only mounts placements that can
be built. Three widgets ship, all reading the feed and nothing else: wire (the
feed reading itself, including what the server threw away because a reader could
not keep up), tape (every frame written down), and tally (verdicts counted
by the rung that decided them, allowed and denied apart — "rung 3 allowed eleven
and denied none" is a finding, "rung 3 answered eleven times" is not).

#80 — write the wire to a file, and play it back with no lab. Two ways to
make a recording: make record curls the wire and strips the SSE framing, and
the record button in the live bar writes down the frames already arriving,
so a recording is exactly what that page saw. The button opens no connection and
starts no process, which is why it sits above the busy guard — the minute worth
recording is the minute every other button is disabled. The opening hello grew
a page (the /api/meta payload, the theme store, the widget registry), which
is what makes a file complete: the player has no server to ask, so a recording
carrying only events could not say which machines to cut doorways for or where
the cards go. The player is deliberately a viewer and not a control surface — no
switches, no attacks, because a recording has nothing to press them against —
but it has a transport and a list of every moment to jump to. Three things that
would be wrong invisibly rather than loudly are tested: a scrub is not a fast
replay (seeking re-applies the snapshots that were standing and skips the
packets and verdicts between); the hello is a header, not a moment; and the
snapshots a recorder seeds from are stamped when they were written, with the
measurement's own clock kept as wasAt — a seven-second recording claimed
forty-two before this.

#83 — measure the fourth side too. The HUD overlay inset itself by whatever
the furniture covered on three sides and by a flat 12px on the fourth. Since #79
gave a theme a bottom dock, that fourth side has something under it: on Warroom
the verdict panel was drawn 30px behind the tally card and the transcript — the
one line on the board that is a command rather than a rendering of a number —
was inside it completely. --pad-b could not simply be added, because it is
measured against the window (the page verdict and view controls have to clear
the rung ladder, which sits below the viewport rather than over it) while the
overlay is laid out inside the viewport. So the number the camera already gets —
what covers the board, not what is occupied at the foot of the window — is
published as --pad-b-canvas and read there. The other three sides need no
second token: the viewport starts at the top-left corner of the window, so there
the two rectangles agree.

#84 — make the board read the feed. The mapping from a frame on the wire to
a drawing on the board existed in two places: app.js made it from the replies
to its own POSTs, replay-app.js from the frames in a file. Same wire, same four
types, two copies — and a copy that drifts is a recording that stops looking like
the session it recorded. hud/driver.js is now the one copy. It takes
{ board, feed } and subscribes: state to setState, a verdict that named
an action to its set-piece, flow to probe, packet to whatever the shot
asked to watch. feed is the widget contract's feed and nothing more, so
window.LabFeed satisfies it and so does the player replay.js builds out of a
file — which is what lets the same three listeners serve a lab and a recording.
Two rules the copies agreed on are now owned in one place and pinned by tests:
set and preset draw no shot (a switch moves the board by changing the lab and
letting the next snapshot redraw it; a driver that played a shot per verdict
would draw something no reader ever saw), and a held frame outranks a snapshot
(a snapshot arriving under a held frame waits and is kept rather than dropped,
so it goes on before the next shot rather than leaving the board standing on the
configuration from before the last one).

#85 — say the whole command, so a machine can always rejoin. tailscale up
refuses to run when the preferences already on the machine carry a non-default
flag the command does not mention, and those preferences live in the ts-*
volume, so they outlive the container. One tailscale set --ssh from the Tailnet
policy panel was therefore enough to make every later join fail — at boot, on a
configuration, on a reset, on a key rotation — with make reset the only way
back and no sign of why. Every up now carries --reset, which makes the
command the whole statement of intent rather than a delta somebody can refuse.
--reset also clears the one setting the lab means to keep, so
setTailscaleSSH becomes the single place that owns Tailscale SSH: it sets it on
every machine that is up and leaves the same fact in /lab/state/tailscale-ssh,
which the entrypoint names explicitly on its own join. A machine restarted on its
own now comes back the way the switch says it is.

If this changes advice or a command

No command a reader of the published guide runs changes, and no chapter is
touched. What changes is the lab's own behaviour and what it reports, which
lab/README.md and lab/design/five-gates.html document — both moved with it:
the README gains make record and the new files, and the five-gates API table
now describes the one wire in place of the three streams it replaced.

OS: macOS 26.6.2
Docker 29.7.2, compose v5.5.1, Headscale 0.29.3, tailscale 1.102.4
Chrome 147 / Chromium, 1440x760 and 1600x1000
Measured on: 2026-09-12 and 2026-09-13

What was run for this release PR itself, and what it printed:

$ make -C lab check
licences
links
go vet
go test
gofmt
ui
...
ℹ tests 59
ℹ pass 59
ℹ fail 0

Everything held.

$ git merge-base --is-ancestor main develop
NO
$ git diff --stat $(git merge-base main develop) main
(empty — main carries no content develop does not)

Each commit carries its own measurement against the running lab; the ones that
decide whether this is releasable:

#85, the join path — tailscale 1.102.4, headscale 0.29.3, 2026-09-13:
  RunSSH true, all three machines Stopped, apply `hardened`
  → all three Running with RunSSH true
  recreate all three containers → all join first time, no retries
  → all eleven audit checks pass afterwards

#84, one driver, two pages — control rebuilt from this branch, 2026-09-13:
  probe drew from the `flow` frame (rung 5, "direct · punched")
  scan-public and scan-tailnet drew their set-pieces from `verdict` frames
  a switch flipped under scan-public's held frame left the frame intact
  a 39-frame recording of that session, opened in replay.html, drew the
  same sniff and probe off the same driver, twice, empty console

#83, the fourth side — Chrome 147 at 1440x760, Warroom, after a probe:
                      before             after       tally card
    #hud-verdict      393-514            393-514     597-700
    #hud-transcript   670-698 (inside)   557-585
    overlay pad-b     12px               125px
  default page unchanged: --pad-b-canvas 0px, padding 12px,
  transcript at 670-698 exactly as before

#79, the docks — Chromium 1600x1000, 2026-09-12:
  chapter: no layer in the DOM, no cards, --rail-* and --pad-* equal
  warroom: three cards, three stylesheets, --pad-r 616 vs --rail-r 346
  probe from lab-ubuntu reached rung 5, tally read 1/0
  attacker to :22 denied at rung 4, tally read 0/1 — a denial, not a tick
  at 820px the cards stack at the foot with no horizontal scroll
  no overlapping rectangles; only console error is the pre-existing favicon 404

#78, one wire — against the running lab:
  904 events, no gaps in seq; sniff verdict and a probe's flow both landing
  each text pane got its own banner and not the other's
  two toggles in one turn cost one reconnect; no console errors

#77, the palette — 2026-09-12:
  /api/themes serves both; selecting warroom moves --board-accent
  #0d7a6f → #e8a33d while --accent is untouched; the theme button
  afterwards changes the ground and leaves the palette
  • I checked whether any other chapter makes the same claim — no chapter is
    touched; the two places that described the streams the lab serves
    (lab/README.md, lab/design/five-gates.html) both moved with Put the whole lab on one wire, so a recording is one file #78
  • If this step can lock someone out of a machine, the warning sits before the step
  • The mechanism is explained, not just the command

No score moved, so the comparison table in lab/README.md is unchanged. The
audit still reads 11/11 on hardened.

Checks

  • make -C lab check passes
  • gofmt is clean (make check fails on any output at all)
  • New source files carry an SPDX-License-Identifier header — the licence
    check is the first thing make check runs, and it passed over every new
    theme, widget, driver and replay file

If this touches the guide's pages

Not applicable — no file under assets/, no chapter and no navigation entry is
touched. The pages this release does add (ui/replay.html) are the lab's
control server's own, served over HTTP from the embedded FS.

  • It still works from file://: no build step, no fetch, no new dependency
  • It still reads with JavaScript off
  • Any new simulation has a data-poster frame chosen deliberately
  • New or moved chapters are registered in assets/nav.js

No dependency, build step or framework was added anywhere: the theme store, the
widget registry and the player are plain modules the Go binary embeds, and the
JavaScript half of make check now finds the widget folders rather than
carrying a list of them. ui/replay.html needs an HTTP origin rather than a
lab — python3 -m http.server over lab/control/ui serves it with Docker not
installed. It cannot be a double-click, because <script type="module"> is
blocked on file: and the board is a module.

If this touches the lab or sandbox engine

  • Nothing is drawn that was not measured
  • A failed attack does not render as a green tick
  • danger still outranks ok
  • Time is not faked — Write the wire to a file, and play it back with no lab #80 is the commit that makes this explicit: a
    recording's seeded snapshots are stamped when they were written, with the
    measurement's own clock kept as wasAt, and a scrub re-applies standing
    snapshots rather than replaying at 60×
  • The engine still returns the rung that decided the outcome, not a bare pass/fail
  • If a score moved, the comparison table in lab/README.md moved with it — no
    score moved

Licence

  • I have the right to submit this, and I am fine with it going out under
    CC BY-SA 4.0 (guide) or GPL-3.0-or-later (code), per the file I touched

The board has never carried colours. readTokens() in hud/scene.js reads nine
custom properties off the page and paints from what it read, which is how the
light/dark button moves the drawing with it. That was already the whole
mechanism a theme needs; what was missing was somewhere for one to stand.

So the nine become --board-*, declared once in style.css and each defaulting to
the page token it used to name. Nothing about the light/dark path changed: the
defaults follow the page into both grounds, because a var() resolves where it
is used rather than where it is declared. What is new is that a stylesheet can
now redefine them without reaching past the drawing and repainting the panels
around it.

A theme is a folder under ui/hud/themes/ with theme.json and theme.css in it.
themes.go walks the embedded UI at startup and serves what it found at
/api/themes, so the picker is populated from the server rather than from a list
in the markup — adding a theme is a folder and a rebuild, and nothing else is
edited. That is the same shape panels.go already has.

Two themes ship. `chapter` overrides nothing and is the board exactly as it
was; a test fails if it ever starts to, because a second copy of those nine
colours is the copy that goes stale. `warroom` is amber on a dark ground, and
exists so the seam is demonstrated rather than asserted.

A theme that does not parse, or whose id disagrees with its folder, is skipped
with a line at the console and the rest of the store still loads — the choice
missing() already makes for set-pieces, for the same reason.

theme.json's `ground` is advisory: selecting a theme drawn for dark moves the
page there, and the light/dark button still wins afterwards.

Measured against the running lab on 2026-09-12: /api/themes serves both, both
stylesheets are served, selecting warroom moves --board-accent from #0d7a6f to
#e8a33d while --accent is untouched, pressing the theme button afterwards
changes the ground and leaves the palette, and selecting chapter again leaves
--board-accent and --accent identical. No console errors.
The live half of this server was four SSE endpoints — status, tcpdump, logs,
stats — and each consumer opened the one it wanted and parsed it for itself.
Four places that know how to read the lab, which is four things a widget
somebody else writes has to get right before it can draw anything, and four
files a recording would have to be stitched out of.

Now there is /api/stream/hud and one envelope: {seq, at, type, data}. Ten types,
listed in an opening hello so a consumer can ask rather than assume. Seq and the
timestamp are both there for replay — the order, and how long apart — and every
data: line is complete on its own, so a `.jsonl` of them is the recording with
no framing to reconstruct.

Two things carried that were not on a stream before. Every Result the API
returns is published as `verdict` (action, set, preset) or `flow` (probe), with
the id of what ran travelling beside it, because the set-piece a replay has to
pick is chosen by id and cannot be inferred from a Result's shape. The board
still draws from the POST reply, so the feed is not in the drawing path and
cannot break it; what it adds is that something which did not press the button
can see what the button did.

The two sources that cost a process inside a container are asked for in the URL
rather than turned on by a POST — ?capture=evil-box, ?logs=lab-ubuntu. No
subscription state on the server, and a recording whose URL says what was being
watched.

A note now says which source it came from. On four streams it could only have
come from the stream it arrived on; on one wire, a capture pane headed
`tail -f /var/log/tailscaled.log` is what dropping that field gets you.

On the page, ui/feed.js owns the one EventSource and the four call sites that
used to open their own now share it: on() to listen, want() to ask for an
expensive source — reference counted, so two things wanting the capture is one
capture — and status() for what the connection is doing. A classic script loaded
before app.js, because the readouts read the lab from their first line.

Nothing looks different. Measured against the running lab: 904 events on one
wire with no gaps in seq, the sniff verdict and a probe's flow both landing on
it, each text pane getting its own banner and not the other's, two toggles in
one turn costing one reconnect, and no console errors.
The page's furniture was in index.html. Adding a readout meant editing the
markup, the stylesheet and app.js, which is three files and a fork for anybody
who wants a different instrument on the same lab. The feed made the data
shareable last week; this makes the layout data too.

There are four docks now — top, left, right, bottom — and a card in one of them
is a widget: a folder under ui/hud/widgets/ with a widget.json saying what it
is, a widget.js, and a widget.css scoped to its own card. widgets.go walks the
embedded UI at startup and serves the registry at /api/widgets, so adding a
widget is a folder and a rebuild. The same shape as the theme store, and
panels.go before it.

What a widget is handed is the whole seam: a div, the feed, and the options its
placement carried. No board object, no page elements, no controller. The board
is twenty-five coupled methods over 1300 lines of three.js and publishing it as
a plugin API would freeze hud/scene.js the day somebody wrote against it — a
widget that wants to know what the lab is doing reads the feed, which is what
the feed is for.

Where a card goes is the theme's business. theme.json grows a layout, the server
prunes it against the registry at startup — unknown widget, or a dock the widget
does not fit — and each drop says why at the console, so the page only mounts
placements that can be built. warroom docks three; chapter docks none, for the
same reason its stylesheet overrides nothing, and a test fails if either starts
to.

Three widgets to have something real in the docks, all three reading the feed
and nothing else. wire is the feed reading itself — open or not, how far the
sequence has got, and what the server threw away because a reader could not keep
up, because a recording made through a gap is worth knowing about before it is
posted. tape is every frame written down, which is the closest thing on the page
to what a replay will work from. tally counts verdicts by the rung that decided
them, allowed and denied apart: "rung 3 allowed eleven and denied none" is a
finding and "rung 3 answered eleven times" is not.

The board is not a widget and does not pretend to be. app.js still creates it
and drives it; the docks' part is reporting what they cover so the camera frames
the diagram in the gap the cards leave. Making it a real widget under the
contract above means the board reading the feed instead of being called, which
is the next piece of work and not this one — a privileged contract that handed
one widget the board object would be the plugin API this change exists to avoid.

One trap, found by driving it. --pad-* is measured against the canvas and
consumed against the window, and those differ by the height of the rung ladder,
which sits below the viewport rather than over it. A card measured one way and
positioned the other is short by exactly one ladder, which put the verdict 40px
inside the bottom card. So the docks are measured twice, against both
rectangles, and --rail-* is split out from --pad-*: the docks are positioned
from the furniture alone, because a dock positioned from a number that includes
its own size is a layout that never settles.

Measured against the running lab on 2026-09-12, Chromium 1600x1000. chapter: no
layer in the DOM, no cards, --rail-* and --pad-* equal. warroom: three cards in
three docks, three stylesheets loaded, --pad-r 616 against --rail-r 346. A probe
from lab-ubuntu reached rung 5 and the tally read 1/0; the attacker to :22 was
denied at rung 4 and the tally read 0/1, which is a denial and not a tick.
Folding the readouts rail moved the tape card 984..1244 -> 1276..1536 and the
board re-framed. At 820px the cards stack at the foot of the page with no
horizontal scroll. Verdict, view controls and the board's gauges clear every
card: no overlapping rectangles. The only console error on the page is the
favicon 404 it already had.

make -C lab check passes: ten new Go tests, and the JavaScript parse check now
finds the widget folders rather than carrying a list of them, since a list here
would have been the third place to edit and the one nobody remembers.
Branch 4 of the HUD sequence, and the one the other three were for. A
recording is a `.jsonl` of /api/stream/hud — one JSON object per line — and
`ui/replay.html` reads one and draws it on the same board, with the same
set-pieces and the same cards a theme docked, asking no server anything.

Two ways to make one. `make record` curls the wire and strips the SSE framing;
the **record** button in the live page's bar writes down the frames already
arriving, so a recording is exactly what that page saw — including the capture
somebody had switched on. It opens no connection and starts no process in a
container, which is why it sits above the busy guard: the minute worth
recording is the minute every other button is disabled.

The opening `hello` grew a `page`: the /api/meta payload, the theme store and
the widget registry. That is what makes a file complete — the player has no
server to ask, so a recording carrying only events could not say which machines
to cut doorways for or where the cards go. `metaPayload()` is now one function
rather than two literals, because the copy that drifts is the one in the
recording and nothing would say so.

The player is a viewer and deliberately not a control surface: no switches, no
attacks, because a recording has nothing to press them against. What is there
is a transport, and a list of the moments — every Result the lab returned — to
jump to.

Three things that are wrong invisibly rather than loudly, so they are tested:

- A scrub is not a fast replay. The snapshots are sent only when they changed,
  so seeking re-applies the ones that were standing and skips the packets and
  verdicts between — replaying at 60× would fire every set-piece it passed.
- The hello is a header, not a moment. On a recording of a session already in
  progress it is older than everything else in the file.
- The snapshots a recorder seeds from can be ten minutes old. They are stamped
  when they were written, with the measurement's own clock kept as `wasAt` —
  driven against the live lab, a seven-second recording claimed forty-two
  before this.

`hud/scene.js` now owns `refs()` and `HUD_IDS`, because two pages create boards
and eighteen querySelector calls copied into the second is how one quietly
loses a readout. A check asserts every id is in both pages.

`wire.js` holds the two lists both feed.js and replay.js read — what the wire
carries, and which of those are snapshots rather than moments. A stale second
copy of the latter is a player drawing yesterday's board after every scrub.

The player needs an HTTP origin and not a lab: `python3 -m http.server` over
`lab/control/ui` serves it with Docker not installed. It cannot be a
double-click, because `<script type="module">` is blocked on `file:` and the
board is a module.
…rd (#83)

The HUD overlay insets itself by whatever the furniture is covering on
three sides and by a flat 12px on the fourth. Since #79 gave a theme a
bottom dock, that fourth side has something under it: on Warroom the
verdict panel was drawn 30px behind the tally card and the transcript —
the one line on the board that is a command rather than a rendering of a
number — was inside it completely.

--pad-b could not simply be added to the padding. It is measured against
the window, because the page verdict and the view controls have to clear
the rung ladder, and the ladder sits below the viewport rather than over
it. The overlay is laid out inside the viewport, so reading --pad-b there
clears the ladder a second time and lifts the whole bottom stack a ladder
higher than the card it only had to clear by a gap.

So publish the number the camera already gets — what covers the board,
not what is occupied at the foot of the window — as --pad-b-canvas, and
read that. The other three sides need no second token: the viewport
starts at the top-left corner of the window, so there the two rectangles
agree.

Measured in Chrome 147 at 1440x760 on 2026-09-13, Warroom, after a probe:

                    before            after       tally card
  #hud-verdict      393-514           393-514     597-700
  #hud-transcript   670-698  (inside) 557-585
  overlay pad-b     12px              125px

The default page is untouched: with nothing docked --pad-b-canvas is 0px
and the padding stays at 12px, the transcript at 670-698 exactly as
before. replay.html gets the same treatment, where the thing below the
board is the transport rather than a card.

The gauges do not move. The top row is an `auto` row above a `1fr`, so
the padding comes out of the gap; at 625px — one pixel above the height
where the layout floats and the question stops applying — the overlay
already overflows with the old rule and both rules put the bottom stack
in the same place.

Closes #81
The mapping from a frame on the wire to a drawing on the board existed in
two places. app.js made it from the replies to its own POSTs; replay-app.js
made it from the frames in a file. Same wire, same four types, two copies —
and a copy that drifts is a recording that stops looking like the session it
recorded.

hud/driver.js is now the one copy. It takes { board, feed } and subscribes:
state to setState, a verdict that named an action to its set-piece, flow to
probe, and packet to whatever the shot asked to watch. `feed` is the widget
contract's feed and nothing more, so window.LabFeed satisfies it and so does
the player replay.js builds out of a file — which is what lets the same
three listeners serve a lab and a recording.

What the pages keep is what is genuinely theirs. app.js keeps the POSTs, the
busy guard, the text trace, the tabs and the rails; it reads the lab back
after a change and hands the snapshot to driver.state() rather than to the
board, because a switch has to move the picture without waiting for the poll.
replay-app.js keeps the transport, the moments and the verdict line — the
sentence lands even with no WebGL to draw the shot on.

Two rules the copies agreed on, now owned in one place and pinned by tests:

  `set` and `preset` draw no shot. A switch moves the board by changing the
  lab and letting the next snapshot redraw it; the board has never rendered
  one as an event, and a driver that played a shot per verdict would draw
  something no reader ever saw.

  A held frame outranks a snapshot. run() calls hold() the moment a shot
  starts and setState repaints the standing board underneath it, so a
  snapshot arriving under a held frame waits — and it is kept rather than
  dropped, so it goes on before the next shot rather than leaving the board
  standing on the configuration from before the last one. `stat` is the
  heartbeat that releases it when nothing else follows, which is the case
  where a switch flipped under a held frame used to half-wipe the frame on
  the live page and be dropped outright on the player.

Driven against the real lab on 2026-09-13, control rebuilt from this branch:
a probe drew from the `flow` frame (rung 5, "direct · punched"), scan-public
and scan-tailnet drew their set-pieces from `verdict` frames, a switch
flipped under scan-public's held frame left the frame intact, and the flat
view still draws nothing while the text trace lands. A 39-frame recording of
that session, opened in replay.html, drew the same sniff and probe off the
same driver, twice, with an empty console.

Closes #82
`tailscale up` refuses to run when the preferences already on the machine
carry a non-default flag the command does not mention, and those preferences
live in the ts-* volume, so they outlive the container. One `tailscale set
--ssh` from the Tailnet policy panel was therefore enough to make every later
join in the lab fail — at boot, on a configuration, on a reset, on a key
rotation — with `make reset` the only way back, and no sign of why.

Every `up` now carries --reset, which makes the command the whole statement of
intent rather than a delta somebody can refuse. --reset also clears the one
setting the lab means to keep, so setTailscaleSSH becomes the single place that
owns Tailscale SSH: it sets it on every machine that is up and leaves the same
fact in /lab/state/tailscale-ssh, which the entrypoint names explicitly on its
own join. A machine restarted on its own now comes back the way the switch says
it is.

Measured on this host against tailscale 1.102.4 and headscale 0.29.3 on
2026-09-13: with RunSSH true and all three machines Stopped, applying
`hardened` brings all three back to Running with RunSSH true; recreating all
three containers has them join first time, no retries; all eleven audit checks
pass afterwards.
@aenawi
aenawi merged commit eda75f4 into main Sep 13, 2026
2 checks passed
aenawi added a commit that referenced this pull request Sep 13, 2026
Back-merge main after the #86 release, so develop contains it
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant