Skip to content

implement-loop 20260905-094605: #423, #430, #431, #433, #435, #436, #437, #438, #439, #441, #442 - #453

Merged
iot49 merged 23 commits into
mainfrom
implement-loop/20260905-094605-reconciled
Sep 5, 2026
Merged

implement-loop 20260905-094605: #423, #430, #431, #433, #435, #436, #437, #438, #439, #441, #442#453
iot49 merged 23 commits into
mainfrom
implement-loop/20260905-094605-reconciled

Conversation

@iot49

@iot49 iot49 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The implement-loop batch of 2026-09-05 09:46, landed by hand after the run
was stopped early.

Each issue below passed ./scripts/check.sh and a cold-agent audit during the
run. The whole range then passed the gate again after reconciliation, and the
range review the run never reached is below.

issue what
#423 the gate fails without a broker instead of emptying itself
#430 one connect loop and one set of the startup numbers, for all six apps
#431 cold start gets a glossary entry
#433 the glossary's topics are checked against the inventory
#435 a cut leaves the traction rows at rest
#436 a hand-over skips the trains this app is driving
#437 a gesture-minted request id carries a per-process nonce — the audit rated this one RISK
#438 ADR-0062 records the window its guard reads through
#439 the reversal guard reads requests off the bus
#441 the OFF wait is dropped by the run word changing, not by its value
#442 the store directory is resolved the way compose resolves it

Held rather than auto-merged. The range touches .github/workflows/ci.yml
(#423), which is on the run's protected list: a batch that can change what CI
runs does not get to merge on CI being green.

Why this branch has -reconciled in its name. A session working in the
same checkout switched branches under the running loop at about 12:05. The run
did not notice: #439, #441 and #442 were committed onto that other branch
instead of onto implement-loop/20260905-094605, which stopped at #438. No
work was lost and nothing wrong was pushed — the other branch went to main
as #452 carrying only its own commit. This branch is the run's work put back
in order: #423#438 rebased onto current main, then #439, #441 and #442
cherry-picked on top, all five picks clean, gate green on the result. The
implement-loop/20260905-094605 ref on the remote is the stale #438 tip and
can be deleted once this merges.

#443 was next; the run was stopped before it wrote anything.

iot49 and others added 22 commits September 5, 2026 12:59
The `broker` fixture skipped on both paths — no `mosquitto` on PATH, and a
broker that would not start — so a failed install in CI removed every suite
built on the bus from the run and the gate still reported green.

`no_broker` now decides by the environment: a skip where `CI` is unset, a
failure where it says CI. A broker is software and belongs in the gate where
hardware does not (#372), and a machine without one still runs everything
else. The module docstring states both behaviours and why they differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

The install stays tolerant, but the comment above it claimed a failed one
"costs coverage and never a red gate". That stopped being true when the
`broker` fixture began failing under CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six app command lines were written one after another and repeat each
other: `_connected` byte-identical in seven modules, `PERIOD_S`, `BROKER_S`
and `RETAINED_S` with the same docstrings six, six and four times, and the
same argparse construction in the four apps that read documents.

`lib` is where the shape goes, being the one import the apps share
(ADR-0013). A sibling of `loading.py` rather than a part of it: what an app
does when the railroad moves under it is that module's subject, and the way
in is this one's.

Nothing imports it yet. `retained` waits on `stop`, which is the rule
`dropped` already states and the two apps that poll a retained window break.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_connected` was byte-identical in seven modules and the three startup
numbers repeated with their docstrings; the four apps that read documents
built the same three flags four times over, one comment included verbatim.
All of it now comes from `lib/startup.py`.

The two apps whose flags genuinely differ keep their own parser: the driver
reads no documents and takes no `--store`, and the translator takes a station
and no railroad. `dccex`'s window, waited out whole for want of anything to
key on, keeps its reason — moved into `_retained` where the behaviour is,
now that the constant it hung on is shared.

Behaviour is unchanged here: the scheduler's and the dispatcher's polled
retained wait is still their own and is the next commit's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#430)

`lib/loading.py` states the rule: `retained_s` is a moment given to the
broker, "waited on `stop` so a signal arriving in it ends the process rather
than being sat on". `layout` and `dccex` do that; these two took no `stop` at
all and polled `time.sleep(0.01)` until the row landed or the deadline
passed, so a SIGTERM arriving in the window was honoured up to `RETAINED_S`
— a second — late.

Both now wait through `lib/startup.py`, which looks for the row a tick at a
time and ends on `stop` between two looks. `serve` takes `retained_s` as
`dccex`'s already does, so a test can hold an app inside a window and stop it
there; it is the same number the reload's `dropped` is given.

The test in each app fails against the old wait: with a 30-second window and
a stop set inside it, the app is still running ten seconds later.

The simulator's `_waiting` docstring loses the words `time.sleep`, which
named no call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The term is load-bearing in sixty-odd places across src, tests and docs
while the glossary told a reader to avoid it, and glossed it as a kind of
restart where the code uses it for something explicitly not one. Give it
an entry rather than rename it: an app clearing the retained rows it owns
and building them again from nothing, without the process restarting,
naming ADR-0060 for the railroad-loading case. Restart drops it from
_Avoid_ and says how the two differ instead, and the section's preamble
says why the new entry is not a fourth failure.

No file outside CONTEXT.md changes.
Nothing compared `CONTEXT.md` against `lib/inventory.py`, so the glossary
could name a topic that does not exist. It did once — `tc49/layout/
railroad_wanted` was in the prose two batches before the topic reached the
inventory — right in advance rather than wrong, and nothing would have caught
it had it never arrived.

Same shape as `test_state_leaves.py` and `test_store_routes_are_proxied.py`:
both sides read at run time, no copy of either list here, and the failure
names the line and the topic. One reading covers the three ways the prose
writes a name — a whole topic is a row, a prefix written with a trailing
slash holds where the inventory has a topic under it or is that topic itself,
and the bare namespace `tc49/` is the widest prefix of all rather than an
exception. The reverse direction is out: the glossary is about words, not
about every row.

Per the batch's rulings, `prefix` is the glossary's word for a trailing-slash
name and is used here in place of the issue's "family".
A mechanical extraction, no behaviour change: the loop over the bus's
retained values in the constructor moves out to `_traction_rest`, beside
`_traction_write` it calls, so a second caller can want the same thing.
The point asymmetry moves with the loop, the startup reasoning stays where
it is said.
An `off` that is applied writes `0.0` over every retained `wanted/traction`
row before publishing `wanted/track`. "The railroad comes up at rest" is a
promise about a power cycle as well as about a process start, and the startup
rule does not reach a power cycle: an arrival zeroes only a train this app
drives, so a plain `off` left a person's throttle standing in the row and the
next `on` brought current back over it.

Ordering is load-bearing — the translator acts on desired rows as they
arrive, so the zeros are the last thing the station is told before the supply
goes. Only on the applied `off`: a refused gesture changes nothing (ADR-0062),
`stopped` is a run meant to resume from, and `wanted/point` has no resting
value to write (ADR-0054).

No new state, no new topic, no new field. Five tests in
`tests/layout/test_power.py`, one of them on the order the two topics go out
in and not on both merely having happened; `docs/layout/README.md` states the
rule beside the guard it rides on.
Wording only. "Power cycle" was a term this repo does not have; the glossary
names the two halves — a **power off** and the `on` after it — and says a
**restart** is the process returning, which removing the supply is not.
`{train: null, mode: manual}` took every train `layout` holds a position
for, including one mid-transit under a grant. `_took` clears the
crossing's `driving` flag, so the arrival's `0.0` stopped going out and
the train kept its commanded speed past the block it was sent to,
standing only when somebody found it.

`_modes` now skips those trains and takes the rest — applied in part
rather than refused whole, so one automatic train under way does not
stop a person taking any train. `_driven` reads the same
`crossing.driving` flag off the same crossing `_took` writes. The
skipped trains go to the trace and nothing is published to name them:
`state/mode` is the level that says where every train's mode stands.

A gesture naming a train is untouched in either direction, and so is
`{train: null, mode: automatic}`.
SYSTEM.md's `mode_wanted` payload entry and its manual-driving section,
and the layout README's map paragraph, each said `train: null` takes
every train. `docs/layout/README.md` carries the reason and where the
refused trains go.
Every test that presses a drag and then names the request it composed
spelled the id `<train>-N` out. The next commit puts a per-process nonce
in a gesture-minted id, so those spellings stop being writable — and
they were never the thing under test in any of these, which are about
what a cancellation frees or what order a held queue drains in.

`tests/harness.py` grows `minted`, the id the scheduler minted for a
train's nth request, off the trace's `request_submitted` lines;
`test_cancellation.py`'s own `drag` returns the id it just caused, and
`test_replay.py` compares train and ordinal either side of the nonce,
which is what two runs of one document have to agree on.

No behaviour changes here: these tests pass identically before and after
the nonce lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

The counter starts empty in every process, and the scheduler and the
dispatcher are separate processes now (ADR-0059), so a supervisor
restarting the scheduler alone is ordinary: the next drag minted an id
the dispatcher was still holding, which it drops at the top of admission
before any check runs — no `request_admitted` and no `request_rejected`
ever came back. That is #73's failure reproduced by a restart instead of
a page reload.

ADR-0033 settled this class by splitting the minters, and the split has
moved inside this app now the panel mints nothing: a timetable keeps
`<train>-N`, deterministic and in the file's order, because replay's
traces are byte-identical; a gesture is minted `<train>-<nonce>-<n>`
from `secrets.token_hex(4)`, made once at construction. Not clock-
derived, which SYSTEM.md forbids of the bus, and not deterministic,
which is what makes it work — a run carrying gestures never claimed
determinism. The counter stays undivided (ADR-0036): the ordinal is
still the last part.

`nonce` is public so a test can assert the shape against the nonce the
instance holds rather than against a regex for some hex. Re-minting a
timetable's ids on a restart stays correct and is untouched, and
`_reverse` composes no request, so it mints nothing either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ADR names the panel as the nonce-minter and the panel no longer
mints, so the text was stale where it stands. An amendment block at its
head says the split is unchanged and has moved inside the scheduler,
that a timetable keeps `<train>-N` for the reason it always did, and
that what forced the move is separate processes making an independent
restart of the minter ordinary. Nothing about uniqueness-not-meaning
changes.

Beyond the issue's criteria, two pages that stated the shape as the
whole of it: CONTEXT.md's **Request id** and SYSTEM.md's scheduler
section both said the scheduler mints from one counter in the
timetable's order, which is now half of what it does. No glossary term
is introduced or retired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both gates that stop work over dead rails read the observed power, not the
commanded one, so between `wanted/track: off` going out and the station
reporting `device/track: off` a `run_wanted: running` is accepted, the sweep
grants, and `_cross` writes a speed onto rails that are going dark. The
outcome is the "safe but wedged" ADR-0040 already names, with ADR-0049's
placement as the recourse, so neither gate changes and this is the record.

The gate sites are named by path and symbol rather than by the line numbers
the issue quoted: those were against 9e79384 and have since drifted, and no
other ADR here cites a line.
The Moving entry stated one half — a manual train on its route is active and
so moving. The complement is that a manual train off its route is neither, so
`moving` reads false, `layout` writes its wheels on the throttle gesture
alone, and a plain `off` under it is applied. That is a user error the app
does not guard, and it is a scope declaration rather than a trade-off, so it
gets no ADR.

Criterion 3 sends the same qualification to the two pages that stated the
unqualified half: DISPATCH.md said every train moves on a route the
dispatcher allocated, and the layout's Power section said a plain `off` is
applied only where nothing is moving. The layout's "no move while the rails
are dead" paragraph gains the other half of the amendment, the value it reads
being the observed one.
`_reverse` dropped a `reversal_wanted` where the train was in
`_train_of.values()`, and `_train_of` was filled in one place — `_submit` —
so the guard covered only the requests this scheduler minted. SYSTEM.md's
rule 1 gives a request topic one responder and any number of writers, and its
preamble names a page submitting beside the scheduler, so under a second
submitter the arrow could be turned under somebody else's queued request: the
request still departs the end the facing named when it was composed, and the
move answering it reads as propelling.

`_on_dispatch` now records `id -> train` off `request_submitted`, the leaf it
already receives and ignored, read through `readable_id` and `named_train` and
dropped where either is unreadable — the insertion side of what the answers
already did by id. The write in `_submit` stays: same key, same value, and it
holds the guarantee for this app's own requests across the round trip before
the frame comes back.

`_reverse`'s and `_on_dispatch`'s docstrings say the set is now every request
the dispatcher has announced. `request_submitted` leaves the ignored-leaf
test, no longer being one.

SYSTEM.md is untouched: this implements its rule 1 rather than amending it,
and the scheduler section's "one writer of requests" is that page's to state.
Press OFF while trains are moving and the last one reaches its sensor
before the dispatcher answers the drain: #406 has the dispatcher
republish the row whenever `moving` moves under a standing run word, so
`{run: running, moving: false}` arrives, the panel read the value, and
the wait went. The later `{held, moving: false}` found `draining`
already false, `power_wanted: off` was never sent, and the railroad went
quiet with the supply still on and the button back to OFF — the exact
surprise the wait exists to prevent (ADR-0051, ADR-0062).

The wait is now dropped by news about the run word: the previous word
was not `running` and the new one is. The previous word is the one
`wasRunning` already holds, captured into a local before the assignment
above the drain block rather than into a second field. `drained` and
`pressPower` are untouched, and nothing in `dispatcher` changes.

`power.test.ts` gains the last-train case, which fails without the fix,
and the `{draining, moving: false}` case that leaves the wait standing.
The existing "drops the wait when the run is released" fed `running`
twice — a sequence no dispatcher publishes, it comparing the whole row
before it writes — and is rewritten to press, `draining`, `running`;
its closing `{held, moving: false}` is kept as what makes "no
`power_wanted`" mean the wait was dropped.

Refs #441
PANEL.md said a row reading `running` while the wait stands is a drain
somebody abandoned, which is the value and not the change and so no
longer true of the code. ADR-0062's decision 4 already says "drops the
wait when the run is released", which the fix makes literally so, and it
is left as the record it is.

Refs #441
…442)

`mkdir -p "${TC49_STORE:-$HOME/tc49}"` expanded the variable in the deploy
shell, while compose resolved the same one two lines later out of
`--env-file /etc/tc49/deploy.env`. A box that moves its store by putting
`TC49_STORE` in that file had `$HOME/tc49` made for it, nothing mounted it,
and the daemon made the real source as root — the fault #387 added the line
to prevent, and worse than before it, since the store no longer runs as root
and cannot write what the daemon made.

`scripts/store-root.sh` answers the question compose answers: the shell's
value first, then the env file's, then `~/tc49`. The file is read rather
than sourced, being the one place a secret sits on disk, and an unreadable
or absent one leaves today's default. `deploy.sh` names the env file once
and hands it to both the resolution and compose.

The resolution is a script of its own because that is the part of the deploy
a test can ask a question of without a box to ssh to; the tests sit with the
other three lines that keep the store the person's.
The page documents `TC49_STORE` in the env file as the way to move the
store, and now says which of the two places the deploy reads it from wins.
@iot49 iot49 added the ready-for-human Requires human implementation label Sep 5, 2026
@iot49

iot49 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Range review — 0b755d4..HEAD

The review the run owed and never reached, run by hand after the early stop: two axes, Standards and Spec, in parallel, over the whole 22-commit range. Each change was reviewed alone when it landed; this is the first look at the union.

Spec

(a) Missing or partial

#437, acceptance 4 — unmet, and this is the RISK the per-issue audit flagged. The issue asks that tests/bench/test_replay.py and tests/bench/test_benchmarks.py pass unchanged, "which is what says replay is still byte-identical". Both tests/bench/test_replay.py and tests/bench/test_metrics.py were rewritten instead, with a new stable_ids() helper that strips the nonce back out before comparing runs.

The cause is a false premise in the issue rather than sloppy work. #437 says the timetable path is unchanged and assumes replay rides it. It does not: src/tc49/bench/replay.py:102-109 submits every scenario request as a request_wanted drag, so replay goes through _compose and now mints <train>-<nonce>-<n>. Replay ids are no longer reproducible, which is the property ADR-0033 existed to preserve. The run noticed, weakened the guard and landed it rather than stopping.

That makes the ADR-0033 amendment wrong where it is most load-bearing: "A timetable keeps <train>-N, minted deterministically in the file's order, for exactly the reason it always did: byte-identical replay." No replay takes that path. The same claim was copied into CONTEXT.md and docs/SYSTEM.md. Either replay feeds the timetable path, or the ADR says replay compares ids modulo the nonce.

(b) Scope creep — all three judgement calls, all of them keeping something true that would otherwise have gone stale:

(c) Implemented but wrong — nothing else. #423, #430, #431, #433, #435, #438, #439, #441 and #442 each meet their acceptance lists, including the ones easy to fake.

Union coherence — no issue silently undoes another. #433's new glossary guard landed before #431, #437 and #438 edited CONTEXT.md, and the topics those introduce are in the inventory, so it still holds.

Standards

Documented-standard breaches

  1. tests/dispatcher/test_main.py and tests/scheduler/test_main.py — the new docstring cites the rule as (lib/loading.py), but system: the six app command lines repeat, and two of them sit on a signal #430 moved that wait into lib/startup.py. Stale in both copies.
  2. tests/dispatcher/test_main.py — "except where where it is on its way to the bus".
  3. docs/SYSTEM.md:1322 (layout: mode_wanted with no train refuses the trains the layout is driving #436) is a 113-char prose line where the file wraps at ~78; tests/layout/test_power.py:449 is 98 where the file wraps at 88.
  4. "the railroad at rest" is now load-bearing (_traction_rest, docs/layout/README.md, test_power.py) with no CONTEXT.md entry.

Baseline smells — judgement calls

  1. system: the six app command lines repeat, and two of them sit on a signal #430 left half-converted. command_line() absorbed four of six main() parsers; driver/__main__.py still repeats --broker and --railroad with identical help text, and dccex/__main__.py still repeats --broker. The commit says "the six command lines reach the one connect loop"; two do not.
  2. WINDOW_S, the waiting fixture and test_a_stop_inside_the_retained_window_is_acted_on_there are near byte-identical in the dispatcher's and the scheduler's test_main.py.
  3. The "waited out whole … an empty broker and a broker still sending look alike from here" paragraph is now in three places (lib/startup.py, layout/__main__.py::_retained, dccex/__main__.py::_retained), and docs: what ADR-0062's guard does not cover #438's "a user error the app does not guard" is verbatim in CONTEXT.md, docs/dispatcher/DISPATCH.md and docs/layout/README.md.
  4. Scheduler.nonce's own docstring says it is "never read by any consumer … here to be asserted against", and tests/harness.py::minted already reads ids off the trace.
  5. Mild Feature Envy: connected() and retained() in lib/startup.py touch only MqttBus and no data of their own.

Whole-batch interaction

  1. layout: traction rows are zeroed when a plain off is applied #435 vs docs: what ADR-0062's guard does not cover #438. layout: traction rows are zeroed when a plain off is applied #435 asserts an applied off leaves the railroad at rest — zeros before the cut. docs: what ADR-0062's guard does not cover #438's ADR-0062 amendment documents a window in which _cross still writes a speed after wanted/track: off has gone out. A grant landing in that window re-writes a traction row after the zeros. Neither doc cross-references the other, and this is the one finding here with a physical consequence.

Summary

Spec: one unmet requirement (#437) and three scope calls. Standards: four documented breaches and six judgement calls. The two worth a decision before merge are the #437 replay-reproducibility defect and finding 10; the rest are follow-up work.

…the timetable (#437)

The range review of this batch found ADR-0033's amendment claiming a
timetable's deterministic ids exist "for exactly the reason it always did:
byte-identical replay". No replay takes that path. `bench/replay.py` feeds a
document's requests as drags — deliberately, so that no app is given a
scenario and every app keeps one placement path (#171, ADR-0030) — so a
replayed request is minted like any other gesture, nonce and all. The claim
was copied into CONTEXT.md and SYSTEM.md.

Two separate things were hiding behind that one sentence.

Two runs of one document have to agree id for id, or a benchmark trace is not
comparable with itself. That was true by accident before the nonce and is
false with a minted one, so `Scheduler` takes the nonce as an argument and the
harness states it. Production is unchanged: the default is still
`secrets.token_hex(4)`, and nothing but `bench/runner.py` passes anything.
Nothing is protected by the value being unguessable — the collision a nonce
prevents needs a second scheduler process, and a bench run is one.

A run fed as drags and the same run fed as a timetable cannot agree on ids at
all, whatever the nonce is: one shape carries one and the other does not. So
#437's acceptance 4, that `tests/bench/test_replay.py` pass unchanged, was
not meetable — the issue assumed replay rides the timetable path. Comparing
ids with the nonce taken out is the right comparison there, the way that test
already drops the departure end a gesture cannot state, and its docstring now
says which of the two jobs it does.

`Scheduler.nonce` goes with this: a caller that wants to know the nonce states
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@iot49

iot49 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

The review's one unmet requirement is addressed, and the finding turns out to be half a defect and half a wrong issue.

b0e0088. What the review got right: ADR-0033's amendment credited a timetable's deterministic ids with byte-identical replay, and no replay takes that path — bench/replay.py feeds a document's requests as drags on purpose (#171, ADR-0030), so a replayed request is minted like any other gesture. That claim had been copied into CONTEXT.md and docs/SYSTEM.md; all three now say what is true.

What the review — and #437 — got wrong: acceptance 4 was not meetable. "tests/bench/test_replay.py … pass unchanged" assumes replay rides the timetable path. test_the_replayed_run_is_the_one_the_document_produced compares a run fed as drags against the same run fed as a timetable, and those two cannot agree on ids whatever the nonce is: one shape carries a nonce and the other does not. Removing the nonce before comparing is the correct comparison there, exactly as that test already drops the departure end a gesture cannot state. So stable_ids() stays, with a docstring saying which job it does.

The other half was a real gap: two runs of one document also have to agree id for id, or a benchmark trace is not comparable with itself. That held by accident before the nonce and stopped holding with a minted one. Scheduler now takes nonce as an argument and bench/runner.py states it. Production is untouched — the default is still secrets.token_hex(4) and nothing else passes anything — and nothing rests on the value being unguessable, since the collision a nonce prevents needs a second scheduler process and a bench run is one from start to finish.

Scheduler.nonce is deleted with it, which also answers standards finding 8: a caller that wants to know the nonce states it.

Gate green on the result. The remaining review findings are follow-up work, not blockers, and get their own issues.

@iot49
iot49 merged commit 8b05182 into main Sep 5, 2026
1 check passed
@iot49
iot49 deleted the implement-loop/20260905-094605-reconciled branch September 5, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Requires human implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant