ADR 0037: Work on nodwyn is validated before it is called done
- Status: accepted; implemented 2026-10-03
- Date: 2026-10-03
Context
AGENTS.md (ADR 36) lists what to run for each kind of change, and says a
change is done when it has been run where it runs. On 2026-10-03 two chat
sessions built ADR 34's worktree sessions and called the work done after
make accept passed. Both attempts were reverted. A review found that:
- the console toggle, the feature that had been asked for, did nothing. The bridge never offered the method the console called, and a fallback hid that. The browser check had been extended only to find the checkbox;
- each session's own "check" searched files for strings
(
grep -q 'worktree: true' home.js) and counted a match as done; - a test written for "a clean close keeps attachments" passed on the code that deleted them, because its setup made the tree dirty for another reason and ignored an error;
- a new primitive was in neither the book's nor the expert guide's tables, and a new working rule had no ADR 32 amendment;
- a failing browser check left its demo server running, holding the pipe
that
run.sh … | tailread, so the session waited ten minutes and then stopped with nothing to report.
The rules were in AGENTS.md, and the model followed some of them. Nothing
checked the rest, and nothing asked: the default loop's finish check
(evidence-loop, strategy.scm) runs only in unattended runs and planned
turns, so a chat had none.
Decision
make validaterunsscripts/validate, a Go program that:- measures the change from where the branch left its upstream (
git merge-base HEAD @{upstream}, elseHEAD), counting committed, staged, unstaged and untracked files; - chooses checks by path:
- changed Go files:
gofmt; - changed console scripts:
node --check; - anything but documentation no test reads:
make accept; - the console, its RPC contract or its checks: every browser check, which fails rather than skips without Node or Playwright;
- book or expert guide sources:
make bookormake expert, which only warn when texlive is missing;
- changed Go files:
- runs each test the change adds or edits against a clean copy of the
code at the base (
git archive, never the stash), with the changed test files laid over it. A test that fails there tests the change. One that passes there is a warning: it does not test a fix. One that cannot compile there calls new code, and says nothing either way; - checks what a diff can show:
- a primitive added to the host is in both primitive tables (a failure);
- a changed
prompts.scmcomes with an ADR 32 change, a changed wire schema withdocs/protocol.md, and changed code with a changed test (warnings);
- runs every command as the leader of a process group of its own, with its output in a file and a time limit, and kills the group when the command ends, so nothing it left behind keeps anyone waiting;
- prints one line per check, the end of each failure's output, and a last line that says PASS or FAIL. It exits 1 on a failure.
test/browser/run.shkills each check's process group when the check ends. A check that throws no longer leaves its demo holding a pipe.- The
validatetool is registered in.nodwyn/init.scm. It runsmake validatethroughshell-startand reads it to the end, since validation can take longer thanshell-exec's ten minutes.allow.tomlapprovesmake validatestanding. - A finish check for this repository's turns.
.nodwyn/init.scmwrapsdefault-agent-turn: - a turn that changed the repository and ends without
validatehaving run on the repository as it then is gets a message saying so. It goes on, at most twice; - a turn that changed nothing, such as one that answers a question, is not reminded;
validatealso counts as a test run for the default loop's own finish check, so a run is not asked twice;- the repository is compared by git's status lines and the diff of tracked files.
- What judges the work is protected. The validator is under
scripts/and its target in theMakefile, both protected paths (ADR 17). The tool and the finish check are in.nodwyn/init.scm, also protected, and not indev/, whichallow.tomllets a session write without asking. AGENTS.mdsays to runvalidateafter the last edit and quote its summary, warnings included. It also says what no command can check:- that a browser check performs the action and checks what it did;
- the console's bridge;
- that repository files are input;
- how to scope a go-ahead and an ADR's clauses.
Consequences
- A change to Go code costs
make accept(about two minutes warm). A console change adds the browser checks (about a minute each). A change to documentation alone costs nothing. - The reminder is advice, as the default loop's are: a model can still finish without validating, after two reminders. Its answer then has no summary to quote, which a reader can see.
- The comparison misses a second edit to an untracked file, since its status line does not change. An edit to a tracked file shows in the diff whether the file tools or the shell made it.
.nodwyn/init.scmstill loadsdev/*.scmbefore it defines the tool and the finish check, and a session writesdev/without asking. What a session writes there runs in the next session, and can redefine what the finish check calls. Takingdev/out ofallow.toml'swriteswould close that; this decision does not.- Browser checks are not run against the unchanged code, and a check that
only finds an element still passes. That rule is
AGENTS.md's, and review's. - A test that passes on the unchanged code is a warning, not a failure: a new check of behaviour that already worked passes there too. The answer says which it is.
- The validator's own tests (
scripts/validate) are fixtures of the failures above: - a test that passes on the unchanged code;
- a primitive missing from the tables;
- a command that leaves a process behind;
- a chat turn that changes a file and finishes without validating.
Each was checked to fail when the part it covers is removed.
Amendment 2026-10-03: checks that fail on what they claim, in a third of the time
Measured on 2026-10-03, warm: make validate for a console change took
about three and a half minutes. make accept took 1m34s, set by
internal/core (101 s under -race), and the six browser checks 1m30s, run
one after another and mostly waiting on fixed sleeps (screens alone 50 s).
Five of the six checks printed a page's errors and still exited 0, so a page
that threw passed validation; several printed what an action did without
asserting it.
- The browser checks fail on what they claim. They share
test/browser/lib.js: - a check fails on any error a page throws or logs. An error it provokes
on purpose (a refused request) it names first with
expectError, once each time; warnings are printed and do not fail it. The first run found one at once: browsers rejected the pairing page's device-name pattern (an unescaped hyphen under thevflag) and so checked nothing; it is escaped now; - each action is asserted by what it did: the JSON-RPC requests the page sent over its socket, the files the daemon wrote, what the page shows after. The screenshots are for people and are compared with nothing;
- whatever ends a check, it stops what it started and removes its
temporary folders, which are under
TMPDIR(a socket's folder falls back to/tmpwhenTMPDIRis too long for a socket path); a failure leaves a screenshot of each page; remotelistens on a port the system picked, not a random one.- Without the scripted model's pause,
tasksfound a console race about one run in eight: a task just started showed "No such task". Each state notification of a task the desk did not list yet started a refresh, and a refresh that a newer one overtook returned at once, so the task's page read a list without it. An overtaken refresh now waits for the newest. - They wait for conditions, never for a time. The console sets
<html data-ready>once the desk's first refresh is drawn and the models have answered, and.task[data-loaded]once a task's log, details and notes are; nothing else reads them.demo -seedprints its URL once its tasks have settled, and the checks pass-pace=0to its scripted model, which otherwise waits 400 ms a reply for people watching. run.shruns the checks at once. Each has its own folder for screenshots and its own log; the logs print in the order the checks were named, each with how long it took.- A scripted model that runs out fails the test.
ollama.NewTfails a test whose loop asks after its script ran out, which the client otherwise retried for six seconds a turn before an error the test never read.internal/core's tests use it. Six ran out (27 requests); their scripts now end their turns, and three of them assert what they were named for:TestAbortDuringConfirmnever reached a confirm to abort,TestUploadSettingWhenNobodyAsksmade both uploads in its first turn, andTestPlanAndEphemeralRedactionrecorded "done" as its plan. - Other slow tests. The CLI tests run their subprocesses with
GORACE=atexit_sleep_ms=0, astest/fakes/containeralready did: the race detector otherwise sleeps a second as each exits.TestTailCallsruns 20k iterations, not 200k: a loop whose call is not in a tail position overflows its 8 MB stack before 5k, with or without-race. - Not done.
internal/core's tests stay sequential. Marked parallel they took about 10 s, but one run in three failed to lock a session log that another test's child process may briefly hold (a flock shared across fork); that is for its own change.
After it, on the same machine: the browser checks take about 10 s together
(screens 6.5 s alone), go test -race ./internal/core/ 30 s, and make
accept 39 s.
Amendment 2026-10-03: internal/core's tests run in parallel
The session log a test failed to lock (the amendment above) was held by a
child that another test's goroutine forked: a flock belongs to the open
file description, which the child shares until its exec. Closing a writer
now unlocks first (ADR 2's amendment of the same date). With that fixed,
219 of internal/core's 228 tests call t.Parallel. The nine that change
what the whole process shares stay sequential and run first: the six
contained-server tests and the two scratch tests that set environment
variables, and TestAnUnfinishedTurnLeavesItsPlan, which sets
llm.RetryDelay. Six runs of each, alternated on the same machine:
go test -race -count=1 ./internal/core/ took 27.1 to 27.7 s
sequential and 6.6 to 7.6 s parallel, none failing.
Amendment 2026-10-05: a worktree's work is measured from where it left main, and a failing browser check is sorted
Three things the local session logs of 2026-10-03 to 10-05 showed validate getting wrong in the worktrees most work now runs in (ADR 34):
- A worktree's branch has no upstream, so validate measured from HEAD, and once the work was committed it said "nothing changed since HEAD … PASS" (20261004T080135Z-aef239e9 seq 1769, 20261004T144303Z-219d1111 seq 4385, 20261004T160126Z-dfc828d6 seq 2528, 20261005T151325Z-d9519026 seq 5649): models re-pointed the branch's upstream by hand or re-ran make accept and the browser checks. A branch with no upstream is now measured from where it left the main line (the branch origin's HEAD names, then main, then master, local before remote); the main line itself, with no upstream, still measures from HEAD.
- This repository's finish check compared git's status lines and the diff
against HEAD before and after a turn, so a turn that only committed what
validate had already checked was told to validate again, at least ten
times (20261005T035352Z-5a76f6e4 seq 556, 20261005T040401Z-cb8a1bdf 482,
20261005T042752Z-871dfe24 575, 20261004T141038Z-b6cc7b4f 1015, ...). It now
compares the untracked files and the diff against the worktree's base
commit, or the commit HEAD named when the turn began (
git-head, andgit-diff's newagainst), so a commit changes nothing. - A failing browser check's summary was the end of the log, which held the checks that passed, and telling a failure the unchanged code has too took 2.5 to 6 minutes a time, once with a wrong diagnosis (20261004T032020Z-442c04d4, 20261004T035536Z-cd08f00d, 20261004T073130Z-40187c24). validate now names the checks that failed with their FAILED lines, runs them once more (one that passes then is flaky), and runs those that fail again, as the unchanged code has them, on a copy of it with this checkout's Playwright. It fails only for a check that fails with the change and passes without it; flaky checks and checks that fail on the unchanged code too are a warning to answer.
Amendment 2026-10-07: the books are built by the release flow, not by validate
Since ADR 42's amendment of 2026-10-05 the PDFs are not tracked. The release
flow's scripts/release.py build checks the source catalogue
(generate.py --check) and builds both books in a scratch checkout of the
release's commit, and nothing else keeps them. validate still built the
book whenever a file under docs/book/ changed, and AGENTS.md has every
Go or Scheme change regenerate docs/book/source/catalogue.tex, whose
counts line then changes; it did in 8 of the 10 commits before this one.
Of the nine validate runs of 2026-10-07, four built the book (81 to 83 s)
and one the expert guide as well (88 s); the rest of a run took 40 to 60 s.
The books were 58 to 74% of those runs' time, for PDFs nobody kept.
latexmk -c removes the auxiliary files after each build, so every build
was a cold one.
- validate builds neither book. Their sources are documentation to it,
as the rest of
docs/is: they need no check. - Nor does a session.
AGENTS.mdno longer asks formake bookormake expertafter a change to the books; the release flow builds them. The targets stay, for anyone who wants to read a copy. - The note that a rebuilt PDF goes with the change is gone with the build: an untracked PDF never differs, so it could not show.
A LaTeX error in a book edit, or a Go or Scheme file the listings cannot typeset, is now found when a release is built, which fails before it publishes anything. The catalogue at this amendment's parent was stale (456 Go files counted, 457 tracked), which validate did not check and that build would have refused; it is regenerated here.
Amendment 2026-10-15: validate checks the books' source inventory
On commit-bench (tasks 556d6d12, c97f9a41, 5040f9fe, 107251d0 and 3422faf1
under builds A, dsl16, dsl18m and dsl19; deep study of 2026-10-15), eight
of the twenty changes left docs/book/source/catalogue.tex stale, and each
had run validate after its last Go edit and read "validate: PASS (0
warnings)": validate never ran generate.py --check. Five never
regenerated the inventory; two regenerated it, then edited tests at a
reminder; one (3422faf1, A) regenerated it with its new exfil scenario
untracked, which the generator, listing the files git tracks, did not
count. The validate tool's description in .nodwyn/init.scm said it
covered "the book and expert guide for their sources", which it has not
since the amendment above, and two explorers repeated it.
- Any changed, added or deleted Go or Scheme file has validate check the
inventory ("book inventory"). It runs
generate.py --checkon a copy of git's index, in a temporary directory, with the Go and Scheme files git does not track added and those the tree no longer has removed (git update-index --add --remove --info-only, throughGIT_INDEX_FILE): the files a commit of the tree would hold. Git's own index and object store are left as they were. A stale inventory fails with what to run:git add -Nfor each new file the generator cannot see, thenpython3 docs/book/source/generate.pyafter the last Go or Scheme edit. - A base whose inventory is already stale fails too, deliberately: the
fix is the same one command, which
AGENTS.mdasks of every Go or Scheme change, and the release flow would refuse the stale file. Withoutpython3the check fails and says so, asnode --checkdoes without node. - The tool's description says what it runs: the inventory for Go and Scheme changes, and neither book.
Replayed on the twenty trees (each task's parent with the run's patch
applied and its new files untracked), the check failed on the eight stale
ones and passed the other twelve, in 0.26 to 0.29 s each. Counting the
untracked files is what catches two of the eight: 3422faf1 under A and
107251d0 under dsl19 pass generate.py --check with their new files left
out.
Amendment 2026-10-16: validate runs what the checks run, and says what a change removes
A review of the tooling found six ways a change reached PASS without the
check that would have failed it. Each is closed here; validate's own tests
(go test ./scripts/validate) cover each.
- The browser checks run for the code they exercise. They ran only for
internal/frontend/web/, the rpc package andtest/browser/, but the checksschedule,retrieval,conversation-search,tasks,models,credentialsandmcpstart the real daemon and readinternal/core,internal/schedule,internal/retrieval,internal/event,internal/configand the fakes. They now run for any change underinternal/,cmd/,test/fakes/ortest/browser/that is not a Go test file, and forgo.modandgo.sum; the whole suite takes about 30 s, so a map of which check reads what would buy little and go stale. - The book catalogue is checked.
generate.py --checktakes under a second; a Go or Scheme change (a Go test file too, since the counts include them) runs it, and a failure says togit add -Nnew files first. The books themselves are still built only by the release flow, as amended on 2026-10-07; this check only keeps the catalogue the release flow checks from being found stale on release day. - The release publisher's tests run when
scripts/release.pyortest/release/changes; only the Gitea workflow ran them. - A console change asks for a browser check. The "tests" warning was
silenced by any test change anywhere; a change under
internal/frontend/web/assetswith no change undertest/browser/checksnow warns separately, as AGENTS.md asks for the check's extension. - Removing a check is a warning. A deleted or removed Go test, exfil
scenario or browser check, and an edit to an existing file under
internal/guard,internal/binding,internal/keychain,internal/egress,internal/redactortest/exfil, now warns, so the answer must say why. Before,make acceptsimply ran the smaller suite and was green. A test moved to another changed file is not removed. Takingtest/exfil/out of the standing writes in.nodwyn/allow.tomlwas considered and not done: a change to the guard must add its exfil test, and a prompt for each would not make the removal easier to see than the warning. - A pushed branch is still measured from the main line. The upstream was
used whatever it was; after
git push -u origin featureit is HEAD, so validate said "nothing to run" for the whole branch. The upstream is the base only when it is a main line (origin/main,main); otherwise the base is where the branch left the main line, and only a repository with no main line measures from its own upstream.
The tool's description no longer says validate builds the books.
Added the same day: validate warns for each line a change adds to a test
file that drops the error of os.WriteFile, MkdirAll, Symlink and the like
(about two hundred such lines were already there and are not listed), and
test/fakes/must is how to write them. The exfiltration scenarios were run
through a guard the test assembled by hand; they now run as tools of a real
session (core.New) with a scripted model, so a checker dropped or reordered
in internal/core/construct.go, or a security confirm that became an
ordinary one, fails them (shown by editing internal/core/construct.go and
internal/guard/taint.go: the old runner passed both edits).
Amendment 2026-10-16: the two catalogue checks are one
The amendments of 2026-10-15 ("book inventory") and 2026-10-16 ("book
catalogue") were made on two branches and each added a run of
generate.py --check to validate. The merge of claude/dsl23 keeps one
check, named "book catalogue": it runs for a changed, added or deleted Go or
Scheme file and for a change to generate.py (the 2026-10-16 trigger), and
it runs the generator on a copy of git's index with the untracked Go and
Scheme files added and the deleted ones removed (the 2026-10-15 check), so
a new file left out of git fails it as on 3422faf1. Its failure names the
git add -N and git rm --cached to run first. Running both would have
spent the second each takes for nothing, and a new untracked file would
have failed one and passed the other.
Amendment 2026-10-18: the reminder names what the model can run, and a shell run counts
The finish check of .nodwyn/init.scm runs in a lean session too: the file
redefines default-agent-turn, which agent-turn calls in either profile.
Two of its parts did not fit that session (ADR 32, amended 2026-10-18,
experiment E, found them):
- the reminder said "Run validate", the tool, and the lean profile does not
offer it: a plain
ask-modelthere offerslean-tools,shell-execandfs-edit(lean-args), so the model was told to call a tool it could not, and rango run ./scripts/validatethroughshell-execinstead; - a validate run made through the shell counted only in the evidence loop
(
validated-run, ADR 32, amended 2026-10-16), which runs a run's turn up to the reminder. The reminder's own continuation (validate-continue) and a chat's loop did not count it, so the second reminder came after a shell validate run whatever it found: on commit-bench's 48f90ea8, after one with no edit since, the model answered "No code changes this turn; re-ran validate".
Decision:
- Where the model is not offered
validate(the lean profile, unless a project adds the tool tolean-tools), the reminder says "Run make validate with shell-exec" in place of "Run validate"; the rest of its wording is as before, and the full profile's is unchanged. - The init file wraps
run-callsand callsvalidated-runon each call and its result, as the evidence loop does, so amake validateorgo run ./scripts/validatethroughshell-execthat ran to an exit, passing or failing, setsvalidated-statein every loop: the chat's, the evidence loop's and the reminder's continuation. Nothing else is counted: a run started withshell-startis still the evidence loop's to see.
Two tests in scripts/validate/gate_test.go run each in both profiles, and
each fails on the init file before this: the lean reminder names make
validate through shell-exec and not the tool, the full one the tool; and
after a failing shell make validate with no edit since, no reminder
follows, whether the run came before the first reminder or after it.
Amendment 2026-10-27: validation certifies unchanged contents
The old Git status/diff fingerprint changed when validated untracked files became committed. It also missed repeated edits to an untracked file and recorded a failed attempt as validated. The content-state primitive is host-owned and reads the workspace through an OS root; a repository may supply bytes to hash but cannot use it to execute Git filters or read outside its root.
Validation in this repository follows a fingerprint of tracked and non-ignored untracked file contents, executable bits and link targets, rather than Git status or commit IDs. Only exit zero with a PASS summary and the same known fingerprint before and after the check certifies the contents. This applies to the validate tool, shell-exec, and background shell-start/shell-read checks in both profiles. A commit or content-preserving rebase keeps that certificate; failed checks, edits during or after checks, and unknown fingerprints do not. Credential-denied files are omitted, symlinks are not followed, and a listing over 16 MiB or file contents over 256 MiB leaves the state unknown. Deleting non-ignored generated files changes this strict content fingerprint too.
The existing reminder limit and repository completeness policy stay in place. The generic strategy retains compatibility with older repositories; this repository overrides attempt recording and generated-file settling so they cannot certify failed or changed contents. No planning or prompt experiment is reintroduced. Local bare-remote tests cover unchanged landing, empty-main rebases, changed-main rebases, failed checks and later edits. A fake-provider matrix checks PASS/exit agreement and changes during validation through each execution route.
Amendment 2026-10-27: the checks run at the same time
validate ran its checks one after another: gofmt, the catalogue, make
accept (which itself ran lint, then the tests, then the exfil suite), the
browser checks, and last the changed tests on the unchanged code. They do
not depend on each other, and a run is mostly waiting (on go test -race,
on Chromium, on a copy's tests), so a change that needed all of them paid
the sum of their times.
- Every check starts at once and the summary is printed when the last
ends, in the order the checks were planned, so it reads the same whichever
finished first. Each plan check prints a
validate: NAME: STATUS (time)line as it ends, so a long run shows what is still outstanding. make acceptis its three steps, run as such:make lintbesidemake test, thenmake exfilonce the tests passed. The exfil suite is insidego test ./..., so that last step is a cache hit; it stays so that what validate runs is still what promotion runs. The check keeps its name and stays one result, with each step's time in its detail and, on a failure, the end of each failing step's output. Anyone who changes theacceptline of theMakefilechangesacceptCheckswith it.- Stopping validate stops every running command's group, not one: the
single "running" slot became a set.
TestStoppingValidateStopsWhatItRunsruns two commands at once and fails when only one is stopped.
The run is now as long as its slowest check (the tests) and no longer the sum of all of them. On the 4-core machine of this session, where every package but three failing for environmental reasons was in the test cache, lint (2 to 25 s), the tests (55 s) and the changed tests on the unchanged code overlapped, and a run took 55 s. The browser checks now overlap the Go tests, too; they run every check at once themselves, so on a machine that limits processes (this session's service allows 1024 threads and processes) the peak load is higher than before, and a check that fails for want of a process shows as the flaky or unchanged-code warning it already produces. The checks' names, their results and PASS or FAIL are what they were.