A board save the database did not store is no longer reported as saved.
When the API could not prepare the board write at all, it wrote no runs and no run
lines, answered the browser with an ordinary success, and the browser then treated
those records as already in the database — so they were left out of every later
save. A scheduler's lane edits were gone, with a green "Synced" over them, and
nothing said so: not the board, not the sync pill, and not the change log, which
recorded the edit as made and even recorded a deletion that never happened.
Measured by driving the real API: the reply was {"ok":true,"written":1}
with not one run or run line stored, and two change-log entries filed for a save
that did not happen. The board now shows a notice naming exactly what was not
stored, in the database's own words, saying that the edits are still on the screen
and that the next save sends them again — it has no dismiss button and it clears
itself only when a save really does land. Nothing is retried automatically, on
purpose. The two things that can go wrong on one save are kept apart: "we
saved it and could not check whether somebody else had edited it" is a different
notice from "we did not save it". Records the database could not file at all —
a run with no number, a run line with no name — are now named too, instead of
being dropped with only a server log line.
RA-1004’s three-hour
minimum froze qty: 3, rate: 165 and a description
reading “… · 3 hr” onto the accepted price
— the record NF.Invoicing copies verbatim and deliberately
never re-derives — so a customer querying the line three months later was
shown three hours of work and we could not show them why. The reason existed at
pricing time, in pricing.trace, and was dropped at the moment of
acceptance. The cartage line now carries one structured qtyBasis
key that rides all four hops (priced quote → stored quote → accepted
stamp → invoice), and NF.Rates.qtyBasisOf is the one reader
that composes the wording — including the quote screen’s own trace
line, so the dispatcher and the customer cannot end up holding two different
explanations of one $495. Four answers, not two: measured,
minimum, flat (per load, which claims nothing about time) and
unrecorded. A minimum is stated plainly and is deliberately not
hatched — we know exactly what happened and why, and hatching on an
invoice would say we were unsure of a figure we are billing for — while a
line whose record does not say is hatched, on the invoice card where a
figure is approved for billing and on the customer’s own invoice page.
Every line in every snapshot already stored comes back byte-identical: the key
is written with the undefined idiom, and no historical figure is
back-filled with a reason nobody recorded at the time. Found while writing it:
the minimum has always been applied to a per-km rate by the same
Math.max and traced for neither, the trace firing on
basis === 'hr' alone.NF.Rates.resolve did
BASES.indexOf(b) > -1 ? b : 'job', so asking to be priced per
pallet — #286’s
own word, from Lee and Zac — returned the per-load rate with
unpriced:false and nothing saying the ask had been dropped.
Measured on the seeded Perth → Pilbara lane, 'pallet',
'tonne', 'HR' and '' all answered
basis:'job', rate:9150. It is refused by name now
(unknown_basis), at that site and at the second copy of the same
coercion in priceQuote, where an unrecognised ask had been folded
into nobody asked and handed to the engine’s own fallback order.
The cartage is carried as unpriced with the ask quoted back, so the quote
reports complete:false and may not be presented as the price.
Three answers and not two: asked for something we hold, asked for something we
do not, and not asked at all — which keeps the per-load default it
always had, since every internal caller names a basis explicitly and a blank is
an absence rather than a value.The compliance gate a driver hits at departure is now on the dispatch
board. NF.Compliance.gateBlockers has been enforced in the cab
since #466 — a driver short of a gated credential is refused on the pickup
departure tap — but every surface that read it was one a driver sees, so
the first person to learn was the driver, at a remote pickup, at the moment of
departure. He can only tap through it; a dispatcher can re-crew the run in the
morning. The engine room now carries a Refused at the pickup strip above
the board and a chip on the row itself, both asking
NF.Compliance.gateOutlook — a new reader that calls
gateBlockers and reports its answer verbatim, because what
that function prints is frozen into the permanent override record and a second
opinion here would have a board and an evidentiary record disagreeing about one
run.
What the new reader adds is why a list of blockers is empty, because
[] means three different things and two of them are not "clear":
nothing gated resolves for that person at all (requirements resolve off
what somebody has already worked, so a new starter matches no rule — we could
not look, and it is drawn hatched); gated rules resolve and none reaches this
job (a known negative, dimmed and dashed); or the register genuinely releases it.
Measured on the seeded fleet over the board's forward window: 21 runs
refused, 11 that could not be checked at all, 7 nothing gated reaches, and
0 clear — and every one of the 23 refusals reads no record on file
rather than an expiry, because the credential register seeds empty. The strip
says that in its own words beside the count, so it reports what we have filed
rather than claiming a fleet-wide compliance crisis; #601 measured where
over-gating ends, which is drivers tapping straight through a control without
reading it. Hired-in rows are left out and named, a status somebody set is never
read as a departure somebody stamped, and nothing about what gates a departure
has changed.
js/data.js seeds them. Adding a new client site's induction,
retiring one, or moving a renewal window or a grace period was a code change and
a deploy, on the register that decides who may legally drive. It is now the
Requirements tab on #/compliance. (#1020)state:'archived' straight onto the record: no
archivedAt, no archivedBy, no active:false
— so the retirement was unattributable and invisible to the
active !== false filter the waiver form next door already applies
— and its only word to the operator was a toast, which slides away while
somebody is still reading it. Every refusal is now the writer's own sentence,
printed in the form and staying there. Archive still never deletes: a
retired rule keeps its id and still names itself, because the credentials earned
against it cite it and WHS (Mines) Regs reg 675E requires them kept. (#1020)Number('') is 0, which the
register reads as never expiring, so an unanswered question could have shipped as
a permanent exemption. Publishing such a rule is refused by name. The same
three-answer rule governs the preview: we could not look (nobody is on
the people register) is hatched and kept distinct from a real measured zero
— on a build holding no credential and no waiver at all
nobody can move, and the screen says that zero is a statement about what
we hold rather than about the fleet's competence. (#1020)<script> for leaflet.js and a <link> for
leaflet.css, each its own request, its own cache entry and its own integrity
hash — and both apps asked
typeof L !== 'undefined' && L && typeof L.map === 'function',
which is a question about one of them. Serve the script and refuse the stylesheet and the
guard passed: L.map() ran and the map drew, with its tiles in the wrong
places (every position:absolute rule Leaflet relies on lives in the file that
did not arrive), the markers and the overlay paths missing from the picture, and no panel
anywhere saying a word about it. That reached eight dispatcher map sites — the
dashboard mini-map, Live Fleet, a depot's yard, the geofence overview and editor, an asset's
trail, the incident modal and the event mini-map — plus the client portal's
tracking map, which is the page an anonymous customer opens from a tracking email. All
nine now test both halves and refuse to draw a map they cannot position: measured with the
stylesheet blocked and the script served, the dashboard and Live Fleet draw 0 tiles, 0
panes and 0 markers and print their existing honest panel instead, and the customer is
shown no map rather than a wrong one. The stylesheet is tested by effect — the
computed position of a throwaway .leaflet-pane — because a
tag that 404s is still a tag, and a sheet refused by an integrity check is still listed in
document.styleSheets with its rules unreadable cross-origin.
It is not a hypothetical split. /sw.js precaches per URL under
Promise.allSettled, deliberately, so one unreachable file cannot fail the whole
install; "script cached, stylesheet not" is a state the worker is designed to be able to
reach, after which cacheFirst serves the script from that cache while the
stylesheet keeps failing. It does not heal on its own.4c09c261: 147
attributed decision entries across 21 of the 144 documents under
docs/, of which Simon Anderson carries 100 and Anthony French
25, every other decider being in single figures. The finding that shapes the
sweep is that there is no single convention for writing down who decides
something — six forms are in use, and a search for any one of them
undercounts, which is not hypothetical: the first three passes each missed a
whole family.docs/ at all — so their subject is recorded as we could
not look, never as nothing and never as zero. That narrows the register's own
"every one of the 42 is enumerated" to a true statement about the GitHub
issue body and not about this repository. Eleven further facts are listed as
unreadable from here, each with a named holder and a named reason.blocks-go-live label
exists and is on five open issues, where three documents say it was never
created — what survives is that production-readiness is still
not a go-live marker, four of the five not carrying it. The 18 September
decisions reached one register and not the other, leaving five of
twenty-five entries reading as open when they are not. CAMERAS_JSON
is withdrawn rather than pending, leaving a repository secret somebody still has
to delete. priority:high is 44 of 86 open issues rather than
the 62 of 114 measured earlier, in the same direction. The go-live date exists on
one issue and nowhere a developer reads. And this document's own first draft was
wrong about #1084, which is corrected in place rather than swapped out, in the
convention the rest of the directory uses.tests/run.js at that
suite file and at every suite file after it — deliberately, because the suites
after it may be built on whatever it was meant to declare — but the summary still
read like a whole-tree result. Reproduced on this branch by throwing from line 1
of tests/suites/56-component-covers.js: 16553 passed, 1
FAILED over 120 suite files in tests/suites, while 62 of those
files never started and 10,501 assertions never ran. The 120 was a count of what
was on disk; it never was a count of what ran. The same shape is on record from
five other slices, one of which reported a break as 2 where the truth was
3, and docs/modules/demurrage.md records 19,686 assertions
reading as 16,928 for this reason.
N of M suite files executed on
every run, not only bad ones, and a run where those two disagree prints
neither ALL PASS nor N passed, M FAILED — both are
claims about a tree — but names the truncation, the file it stopped at, and how
many files never started, in three-state words: not passing, not failing, not
run. The same run now reads RUN TRUNCATED — 57 of 120 suite files executed.
THIS IS NOT A RESULT FOR THIS TREE.
#842 already
guards. Measured: hard-coding the comparison to false reddens 6 assertions
in tests/suites/88-runner-early-exit.js, restoring the old verdict
line 9, swallowing the throw 7, and dropping truncation from the
exit code 2.NF.People.build() projected a fixed six-key shape for
a driver — id, name, kind, licence, phone, active — so depot,
bu, role, email and startedAt
were dropped off any row that is only on the driver register.
NF.Compliance.contextFor()'s if (p.depot) clause, and
the if (p.bu) on the line below it, therefore tested a field that
never arrived: a rule somebody published deliberately did not fire for the people
it was written for, and nothing said so — the permissive direction, and silent at
both ends. Measured on the seeded fleet: a depot written onto all 20 driver
records came back on 0 of 20 rows at four clock settings, and all
seven readers of the field answered as if nobody had one (contextFor,
the compliance matrix, site readiness, the evidence pack's transcripts,
#/compliance's depot and business-unit filter lists,
#/people's Depot and Business unit columns, and the role panel's
depot-scope tick list). The value is carried verbatim and is neither
resolved nor normalised, which is what makes it correct under either answer to
the open depot id-versus-code question.#/people answers three ways instead of two.
It printed — for every driver, and the dash was true of the reader
rather than of the person. A depot that names a yard is an ordinary answer, drawn
flat, with the yard's own name and code beside it. A depot value that names
no yard is hatched and named, because the compliance matcher
resolves by id and a value spelled as a code reaches nobody — the same failure
measured once already on the group-scope and skills-matrix filters, where KTA,
ZNE and HAZ selected nobody while three genuinely-empty yards printed the same
nought. A depot nobody has recorded stays dimmed and flat, because that is a
known negative, with the hover naming it as a missing field: the driver
form still has no depot box.docs/decisions/gated-assertion-census.md's named remainder, across nine
suite files: 173 assertions measured back into the run's total over ten
deliberate breaks, each one a before-and-after rather than an estimate. Renaming the backups panel's own
function used to report 6 reds and remove 40 checks from the total; it now
reddens 38 and the total holds. Regressing NF.Desk.compose()
to a refusal — a composer regression, which is what those groups exist to
catch — hid 65 across two files; it now reddens them. Renaming the
driver disclosure painter hid 17 claims about what a driver is told before
putting their name to a shift. The suite reads ALL PASS — 31091
before and after: nothing was added, removed, weakened or exempted, because it
was the denominator that had gone missing, never the assertions.''.indexOf(x) === -1,
!/x/.test(''), [].every(f) and
undefined !== 'verified' are every one of them true — so
with the source region unreadable the suite went on reporting that
NF.Tracking and NF.QuoteAccept carry no weak random
generator, that NF.SpeedLimit names no external host, that the
backups panel uses no toast and reads no bucket or key, that a driver-retention
period falls back to no default, that a refused send wrote nothing to the thread,
and that an indeterminate credential does not ride on verified.
Each establishes non-empty output first now. Measured: on one break the reds went
from 12 to 15 with the total unchanged, the three extra being
exactly the claims that could not previously fail.if (!ok(…)) continue; is not the safe
shape it looks like, and the census had classified all 53 of them as safe without
checking. An inverted gate whose body is a diagnostic adds a red and is
harmless; one whose body is return or continue drops
everything after it, and is worse than an ordinary gate because the damage
is not bounded by a block. Measured on the send-stamp-order suite: one red and
22 assertions, found by arithmetic rather than by reading — after the
file's four ordinary gates were repaired the same break still came back 22 short
of the baseline. Two instances fixed; the remaining 37 sites in 13 files
are named for reading rather than counted as defects, since a guard on a file
being on disk belongs exactly where it is.The requirement report is now checked in a real browser, like the other ten compliance reports. Eleven reports were built for the compliance programme, and the promise made about each of them was that it renders in a browser and exports a spreadsheet matching what is on the screen. Ten had a check that actually opened the page; the requirement report — the one that answers is this rule covered — was only ever checked as text, never as a drawn page, so nothing was watching the part a reader sees. It now has one, and it holds the report to the four things it exists to do: every rule prints how many people hold it out of how many it reaches (the count that is on no other screen, and the reason a rule half the fleet already holds could otherwise be ranked above one that nobody who needs it holds); nothing anywhere on the tab or in the exported file is a percentage; the two columns that cannot be derived from anything this system records — average score and average time on task — are named on the page as undeliverable rather than drawn as a column of dashes; and a ticket whose expiry nobody recorded is drawn in the hatched “we do not know” pattern rather than as an ordinary answer, checked by reading the pattern the browser actually painted instead of the label in the markup. The exported file is downloaded, read back and compared with the table cell by cell, including the rule that reaches nobody, which a file listing only the ranked rows would quietly drop. The check was run at thirteen simulated dates covering every day of the week and makes the same 45 checks at every one — a check whose checks come and go with the calendar can never go red. No screen, report or export changed.
OQ-30, still open, still Simon's with finance and legal and still
coordinated with the terms wording. The shipped position is unchanged: unset for
the organisation and for every client, and unset still holds the levy rather than
picking a side. A guard asserts that no mode is assigned as a default in either
screen, including behind a comment.driver kind's field
spec was ['name','phone','licence','entraOid'], so the one axis a
depot-scoped compliance requirement matches on could not be typed anywhere in
the app — 0 of 20 driver rows carried a depot, and seven readers of that
axis (the compliance matcher's contextFor(), the matrix, site
readiness, the evidence pack's transcripts, both #/compliance
filter lists, both #/people columns and the role panel's
depot-scope list) were reading a field no control wrote. The box is on the
driver form, which #/crew's + New driver and every row's
Edit already open, so it is typed where the name, mobile and licence
are, through NF.Registry and nothing else, with the writer's own
sentence printed in the form rather than as a toast. The value is carried
verbatim — a depot spelled as a yard's code stays that code and is never
rewritten to the yard's id — which is what makes it correct under either answer
to the open depot id-versus-code question.NF.Registry.create() rebuilds a
record from the kind's field spec, so a depot handed to it was dropped:
measured, depot:'PER' came back undefined on the new
record. update() patches in place and rebuilds nothing, so a
stored depot survived an unrelated edit — what it did instead is the
quieter half, which is that a patch naming depot was silently
ignored while the writer still answered ok:true, “Driver
updated”. A save that reports success and writes nothing is the shape somebody
trusts. Both are closed, and both are now pinned in both directions, because an
update() later rewritten as a rebuild would pass every create
assertion while destroying the value on every unrelated save.#/crew answers about the driver, not about
the board. It read the driver's schedule lane — where the board has them
this week — under a heading a reader takes to mean the yard they work out of.
Those are different facts with different next actions, and until a depot could
be recorded only one of them existed. The recorded yard is the answer now and
the board's own yard is kept underneath it, named; the two are never merged.
Three answers rather than two: a value that names a yard is flat, with the
yard's name and code beside it; a value that names no yard is
hatched, because the compliance matcher resolves by id and a value
spelled as a code reaches nobody, which used to look identical to a driver at
no yard at all; and nothing recorded stays dimmed and flat, because that is a
known negative. The same three answers are on the driver's own page.create() upper-cases a depot code
and update() stores it as typed, so the two writers disagree about
one field. It is latent rather than live — the one shipped caller upper-cases
on the way in — and it belongs to the open depot keying question, so it is
pinned in a group that goes red the day somebody makes the two agree, and says
so.jobVia() decided which axis
made a gated requirement relevant to a run, and it answered
'role' for any rule carrying a role clause before it ever looked at
the depot, client or asset-type clauses next to it — so a second axis, which is
something somebody adds to make a rule narrower, made it universal:
“forklift ticket, at Newman” was published as “forklift ticket,
everywhere”, and no screen said the clause had been discarded. Measured on
the seeded fleet (36 crewed con notes, 20 drivers): a pure depot rule refused
19 of 36 runs — exactly the ones that touch that yard — and the same rule
with a role clause added refused 36 of 36. The clauses now conjoin by
group: (who: roles, everybody, a manual assignment) and (where: clients,
depots, asset types, business units, sites), with the clauses inside each group
still alternatives. The same rule now refuses 19 of 36, and
17 runs across 12 named drivers stop being refused — not one of them
anywhere near the yard the rule names. Since the departure gate is a soft block,
each of those was a false entry in a driver's file rather than a stranded load.
Nothing on the shipped register changes: the same 20 of 36 crewed runs are
refused, by the same rules, before and after.NF.Compliance.jobScope() answers
applies / no / unknown, the departure gate still refuses nothing on a
doubt, and the engine room's Refused at the pickup strip carries a fourth
state — hatched, naming the rule and what was missing, and never folded into
clear. Measured on the seeded fleet: 2 of 36 runs land in it and
8 of 36 carry such a doubt beside another answer.A test that crashed reported a smaller break than it caused, so the number read off it was not a measurement. One of the checks over the production purge script read the dump file it produces without first asking whether there was one. When the run under test wrote no dump — which is what happens if a collection is missing from the registry — that read raised an error and the whole file stopped where it stood, running none of its remaining checks. Anybody deliberately breaking something to prove the check works would then read off whatever partial count was printed and publish it. Measured, on exactly that break: the file stopped after 59 failures with 127 still to come, printed no summary line and no completion marker, and the backend runner reported it as failed with no count at all — so failures plus passes could not be reconciled against anything. Read defensively, the same break reports 102 failures and 177 passes, which is exactly the file's own total. A missing result is now a third state and never zero: the helpers answer −1 for the run produced no such list and 0 only for it produced an empty one, and the absent case is a failure with a name on it rather than a check that quietly does not run.
The shape that makes a test crash instead of failing is now refused from spreading. Sweeping every test file found the same shape in 70 more places: a check that asserts how many results came back, followed immediately by a read of the first one that assumes the answer was right. When that assertion fails at zero, the next line crashes and takes the rest of the file with it. Those 70 are recorded as a named budget that goes red if it grows — a new one cannot be added — and also if it goes stale, so fixing one means striking it off and the list shrinks visibly instead of rotting. The instrument proves itself on every run against the real case it was built from, plus the guarded forms it must not flag, because a sweep that has quietly stopped matching would otherwise report a reassuring empty tree. Its one exemption is named, and is asserted to be a file the detector really does flag, so it cannot become a quiet place to park a real instance. The narrower shape it cannot yet detect is written down as a known gap rather than left to read as covered.
tests/manual/rendered-contrast.js
was green on 12 of 12 simulated dates and measured 173 assertions on the six
weekdays, 158 on three Saturdays and 118 on Sat 3 Oct 2026 — printing
the same sentence, all N checks passed, for all three. A contrast pair
that is not drawn is not measured, and nothing read the count. The three sections
that sweep the driver's run sheet now seed it through the app's own writers
(NF.Odoo.recordPOD plus the status stamp the cab itself makes), and
every run-sheet pair is accounted for on every screen that sweeps the sheet — one
check per pair whichever way it lands, with a pair that is not on screen
named, together with the element that would have carried it, rather than
silently absent. Measured after: 227 / 230 / 230 on the same three dates,
with 36 of 42 run-sheet contrast pairs measured, 6 could not be stated
identically on all of them and the six named.opacity, so a leg-type chip on a completed job card — same
colours, same backgrounds, faded by .jcard.is-done{opacity:.6} and
nothing else — keyed identically to its twin on a live card and was thrown away as
the same observation. The live one passes at 5.98:1; the faded one is in the budget
at 2.66:1. Measured on 13 Sep 2026: neither a pass nor a failure was reported for it
on any of the three screens that sweep the run sheet.The tool built to catch stale claims carried two of its own, and nothing in the
repository could have told us. tools/claim-census.js named
91-claim-census.js under tests/suites/ as the suite that gates
it — twice, once in its header and once in what it prints when it finds something — and
that file has never existed, at any commit, on any branch. The census suite landed under
another number and was renumbered again since. Both citations are gone: the gate is now
found by name at run time, by the one part of a suite's filename a renumber does not
touch, so the path the tool prints is one it has just looked up rather than one somebody
typed into a comment. It answers three ways, keeping "we could not list that directory"
apart from "there is no such suite", and it does not refuse — a census that declines to
print because a sentence in its own header went stale is a census somebody deletes.
The refusal belongs instead to a new check over the whole tree,
tools/suite-citation-check.js, which is the answer to a cost #842 named and
declined to pay: it refused to renumber all 122 suites partly because "a rename rots the
prose citations of the old filename and nothing checks for a stale one". Nothing did.
Measured over CLAUDE.md, tools/, docs/ and
.github/ — 182 files, 365 citations — the tree held seven rotted
citations across five files, of which the issue knew about two, and three of the
five are files a dispatch is sent to read: the schedule-board doc pointed at a suite number
that was moved off a collided prefix by the very commit whose subject says it settled that
doc's figures, and the compliance register pointed at a suite that was folded into another
one before it ever merged. All seven are corrected, each naming the file that really holds
the assertions, and each keeping the wrong name as a quotation so the record of what was
wrong is not destroyed by the fix.
The check proves it can register a positive before its zero is believed: it self-tests on every invocation over the citations as they really stood, and refuses — rather than reporting a clean sweep — if its own matcher cannot find them, or if there was nothing to look at. A citation whose file is missing is reported beside the file on disk that carries the same name under a different number, which makes the repair a one-word edit. A suite directory it could not read is reported as we could not look and never as a tree with nothing wrong in it.
#1054 had already made one row
false about the line 135 above it, and re-checking all six by hand for this issue
turned up a second drifted claim nobody had noticed — the table's
own count of options: declarations, made wrong by the very commit
that corrected the first row. The retired wording of both stays where it is,
quoted inside the correction, because a correction that deletes what it corrects
leaves the next reader unable to tell a fixed claim from one nobody ever made.
(#1067)tests/suites/99-registry-picker-table.js reads the table out of the
app's own source and holds each of its six rows against the declaration it
judges: a row naming a reader that no longer exists reddens, so does a picker
wired into the form with no row, so does a row whose “takes the value”
disagrees with the reader's arity or whose archive verdict disagrees with what
the reader actually reads, and so do the two counts the table opens with.
Measured, not assumed: reverting the depot picker to its old raw read reddens
2 assertions and reverting the corrected prose reddens
4, which is the demonstration the issue asked for before any
guard was allowed to ship — and the 2 are the only reds in
node tests/run.js, the shipped coverage of that picker being a
browser suite CI shards separately. A quotation is treated as history rather than as a
claim — every row is read with quoted spans removed, so keeping a retired
wording in place cannot redden the guard that protects the rule requiring it.
The depot-reader census the table cites is deliberately not asserted: it
falls whenever somebody converts an unrelated call site, so it is cited by the
mechanism that reproduces it and re-measured here instead
(20 files read NF.depots raw, 3
guard at the read, 16 go through NF.liveDepots).
(#1067)A booking made after the database was cleared is no longer thrown away as if it had been made before it. A browser opening FleetView for the first time after the database was cleared would read the empty database, take a booking, fail to send it (no signal, a shut laptop) — and then, on the next start, silently delete that booking and report in its log that the copy "predates a purge of the database". It did not: it had been written a whole day afterwards. The cause was that the marker recording this browser has looked was only ever written when a copy was actually thrown away, so a browser that had looked and found nothing to throw away was indistinguishable from one that had never looked at all — and the second was answered by deleting its work. A start that finds the database cleared and has nothing of its own to discard now records that it looked, so the work survives; measured over a four-start sequence on one machine's storage, the booking now survives all four.
Two things are unchanged on purpose. A copy that really was written before the clear-out is still discarded even when it is carrying unsent work, which is the deliberate rule that stops a stale copy being written back over live data. And the obvious one-line fix — keeping any copy that has unsent work in it — was measured and refused, because it would have exempted exactly the machine that rule was written for.
The log line itself is also now honest in two ways it was not. It no longer claims a copy is old when it is not: it says which of the three things it actually established — that the copy predates the clear-out, that it does not, or that it could not be read at all. And where a copy is discarded it now says what went with it, naming whether it was carrying unsent work, instead of only saying why — with "we could not tell" kept distinct from "nothing was lost".
NF.Store autosaves the
whole document every five seconds — so a check that reports agreement over
collections it never read is the mechanism that puts invented drivers, trucks
and con notes back into the shared database as fact. The purge empties
34 collections; the check consulted three, and answered
why:'agrees' for the rest, while the only thing it named as
unexamined was the scope narrowing. Measured through the real
apply() in a real realm, one sentinel row per collection:
34 of 34 survive an answer that omits the key and 27 of 34 survive
one that carries it empty. The three were not a subset with a reason — they are
the three that happened to be on the screen the morning the fault was measured.
It consults 33 now.agrees is now returned only for a comparison that actually ran:
every collection is either compared, or named with its own reason for not
being — out-of-scope, account-narrowed,
withheld or unreadable, each in its own words on the
console line the last four readings of this fault were taken off. Two of those
are new and both are read off the answer rather than from a second list
kept here: the client portal’s GET /state keeps only the rows
naming one account for consignments, tmsJobs and
rateAgreements, so an empty consignments there means
“this account has none” and never “the database has none” — the same defect as
the scope narrowing, one level down, and widening the list is what would have
walked into it. A key that is present and is not a collection at all is
unreadable, never read as empty.demoData:false boot with no cache at all, exactly one go-list
collection comes up non-empty: rateAgreements, whose standard
customer:null rates are pricing configuration. The second arrived
while this was being written, which is the mechanism working rather than a
coincidence: intents joined the purge’s go list, the partition
check went red naming it, and it had to be classified — the driver’s intent
outbox is written by PUT /state and deliberately never returned
by GET, so an absent key there says nothing either way, and consulting it
would have fired a false discard on every tablet. Both are named in
NF.PURGE_RESIDUE_NOT_CHECKED with their reason rather than left out
of the list in silence, and each says what the omission costs — a
customer-specific agreement left in a stale cache is residue this will not find.
The other seven that an emptied answer would clear anyway are consulted,
because “apply() clears it” is true of the answer shape that
carries the key and false of the one that omits it, and a browser cannot tell
which it is about to be sent.Array.isArray(v) ? v.length : 0, which reads every map
as empty — so clientTerms, driverQuals,
driverUnavail, personRoles and truckMaint
could never have been found to hold anything. A map is counted by its keys now.
The suite reads the purge script’s own go list rather than restating it, so the
two lists cannot drift into agreement by hand: every collection the purge
empties is either consulted or named, the count is derived from that list, and
adding one to the purge script reddens here.The nightly day-independence sweep can now tell a coverage gap from a test
finding more of the screen. The sweep watches how many checks each browser
suite measures, because a suite that is green on every simulated date while
measuring a different number of checks on each can never go red and no
pass/fail verdict can see it. Its first finding was the rendered-contrast pass
for the customer portal and the driver cab, which measured 227 checks on one
simulated Saturday and 230 on another. Diffed check by check: 158 of them are
identical on both dates — every named colour row, every forced chip and
panel state, and every run-sheet pair — and all three of the difference is
the pass discovering one extra failing colour pair, because the run sheet
it was given happened to carry two kinds of leg-type chip on its completed jobs
rather than one. That is a property of the sample roster, not of the test, so
pinning the number would mean asserting a count of something the suite does not
own. It is left unpinned deliberately, and the suite now says so: a run prints
COUNT-AXIS: 159 fixed + 72 declared-variable beside its verdict,
with the reason in its own words, and the sweep compares the fixed half
across dates.
It is a split and never a mute. A fall in the fixed half is still reported — including a fall masked by a compensating rise in the discovery count, which the old total-only comparison could not see at all — and a count that moves on a suite that has declared nothing is reported as "we cannot say which half moved", never as clean. Three answers, so an unactioned finding the guard re-files every night cannot train everybody to ignore a real one.
The driver tablet no longer says "Synced" before it has read anything. At start-up, on a build with a database configured, the sync pill took its one reading from the app before the first read of the database had come back — and the answer it got was "Synced", because that was what the app said when it had no news either way. So "we have not started" and "we finished and everything is fine" were the same answer. The pill then cleared itself off the screen after 2.6 seconds and nothing ever corrected it, because nothing is announced while a read is still out. Usually the read landed a moment later and no harm was done. Behind a wifi sign-in page — a mine-site gate, a depot with a captive portal — the request is held open rather than refused, so the green "Synced" was the last word for the rest of the shift, over a run sheet nobody had checked against the office. The app now answers reading until a whole document has actually come back and been merged, and the cab shows "Not checked with the office yet — this is what was already on the tablet", drawn in the hatched treatment this app uses for we do not know, and it does not hide itself. It is deliberately not the same answer as a read that asked and failed: not having asked yet and having been refused are different phone calls. A build with no database configured is unchanged — it is not waiting for anything, and its pill stays hidden exactly as it always has.
A check now says what the deploy would publish from the repository root, and
refuses anything nobody has justified. Both deploy paths copy the working
tree to the live site rather than the files git is tracking, so
.gitignore does not stop a stray file being published — a scratch
.html left in the top folder would go live and render at a guessable
address. This is not hypothetical: the deploy's own notes record developer
documentation having been public for as long as it existed, because the rule that
was supposed to stop it only caught Markdown. Markdown itself was never the
exposure, and a claim that a stray PR-846.md had been published is
corrected here: both deploy paths exclude Markdown, and the check measures that
rather than assuming it.
The check is keyed on what would be published, not on what a file is called — a rule aimed at the shape of the last thing that went wrong has been shipped four times here and has missed the next one four times. It reads the exclude list out of each deploy command's own text rather than keeping a copy, so it cannot go on describing a deploy that no longer exists. Every one of the twenty-five entries in the top folder carries a written reason, and an entry nobody can justify is reported as unknown rather than quietly waved through — there are none today, and that is measured rather than asserted.
Two entries were researched rather than guessed. The data-model diagram
is meant to be published: the Help screen offers it as a download and links
it into draw.io, which only works if it is publicly fetchable. The old
backlog.md is not: it was retired in favour of GitHub Issues
and is excluded as Markdown on both paths.
The check also found something nobody had written down: the two deploy paths
disagree about four things, so what ends up on the live site depends on who
deployed. The by-hand path excludes the data-model diagram (which breaks both of
the Help screen's links), and publishes the whole developer-documentation and
release-tooling folders that the automatic path actively deletes from the site
before every deploy; the automatic path publishes .gitignore, which
the by-hand path excludes by name. All four are recorded with which path is wrong
and what fixing it would cost. None of them is fixed here, because three
are a change to how a deploy behaves and the fourth is not a one-line change:
excluding something stops it being uploaded again and does not remove what is
already there. Whether anything untoward is already on the live site needs a read
of the live storage, which this repository cannot take — that is unknown, and is
recorded as unknown rather than as nothing.
The check that stops a merge closing the wrong backlog item was reading one commit and calling it a clean bill. Every change is scanned for a wording that makes GitHub close an item nobody meant to close — a comma-separated list where only the first is acted on, or a sentence saying an item was not fixed, which the parser reads as an instruction to close it anyway. That scan reads the commit messages the checkout in front of it happens to hold, and the automated checks fetch only the most recent commit, so it was reading one message and reporting a clean result over it. Measured on two runs of the same change: the automated check examined 1 message and passed; the same change read with the full history examined 1,558 and found two malformed lines in one of the change's own messages. The gap was 1,557 messages, and always in the reassuring direction — the newest messages are exactly the ones a truncated checkout drops, and exactly the ones a check run before a merge exists to read. Two things changed. The pre-merge check now fetches the whole history, measured at about ten seconds and 20 MB more on a step that took two seconds. And the scan now has three answers instead of two: it says clear only when it could see everything, says could not look when it could not, and prints how many messages it examined beside whichever answer it gives, so a scan of one message can never again be reported in the same words as a scan of the lot. A run that finds a problem still reports it whatever it could see, because finding one is finding one.
Two comments in the shared layer said a driver-app change does not reach a
tablet until a release is cut. It does, and the sweep that finds sentences like
that one is now a tool rather than a one-line search. A change under
driver/ reaches installed tablets on their next online load after
the merge's own automatic deploy: every push to master rewrites the build number
and then stamps it into the driver app's offline cache name, so that cache
rotates on every deploy. No release cut is required. The two comments that said
otherwise were arguing for a design decision that is right for a second reason,
so the decision has not moved — only the false half of its justification,
which is kept on the page as a quotation, marked false, beside what falsified
it. The claim being plausible is why it keeps coming back: the release tool
really is the only thing that writes the version into the repository, which is a
different question from what is deployed, and both are true at once.
The worklist for that claim used to be a search published in the developer documentation, and it printed file names. Three things follow from that and all three are fixed: it now prints the matching sentence, which is what a reader has to see to judge it — one file had been recorded as a false positive on the strength of its name alone while it states the claim word for word, and that entry is corrected and the file named as still open; a file that corrects the claim in one place and repeats it in another no longer drops off the list silently; and a sentence broken across two lines by ordinary comment wrapping is no longer invisible, which had hidden twelve places in the repository, one of them an entire document on both sides of its own correction. What no version of this can do is tell a statement of the claim from a test written to catch one, so a test now says so in its own words. The remaining matches are listed by name with the reason each is not a defect, and that list fails if it grows or if something on it is quietly repaired and left listed.
enquiries is not in the deployed backend, so allocating automatically
would write rows that do not persist — is false, and is kept verbatim in the
brief rather than swapped out because it is what the issue's instruction was sized
against. backend/src/collections.js has carried the registry entry since
7297a291 (#1055, 12 September), and that commit is an ancestor of the head of
Deploy backend run #152 (18 September) and of #156 (24 September) but
not of #151 (10 September) — established by ancestry and by reading run #152's
own job steps, because runs #153–#155 all failed and a success conclusion
alone is not a deploy. The issue's re-check comment restated the constraint 62
minutes before that deploy started. What survives is the sizing that forced the
shard (745 B open, 853 B merged, so a col# singleton would 413
at 455–521 rows) and the register screen's refusal to guess, since no route
reports which collections a deployment stores.NF.Enquiries.create( and .ensure( have 0 callers outside
js/data.js across 116 files, so a record exists today only as a side effect of
a person pressing merge, split, relink or add-customer-ref — there is no
“raise an enquiry” control at all. Meanwhile the matching cascade already answers
five ways and one of them is create, and NF.Desk.tickets() already
holds the complete account of what arrived, keyed on the message id and drawn hatched
where the sender cannot be attributed. So the brief recommends allocating only on the
cascade's own create answer, when a person or the reply pass acts, and never
inside the poller's delivery loop. The identity half is measured rather than argued:
idFor('') is '', and the two persistence guards then disagree
silently and in opposite directions — the browser's merge admits it and folds every
reference-less record into the first, while the backend's write loop drops every one
unwritten. Volume is we could not look: the real 89-email and 37-email corpora are
not in this repository, all 17 fixtures are marked synthesised, and #385 is closed,
so no open issue tracks their arrival. Five decisions are refused by name, and who owns
the desk is recorded nowhere. Nothing executable changed and no assertion was added
(#1102).The Dispatch Board now says whether the compliance gate will refuse the run, on the card where a driver is put on it. The gate that stops a departure is checked in the cab, at the moment a driver taps to leave a pickup — so until now the first person to learn that a crewed run was short of a required credential was the driver, at a remote site, with nothing to do about it but tap through a second time and have the override written permanently into their own file. The Weekly Schedule started showing this; the Dispatch Board, which is where an unassigned card actually gets a driver, still said nothing. Every card now carries the answer, read off the same register check the cab itself runs and reported in its words rather than a second opinion.
There are seven answers and none of them is folded into another, because they send somebody to different places. A refusal names how many requirements will refuse, which ones and who — never a percentage. A run nobody is crewed on, and a run that nothing gating a departure reaches, are both drawn as the plain known negatives they are. Three different ways of not being able to look — nothing gated resolves for that person at all, a rule that resolves but whose reach could not be settled for this run, and no consignment to ask about — are drawn hatched and each says in its own words that it is not a statement that the run is clear. A clearance is only ever shown when the register really released every rule that gates the departure.
An empty credential register reads as an empty register and not as a fleet of blocked drivers. Measured on the sample fleet: 24 of 74 cards refuse, and every one of the refusals is "no record on file" rather than an expiry — so each says so on its face, because a board that refuses nearly everything teaches people to stop reading it. Nothing about what the gate does has changed and it is still not a hard block: the driver picker is untouched, allocation stays possible, and the cab is unmodified. Measured cost: a full board render of 74 cards goes from 20.86 ms to 42.34 ms, and the cost falls almost entirely on cards that already have a driver — an uncrewed card is 0.003 ms, so the large boards of unassigned imported work cost about 2 ms in total.
The consignment list is now ordered by when the freight is due, soonest first, with the con note number as the tiebreak. It had no order at all: rows came out in whatever order the records happened to be held in, which after an FMS import is the order the batch landed in. Due date is read through the same function the engine room's unscheduled tray already uses, so the two screens cannot disagree about one con note — overdue above due, and a con note nobody has given a date sits at the bottom rather than being treated as urgent or as slack. Deliberately not ordered by date created, which is the obvious answer and the wrong one: an imported con note is stamped with the moment the import ran, so twenty years of FMS history all carry the same timestamp to the second and would have presented as the newest work in the system. The number is the booking order those rows really hold. Within one due date the digits are read as a number, so CN-9 sits above CN-24817, and the two number spaces do not interleave — con notes we raised and con notes FMS raised are not points on one line.
A con note's pickup and delivery boxes now open on the places the con note already names. The panel only ever filled them from a copy saved by this same panel, so a con note that had not been through it opened asking you to choose a location — a few inches under a header already reading FLEETWOOD → HOPE DOWNS 1. That is every con note imported from FMS. The location is durable reference data and defaults; the site contact still never does, which was the whole point of this panel — a name nobody has checked must not be the easy option. A place already saved onto the con note still wins, so a default never quietly overwrites somebody's decision. And where the con note names an end the address book does not hold — which is the ordinary state until the places are imported — the panel now says so and prints what the con note says, instead of showing an empty box that looks identical to a con note naming nowhere at all.
--next read unmerged
branches with one git log --diff-filter=A, which records a file
under the name it was added with — so a suite added as
111-foo.js and renumbered to 112-foo.js later on the
same branch filed its claim under 111, where an on-disk entry already wins and
the branch read is never consulted, and filed nothing at all under 112. The
answer was not a silence but a sentence: “none of the 888 number(s) free
here is claimed on an unmerged branch — we looked, and there is none.” And
renumbering is what the tool tells you to do when it refuses a
collision, so the blind spot was opened by following its own advice — which is
the sequence that produced the 111 duplicate that took master red.
It now reads each unmerged branch's tip
(for-each-ref --no-merged then ls-tree per ref), so a
file is seen under the name it currently has, and it names the branch holding
each claim. Measured in a 380-ref clone: 89 ms for the retired read against
1,177 ms for this one, 280 tips at 4.2 ms each. It is deliberately
not bounded — a skipped branch is an unread branch, and the only honest
report for one is we could not look, never free — and one tip that
cannot be read declines the whole answer while still reporting how many were
read. Nothing fetches, and it still says so.NF.EWD: nothing connected, a
certified provider connected and named, or the seam not loaded at all —
three sentences, one hatch, pairwise distinct and driven as such. A provider
named in config.js deliberately does not buy a
green answer, because this panel still never reads an hour, which is the exact
defect the adapter's own disclaimer once had. A screen half derived and half
asserted is worse than either: the two halves sit inches apart and disagree,
with no way to tell which to believe. (#1117)intakeQueue#) was declared in the collection
registry and mapped nowhere in the PostgreSQL adapter, so the targeted read the
poller makes of its own queue — twice on every poll — was refused by
the store. Both reads catch, so nothing ever crashed and nothing appeared in a
log a person reads; instead the poller answered “every message in the
window was re-attempted” when several were inside their own backoff,
and the dead-letter state answered “unknown” however long a
message had been sitting there needing a person. Measured by driving the shipped
adapter: consignment# and mailIntake# returned their
rows, intakeQueue# threw. No message was ever lost — the rows
were being written, just to the fall-back location the whole-document read still
finds, which is why the screen looked right. (#1118)The Fleet Dynamics time-zone knob can now be set by a deploy, and a guard refuses any future setting that nothing can reach. Fleet Dynamics sends a bare wall clock with no time zone, and how many minutes to subtract from it decides whether a truck's last fix reads as four minutes old or 147 days in the past. The Lambda has read that setting since the fix that introduced it, and the template has declared it — but the deploy workflow, which is the only way the stack is ever deployed, had no way to pass it. Measured on the merged tree: the parameter appeared 5 times in the template and 0 times in the workflow, against 4 for its neighbour, which is the control that makes that zero a measurement rather than a broken search. It is the same defect, in the same file, one parameter along from the one repaired earlier — left behind by that repair.
The deploy now offers the setting, and refuses a value it cannot use rather than passing it on: anything non-numeric, and anything outside ±14 hours, is rejected by name before the build runs — because a value the Lambda cannot parse is read there as zero, which silently asserts the feed is UTC. A knob that reports itself set while doing nothing is the failure this exists to prevent.
Leaving it blank and setting it to zero are now different acts, and the deploy log says which one happened. Blank changes nothing; zero is a positive statement that the feed really is UTC. That distinction exists only in the deploy log, because the running code cannot tell missing from empty from zero — and the change deliberately does not make it refuse an unset value, since doing so would leave every fix undated until somebody set it, taking the live map dark. That trade-off is recorded rather than taken quietly.
And the wider problem behind it is now guarded. A parameter that exists and cannot be set turned out to be a class rather than an incident: of 44 settings the stack declares, 12 can be set by a deploy and 32 cannot. Most of those should not be — a credential's location, a vendor's address, a database's name — so each is now listed with the reason it is deliberately fixed, and the two that are real gaps are listed as gaps against the issues that filed them. The list fails if it grows and if one of its entries is quietly repaired, so it cannot rot in either direction. Nothing about the live fleet changes until somebody runs a deploy and passes a value; what changes today is that they can.
FD_FIX_TZ_OFFSET_MIN decides whether a fix from a truck is dated
four minutes ago or refused as impossible, and an unset offset is a positive
claim that Fleet Dynamics sends UTC rather than a neutral starting point. The
running Lambda has answered that three ways — nobody has set one,
somebody set this value, somebody set something we cannot
believe — on every /fleet/live response since the offset
reader landed. Nothing read it: measured on backend deploy run #156, the smoke
test printed “keys this report does not know about: skewMs tzOffsetMin
tzOffsetSource — NAMES ONLY, values withheld”, so the one
instrument an operator reads after a deploy withheld the answer. It reports it
now, in words, with the offset it resolved to beside it. (#1119)backend/src/fleetdynamics.js read the offset as
parseInt(process.env.FD_FIX_TZ_OFFSET_MIN, 10) || 0, which answers
0 for nobody has set it, for somebody set 0 —
which states the feed is UTC — and for somebody set something
unreadable alike. Measured on the retired expression, seven of eight
probe inputs came back identical ('', unset, '0',
0, 'abc' and '-0' all 0; only
'08' differed, at 8). That is the
Number(null) === 0 family this repository has been bitten by before,
arriving on the field that decides whether a fix is dated four minutes ago or
147 days in the past.
And 0 is not a neutral default here. It is exactly the value
that reads a bare Fleet Dynamics wall clock as UTC, so a fresh fix parses
one zone-offset into the future and fixTime’s rule 3 correctly
refuses it — measured on production sweeps 144/150/151,
futureTime 20 of 22 and 19 of 26 twice, with
noTime and badTime 0 throughout (#602).
The reader now answers three ways — unset,
set and invalid — classified on the same three
axes the deploy guard settled in #1151 (shape, range
−720…840, quarter-hour), with a suite that runs the
shipped workflow guard and compares the two answers value by value so they
cannot drift. The answer rides diag.tzOffsetSource on every
GET /fleet/live beside the futureTime counter that is
its independent evidence, and the whole-sweep warning now says which of
the three it is rather than ending “set
FD_FIX_TZ_OFFSET_MIN” at somebody who already had.
Until now those three facts existed only in the deploy log, and a log line
is not a thing the running system can read — worse,
--parameter-overrides is sticky, so a routine deploy passing
nothing logs “keeps its current value” and cannot say what
that value is. Today’s log is right only because nobody has set the
parameter yet.
Nothing a truck can feel changes. The arithmetic is byte-identical for
every input, the unbelievable ones included — '480.5' still
reads 480 and '0x1E0' still reads 0 — and that is pinned by a
guard of its own. Whether an unset offset should refuse, leaving every fix
undated and the live map dark until somebody sets one, is an operational
decision put to Simon on #1119 and deliberately not taken here.tools/preflight.js had its own
git log --all … --diff-filter=A over tests/suites/ —
the identical added-name blind spot — and then printed “✓ suite numbers: all
N free in this tree are free on every unmerged branch too”: a positive
claim, with a tick, from a scan that could not have found the positive, on the
report a dispatcher reads before choosing a number. It now calls the one
reader rather than keeping a second copy, and the tick is reachable only from a
read that came back checked; a read that declined prints we
could not look with the reader's own sentence for why, and no number is
cleared. A claim is named with the file and the branch holding it.
--free/--next
read refs/remotes/origin/ alone, because a number is handed out
against what has been published; this report also reads
refs/heads/, because it runs in a shared checkout where several
dispatches hold their own worktrees over one object store. Measured on 18 Sep
2026: 97 unmerged local branches with no remote counterpart at all,
holding two suite files present on no origin tip —
111-bare-time-stamps.js and 111-global-search-cap.js,
111 being the number whose duplicate took master red. So the retired read was
right about the namespace and wrong about how it read it, and narrowing to
origin here would have traded one blind spot for another. Tags and any second
remote are dropped, which is the only narrowing against --all.
Cost: 383 unmerged tips in ~1.6 s, on an axis that has already been to the
network.
freeNumbers(onDisk, 99), which caps how many numbers come
back rather than how high they go — so “all 99 free in this tree”
was measured here over 883.
node tests/run.js: this report's only cover is
tests/preflight-check.js, under npm run checks.The suite-number tool no longer tells you the branch list is half an hour old
seconds after you fetched. It dates its own answer off FETCH_HEAD, and
a git fetch run in a linked worktree — which is how every agent on this project
works — writes that worktree's own copy and leaves the shared checkout's alone. The tool
read only the shared one, so --next printed "AS LAST FETCHED — 27 minute(s)
ago" immediately after a successful fetch, and told the reader to do the thing they had just
done. It failed the reassuring way too, which is the worse direction: a worktree that had not
fetched for a day read as current because a different checkout fetched a minute ago. The
remote-tracking refs are shared between worktrees, so the freshness of what the tool actually
reads is the newer of the two timestamps, and that is what it now answers — measured in
a linked worktree of this repository at 01:50:18 for the fetch made there against 01:50:11 for
the shared copy, where the tool had been answering 01:50:11. Reading only the worktree's copy
would have been the opposite mistake and is guarded against separately. "We could not read
either" is still its own answer, printed as NEVER FETCHED rather than as an age of zero.
A comment and the test that guarded it were wrong together, and both are corrected in
place with the retired reasoning kept beside the correction. The tool's own comment cited
git-common-dir as the reason the shared copy was the right one to read, and the
repository guard asserted that same rationale in the same words — so the check agreed with the
claim instead of testing it, which is the hand-written-copy failure this project already
records for its timezone day key: a copy only protects you if the hand differs. The retired
sentence is quoted where it stood rather than deleted, so the next reader can tell a fixed
claim from one nobody ever made. The replacement guard stands up a real throwaway repository
with a real linked worktree and a real fetch in it, because a fixture in an ordinary checkout
agrees with the right implementation and the wrong one alike — there, the two timestamps are
one file.
backup#) was declared in the collection registry and mapped
nowhere in the PostgreSQL adapter, so the targeted read the backup routes make
of the catalogue was refused by the store. Every capture was still being
recorded — the rows were written to the fall-back location the
whole-document read still finds — but the two places that read the
catalogue as its own question could not. Measured by driving the shipped
adapter: consignment#, mailIntake# and
intakeQueue# returned their rows, backup# threw.
(#1124)The close-keyword check printed a remedy that could not clear it, measured end to
end on one pull request. The check that stops a pull request closing an issue against
its own meaning fired correctly on a description reading "Do not close #602" —
GitHub's parser reads the keyword and not the negation — and then told the author
"Edit the pull request title or description — no push is needed." The
description was edited, the job re-run, and it failed again on the same step. Two
independent mechanisms, either alone sufficient: an edit fires no workflow at all,
because edited is not one of GitHub's default activity types for
pull_request; and a re-run replays the original event payload, so the
step re-reads the body as it was, however many times the button is pressed. The only thing
that could clear the check was a push — and the two ways to force one without a code change
are an empty commit and a close-and-reopen, both of which this repository bans. A guard
satisfiable only by a banned action gets satisfied by the banned action, or it gets
ignored; either way the protection measured at 12 orphaned issues and two wrongly
closed ones is gone.
The check now also runs from its own workflow,
.github/workflows/close-keyword.yml, on
types: [opened, edited, synchronize, reopened] — so saving an edit fires a
fresh run carrying the new text, which is the printed remedy actually working. It is a
separate file rather than one line added to ci.yml's triggers because
types: is per-workflow and not per-job: every description edit would
re-run all seven of that pipeline's jobs, six of them browser shards, roughly 28
runner-minutes to re-read a body. The cost of the alternative is recorded in the new
file rather than left implicit, and ci.yml keeps its own copy on the default
triggers.
The printed remedy was rewritten in the same change, because a remedy that names an action which does not work is worse than none — it tells you you have fixed the thing. It now names the file that makes an edit fire (so the sentence is true only for as long as that file is there, which an assertion binds), refuses the "Re-run jobs" button by name with the reason, and says the half nobody can fix from here: fixing the text may not undo the link. On the same pull request #602 closed on merge four minutes after the closing phrase had been edited out, from a squash message carrying no closing keyword at all — verified from inside this repository by running the tool over that commit — with no commit id on the close event. So the remedy points at the pull request's own linked-issues list and says whose answer that is, rather than claiming the link is clear; nothing here can read GitHub's linkage table, and nothing here will pretend to.
Two smaller repairs ride with it. Every we could not read the text answer — the
body never written, the title never written, the title arriving empty — used to end
"Repair the wiring in .github/workflows/ci.yml", which was true while
there was one caller and sends half of those failures to a file that did not run the step
now there are two; the workflows are named from one exported list, and a guard asserts that
list is exactly the set that invokes the tool, derived off disk in both directions, so a
third caller cannot be added without the sentence learning about it. And the older suite's
printed coverage note, which recorded "the edited activity type is not in the
trigger" as a known gap, carries its correction in place with the original kept, since
that sentence is still true of ci.yml and the figure beside it is what the
decision was sized against.
It is a report and not yet a gate, which is a repository setting and not a file. Nothing committable here can make a check required. The new job's name is the string an admin adds to master's required-status-check list, and it is asserted rather than left to care — renaming it would silently un-require the check. It is a separate workflow precisely so that switch can be thrown without making six browser shards required at the same time.
#/clientcontacts and #/locations. It was a card
at the top of every view naming who you are and stating whether that identity is
verified, and it arrived before anybody had done anything. Simon ruled it off the
screen entirely, including the four unverified states the issue was first filed
recommending it be kept for. Two screens went, not one. The issue
as filed said the banner drew on one screen, on the strength of a search for the
words “Working as” returning exactly one file — a
search that is accurate and a conclusion from it that is wrong, because
#/locations carries the identical component and words its
label “Confirming as”. The instrument was looking for the
wording where the thing is a component. Re-measured by shape — a card whose
class ends -actor, holding a label and a prose span — there
were exactly two in the tree, and one removed with the other left
is how two copies of one component drift apart, which is precisely what those two
blocks existed to stop. .cc-actor and .adb-actor left
css/modules/cc.css and css/modules/adb.css with the
markup, so nothing is defined and undrawn. (#1129)<select> deciding who a confirmation, a note and an
AI-note check are recorded against, and its two screens are the ones whose entire
job is an accurate record of who rang whom. Removing the card with the select
inside it does not fail, error or leave a field blank: it quietly files every
lastConfirmedAt on every client and site contact against the
fallback, which is the last person offered, which is Dispatch.
So the picker now sits inside the contacts card and the confirm worklist —
a caption on an act rather than furniture on arrival — and every writer on
both screens resolves through one function instead of reading the
variable directly, the shape the registry's own regBy() already
uses. That matters because a later edit reading the bare variable would hand the
writer an empty string on any view that never drew the picker, and
NF.Contacts.confirm() only writes confirmedBy when the
value is non-blank — so the previous person's name would have
stayed on the record, which is quieter still. A verified session is offered no
picker at all, unchanged: an actor you can pick is one you can pick somebody else
for. (#1129)#/identity states
all five states in full. (#1129)tests/suites/123-actor-picker.js sweeps every file of the dispatcher
app for a card whose class ends -actor rather than for either set of
words, so a third copy appearing tomorrow under a third verb reddens here where a
word-search would not see it; it drives the acting-person control through all
five identity states off the real screen source; and it derives from that source
that no writer on either screen reads the actor variable outside the one
resolver, so a sixth writer added later is covered without anybody remembering
to come back. Every absence claim in it reads the comment-only stripper and not
the one that also blanks strings, because every name it forbids is markup
(#1159). The end-to-end half is in the browser suites, which pick a person who is
not the default, press the real Confirm button and read the name back off
the record. Twenty-eight assertions that measured the removed banner's geometry
were deleted with it rather than softened — keeping them
would have meant tolerating a missing element, which is an assertion that passes
because the thing is absent — and two guards that merely used the banner as
a sentinel were re-anchored instead, one of which had become unfailable.
(#1129)tools/arrival-prose-census.js still said the ✕ “does not
stick” — a session-only flag, back on every reload — and offered
persisting it as the alternative to removing the card. fe364f94 built
exactly that on 19 September and did not touch the census, so the two contradicted
each other for six days with nothing asserting either sentence; the #1133 entry
that introduced the census states the claim as it stood when that was measured.
The census row now keeps the retired sentence, marked false and dated, and says
what is still true of it: the dismissal is per browser, not per person; where a
browser refuses storage it lasts only until the next reload; and nothing in the
app brings the card back once it is dismissed. tests/suites/134-arrival-prose.js
now works out from the app’s own source whether the card’s flag is
read back from the key its ✕ writes, and reddens if the census and the
source disagree in either direction. Measured: undoing the persistence reddens
2 assertions in node tests/run.js, where before this change
it reddened 0 — the only other check being a browser suite CI runs
separately. Nothing on screen changes, and whether the card’s sentence
stays is still Simon’s ruling. (#1130)A documentation-only change no longer runs six browsers, and the honest
saving is about 5%. CI carried no path filter at all, so a pull request
changing three markdown files ran the whole six-shard browser matrix.
Measured off GitHub's own per-job timings on 18 September 2026: one full run is
47.65 runner-minutes, of which the browser shards are 40.92 — 85.9% — and a
merge runs the suite three times for 110 billed minutes. A new
changed paths job now classifies each change and the browser job
reads its verdict. Replayed over the 190 real merges of the preceding ten days,
it skips the browsers on 15 of them (7.9%): those merges drop from 110 to
24 billed minutes and from 10.2 to about 6.9 minutes of wall clock, while
every other merge pays 2 billed minutes more for the classifier itself.
The expected saving is therefore about 5 billed minutes per merge, ~5.5%
— worth having, and much smaller than the 85.9% headline suggests, because 92%
of merges touch code. The Playwright browser download is also cached now, which
was estimated at 7.87 seconds of a 25.96-second install step per shard and then
measured at 12 seconds — the download collapses and the cache's own
save step disappears, and the estimate had counted only the first. The remaining
14 seconds is apt-get, which no cache can cover, and the install is
deliberately split into two steps so CI's own timings keep saying so. A cold key
costs about 5 seconds more per shard, paid once per Playwright version, so the
first run after a bump is slower by design; and a cache written on a pull request
is not visible to other pull requests, so the shared copy is the one the run on
master leaves behind.
The filter is written to fail towards running the browsers, because the browser suites catch defects the dependency-free suite scores zero on — a stylesheet rule losing the cascade, a hatch shipping flat, a contrast pair passing a static check — and a silent loss of that is worth far more than the minutes. So it is an allow-list of documentation paths rather than a list of the code paths that matter: a directory nobody has thought of yet is not on the list and therefore runs. A change that could not be classified at all is a third answer, reported by name, and it runs them too; an empty file list counts as that third answer rather than as "nothing changed", because a diff that came back empty is far likelier to be a diff that failed. The suite job is untouched and cannot be skipped by anything, which is asserted rather than intended — it is the job that reads the documentation off disk, so a documentation change is exactly what it is for.
The duplication of running the same suite three times was examined and deliberately kept. The deploy's verify step runs only the dependency-free suite — no browsers, no backend suites — and sits behind a repository variable, so it does not stand in for the run on master. The run on master earns its place on measured evidence: of 600 CI runs, 29 reds on master, 23 of which were already red on their own pull request, but six were green on the pull request and red on master, and two of those six were a browser shard alone. The cause is a race rather than an accident — master moved between the last pull-request run and the merge on 38 of 80 merged pull requests (47.5%), and the clearest case put master red when two branches independently claimed the same test-suite number, each perfectly green on its own. A merge queue is the fix for that and is recommended, but it replaces the master run one-for-one and saves no minutes; it needs a repository setting, and enabling it without removing the master trigger in the same change would make things worse rather than better.
And the first version of this change was refused by GitHub while passing
every check on disk. One expression — ${{ runner.temp }} — sat in
a job-level env:, where that context is not available; only a step
can see it. The file is valid YAML and loads cleanly, so the workflow-parse check
could not see it either. What GitHub produces in that case is not an error
message but a workflow run named after the file path instead of the
workflow, with zero jobs and therefore zero check runs — so the pull request had
nothing that could go green and could not be merged, with every local signal
clean. It was isolated with throwaway probe workflows (a refused workflow bills
nothing, having no jobs): the same context one level down, inside a step, runs
fine, which is what makes it the placement rather than the context. The repair
needs no expression at all, and a new guard refuses the construct statically —
restoring the defect turns two assertions red while the YAML parse stays green,
which is the whole point. A check that reads a file is not a check that the
platform will accept it.
CAMERAS_JSON is no longer read by either deploy path, but deleting
the secret itself needs repository-admin access and cannot be done from a
branch. Until somebody does, it is an unused credential slot sitting in the
organisation — the kind of thing a later audit has to ask about.tools/arrival-prose-census.js enumerates js/screens/ off
disk and names each file, with its reason, against the line #1130 draws: an
unconditional card above the content restating what the screen is, against a
sentence telling somebody something they could not otherwise know. Nothing was
removed — the issue asks for a list, and each removal is its own child for
Simon to rule on. The shape swept for is a sentence, not a style.
#1129 was filed saying the identity banner drew on one screen, off a search for
the wording, and the second screen carried the same component under
different words; #1133’s own first pass then reached for a second phrase
instrument, a search for one inline CSS string, which flags 14 screens and cannot
tell a page lede from the travelling-height sentence a 4.6 m permit against a
5.0 m load is the reason for. This one reads prose runs out of the string
literals, comments stripped, so it is blind neither to markup built by
concatenation — a block-element matcher returns nothing at all for
15 of the 71 files, including the identity screen, which is made of little else
— nor to a sentence somebody worded differently. (#1133)mixed, and a mixed screen is named and not proposed: the
lede and the fact have to be split into two elements before anybody can rule on
it. The census reads 6 furniture · 28 mixed · 30
load-bearing · 7 with no on-arrival block. (#1133)tests/suites/134-arrival-prose.js refuses
any candidate carrying a state, a cause, a provenance or a refusal, so a
nomination that drifts into an honesty sentence reddens rather than being argued
about in a pull request; it refuses a screen file on disk with no verdict against
it, so one added tomorrow cannot inherit “clean”; and it pins the
three sentences no child of the epic may delete — the work-diary disclaimer,
“FleetView cannot merge two records, for anyone, anywhere”, and the
compliance matrix refusing to read an empty filter as a clear register. (#1133)PAGE_HINTS in js/app.js puts a hint per route into the
topbar on arrival, and the guided tour points at that element by selector and
tells a new user it is there. So a lede can be the same sentence three inches
below one the reader has already been given. Two consequences are now asserted
rather than left to be discovered: a removal aimed at the hint instead of
the lede would leave the tour one step short, because
startTour() skips a step whose selector is missing and says nothing;
and #/dashboard’s “Getting started” card already has
a × whose state is a session-only variable that is never persisted, so it is
back on every reload — the fix there may be one line of
localStorage rather than a removal at all. (#1133)#/clientcontacts and #/locations
with the identity banner, so it is neither number now. The comment records what it
said and why it was wrong, and the figure itself is measured in the suite, because
a number in a comment is what went stale both times. Two more copies of the same
claim are live outside this change’s lease and are reported rather than
edited: js/data.js and
docs/requirements/90-platform.md. (#1133)The dispatch report dated its own fetch to a sibling checkout's, and in the
worktree every agent actually works in it could say "just fetched" about a clone
that had never fetched at all. tools/preflight.js carried its own
copy of the FETCH_HEAD read repaired last week in the suite-number
tool — same function name, same shared-directory-only lookup, same direction of
error. A git fetch run in a linked worktree writes that worktree's own
FETCH_HEAD and leaves the shared checkout's alone, so
“Last fetch in this clone: …” reported a different checkout's fetch. Measured
in a linked worktree of this repository: the worktree's own fetch at 06:24:53 against
the shared copy's 06:22:59, with the tool answering 06:22:59 — and two minutes earlier
the worktree had no FETCH_HEAD at all while the tool still answered the
shared checkout's fetch of ninety seconds before, which is the unbounded version of
the same error and the reassuring direction. The size of the gap is never the
magnitude: it was 114 seconds here, 27 minutes in the suite-number tool and 7
seconds in the issue that reported it, every one of them a fact about what the other
agents were doing at that moment. The read is now the one reader, called rather than
copied — the freshness of what the tool actually consults is the newer of the two
timestamps, because the remote-tracking refs are shared between worktrees. "We could
not read either" is still its own answer and still prints as NEVER, never as an age
of zero and never as today's clock.
This copy was worse than the first in one respect, and the guard for it had to
be end to end in two halves because no assertion about the file's text can see it.
The report fetches itself and dates the answer three lines later, so the thing
to prove is an ORDERING between two statements rather than the shape of a function: a
check that greps for the right reader's name is satisfied by a file that calls it at
the wrong moment. So the repository guard stands up a real throwaway repository with a
real linked worktree, never fetches in it, and lets the tool's own fetch be the only
one that can write the worktree's FETCH_HEAD — which makes three
different wrong implementations red and only one green. The second half drives the
real command line in that worktree and matches the printed timestamp to the
millisecond. Measured against a baseline of 31,188 assertions and 262 checks:
restoring the retired reader reddens 4 assertions and 2 checks, taking
the reading before the fetch instead of after reddens 2 assertions and
0 checks — the ordering is invisible to the command-line half, which is why
both exist — and letting an unreadable pair fall back to the epoch reddens 1
assertion and 2 checks. One of those figures is a finding about the guard
rather than about the tool: the absence check was first written over a stripper that
blanks string literals as well as comments, so it could not have seen
'FETCH_HEAD' spelled out in code however plainly, and it reddened one
short until that was corrected.
node tests/run.js no longer reads as cover over the guards
it never loads. tests/preflight-check.js holds the whole guard
surface over the dispatch report — the thing a dispatcher reads before
choosing what to work on — and the suite runner cannot see it, because it
globs tests/suites/ and that file is not in it. Three verify-to-fail
breaks taken on it reddened 13, 3 and 3 of its own 251
assertions and zero in the main suite, all three reconciling. It is not
unwired: npm run wiring accounts for it and CI runs
npm run checks. What was wrong is that a green
npm test is read as “the suite passes”, and for that file
it means nothing at all.
require.cache
after a full run: of the 23 .js files at tests/
root the run loads 9 and never loads the other 14, which are exactly
the fourteen *-check.js guards npm run checks names. The
same sweep over tools/ found only break.js and
release.js unloaded and neither is a guard, so every guard under
tools/ is reached by some suite.
unread · line beside inputs ·
naming every one of them, the command that does run them, and — in the
runner's own three-state words — that the verdict above is no verdict on
them: not passing, not failing, not run. It is measured, not listed:
the set is the directory minus what the process actually required, so the
fifteenth guard is named on the first run after it lands with no ledger to go
stale. A run that could not look says that and never prints a zero, a
measured empty says it was measured, and a filtered run withholds the
“nothing runs this” claim it cannot make honestly. Every name is
printed rather than capped, because preflight-check.js sorts tenth of
the fourteen and a six-name elision would have hidden the subject behind
“and 8 more”.
docs/modules/testing.md). B, running
the guards inside the suite, is cheaper than the issue assumed — 8.2 s
for preflight-check and 33.0 s for all fourteen against a full
run of 345.7 s — and is refused because it fixes fourteen files and not
the next one, and puts a git-spawning script inside the suite whose documented
property is that it is dependency-free. C, folding npm run checks into
npm test, is refused because the command the test bar names is
node tests/run.js, so C would leave the very misreading it is aimed at
exactly where it was. Measured against a clean ALL PASS — 31,762 · 138 of 138 suite files
executed, every break confirmed applied by counting the changed text and every
run reconciling: neutralising the new footer clause reddens 5 in
tests/suites/127-runner-unread.js, printing the measured-zero wording
for a read that FAILED 2, a filtered run making the “nothing runs
this” claim 2, restoring the six-name elision 4, and naming a
guard by a string literal in tests/run.js instead of deriving it
1 — while breaking an assertion inside
tests/preflight-check.js still reddens 0 here and 1 of 262
under npm run checks, which is the gap itself and is now the thing the
summary says out loud rather than the thing it hides.To: and this quote's acceptance link already in
the body. It built mailto: +
encodeURIComponent(q.email || ''), and the body under it is composed
by NF.QuoteAccept.messageFor — which carries the acceptance
bearer link, openable with no sign-in by anyone it reaches. So a quote
holding no address did not fail: it opened a mail client ready to send that
quote's acceptance to whatever the operator typed next. This is narrower than the
same defect on the consignment screen and is deliberately not widened into it —
re-measured on the loaded sample, 16 of 16 quotes carry a non-blank,
email-shaped address against 0 of 50 consignments carrying an
email key at all, so this blank is conditional where that one
was unconditional. Lower frequency, the same disclosure when it fires. The
control is now off where there is nobody to send to — a disabled button carrying
no href at all — with the reason printed in the card and never
toasted, because a refusal that slides away while somebody is still reading it
has not been read. Nothing ever recorded an address is drawn
hatched, which is the email-drafted path that writes no
email key; an empty address is on the record is drawn
dimmed and dashed, which is the new-quote form submitted with the box
empty, and both of those are measured reachable rather than assumed. A quote
whose thread or record cannot be read is we could not look and is folded
into neither. There is deliberately no fallback: it reads the quote's own
address and nothing else, not the client's nominated notification address, the
customer record, the quote's own inbound mail trail, or whoever last wrote in —
and the refusal is asserted to stay a refusal with a resolvable nomination
sitting on the same client, because a defaulted recipient does not fail, it
discloses. Whether a quote may ever go to an account's nominated address instead
of its enquirer is a business decision and is open. Copy link and Copy quote
email are unaffected: choosing your own recipient in your own mail client is a
different act from this app addressing one. One rule, one home — the email-shape
test is now exported from the client-terms reader rather than copied, and a build
that cannot reach it refuses by name instead of guessing. Restoring the blank
To: composition reddens 3 assertions in tests/run.js
and 4 of 244 in tests/manual/quote-accept.js, where the browser
reads the restored link back at 4,131 characters with an empty
To: — the disclosure reproduced exactly; collapsing
dimmed-and-dashed into hatched reddens 3 and 2; making the resolver fall back to
the client's nominated address reddens 7 and 2.A quote now says which version of the terms and conditions it cites. The version has been recorded on the quote thread at send since the record was built, so it cannot be left off — but the only way to see which one a given quote carried was to open the register and work it out from dates. It is on the quote screen itself now, beside the Send button, in three states: the version it cites (with its permanent link, and with a warning where the wording has since moved on and re-sending would not re-date what the customer was given); a recorded "nothing had been published when this went out", drawn dimmed and dashed because we know that one exactly; and no record at all — a quote that predates the mechanism, or one that has never been sent — drawn hatched, never as today's version and never as a blank. A blank there reads as "no terms apply", which is a claim about a legal document nobody made.
The terms screens are checked in a browser. The register
(#/terms), the public per-version page
(portal/#terms/<n>) and the new stamp on a quote had 188
assertions behind the record itself and none behind anything a human sees. 75
browser checks now cover it: that the public page opens with no sign-in, that a
superseded version still renders its own wording rather than today's, that a
version we no longer hold is refused rather than answered, that a half-typed
version survives the repaint a database sync performs, and that the two empties
on each screen are drawn differently — "we could not look" hatched,
"we know, and there is nothing" dimmed and dashed. Each is read off the computed
background, not off a class name, and the two are compared against each other:
checked in isolation both pass with both drawn identically, and the customer-facing
page loads none of the dispatcher's stylesheets.
The driver sign-on screen no longer tells a driver the register is empty when it simply could not read it. When a tablet had nobody to offer, the sign-on screen disabled the picker and the Sign on button and said “No drivers on the register yet — ask the office.” The refusal was right and has not changed — an empty picker submits an empty name, and a tablet signed on to nobody is worse than one that will not let anybody on — but the sentence was a flat claim about the database, made over a list that is empty for at least three different reasons: the register really holds no drivers, the last answer from the database could not be used, or this tablet has not had an answer at all yet. On the screen that gates a driver’s whole working day, it sent people to ring an office that may have had nothing to fix. The screen now asks the one reader the rest of the app already uses for this question and gives three answers instead of one. We read the database and it holds no drivers still sends the driver to the office, because there it really is the remedy, and is drawn dimmed and dashed — we know exactly. We could not read it and we have not read it yet are drawn in the hatched “we do not know” pattern instead, each says in as many words that it is not a statement that the register is empty, and neither sends anybody to the office: the first says to get back into signal and reopen the app, and the second says this fills itself in the moment a document lands and no call is needed. The refusal was measured in all three answers and in five more — including a reader that fails outright and a state this app has never heard of, both of which fall to the hatch rather than to a claim — and by pressing Enter in the password box, which is the one route past a disabled button. The hatch is read as the pattern the browser really painted rather than as a class name, because the driver app loads only its own stylesheet and a pattern borrowed from the dispatcher would have shipped as a plain box with every check still green.
The "Licence classes" box on a run line said it stated what the run
requires. It never did, and it now says so. The box was stored on every run
line and read by nothing — measured across the whole app, there are three
places that touch the field and all three are writing it. A dispatcher typing
MC there reasonably believed they had said what the run needed;
they had not, and no screen said otherwise. It is now labelled for what it holds
— licences, endorsements and cards, which is what the taxonomy behind it
actually distinguishes, where the old label named only one of the three —
and the sentence under it names where the requirement really comes from: the rig
allocated to the run, on the engine room's row. It was reported as a check that
fails permissively; measured, an unrecognised value does the opposite, so
the fix is an honest label rather than a repair to a gate.
What is written against a run line is now shown back, in three answers. It was written once and visible nowhere, so a value nothing could make sense of could never be spotted, let alone corrected. Each run line now shows what it carries: a value we recognise, named with whether it is a licence, an endorsement or a card; a value we hold and cannot make sense of, drawn hatched and quoted exactly as it was typed, so trailing spaces are visible; and a run line carrying no note at all, drawn as the measured empty it is rather than as something we could not read. Nothing stored is rewritten, re-cased, trimmed, reordered or deleted — a value somebody entered on purpose is kept and reported, never quietly dropped — so nothing needs migrating and anything written before this change reads as it is.
#/schedule — about 290 lines of a 1,146-line screen
— sitting above the fold on the screen dispatchers open to look at the
week. It is its own screen now, at #/runlines. Nothing about the
card changed: the same three-state empty, the same retired accordion, the same
refusal printed in the form in place, and NF.Schedule is still the
only writer, so there is no new collection and nothing to deploy. The one
wording that moved with it is the one the move made wrong — it called
itself "a row of this board" on a screen that is not the board.scflBoardState() is what the scheduling-conflicts
panel and the run-lines list both ask, and splitting the two cards onto two
screens is exactly what makes copying it tempting. A copy would not crash and
would not look wrong — it would let one screen say "we have not read
the roster" while the other says "the roster holds no run lines",
with neither wrong on its own terms. It stayed declared once, on the schedule
screen, and a second declaration anywhere now reddens
tests/suites/132-run-lines-config.js. Two of the issue's own
expectations were measured false on the way: laneById does
not travel with the card (nothing in it ever called it; its callers are
the engine room's swap handlers), and the "two help/description tables"
in js/app.js are two rows of one table.index.html — which carries
every nav item, every screen's <script> tag and every
stylesheet link — was leased to another change, so the leaf beside
Pricing & defaults could not be added here. That makes the link on
the Transport Configuration screen load-bearing rather than decorative: without
it, the only control in the app that can create a run line would be reachable
only by typing a URL, and on a build with demoData off there are no
run lines at all, so nothing could be assigned to anybody. The link is pinned by
an assertion that says so in its own words.index.html carried no navigation
entry for it at all. That is sharper than an ordinary missing menu item: it
holds the only control in the app that can create a run line, and nothing can be
assigned to a driver until one exists, so the precondition for the whole board
was off the menu. The leaf now sits in Transport → Configuration,
after Pricing & defaults. The link on Transport Configuration is kept, and
so is the assertion pinning it: “is there a way in from a screen somebody
is already on” and “is it in the menu” are different questions,
and this was a screen that had the first and not the second.fixStatus() printed “movement unknown · no readable
fix time” on the dashboard’s Fleet status list, Live Fleet’s
side panel, every map popup and the asset page’s summary tile, and
undatedNote() said “The unit did not send a readable time
with this fix”. Both are claims about the unit, and #602’s
diagnosis is what proves them false: FD_FIX_TZ_OFFSET_MIN is
0, so Fleet Dynamics’ bare local wall clock is read as UTC,
every fresh fix parses one zone-offset into the future, and
fixTime’s rule 3 refuses it — correctly. The time was
sent, it was read, an instant was formed, and the instant was disbelieved.
Measured on three production sweeps read out of deploy-backend.yml’s
own smoke test (runs 144/150/151, 8–10 Sep 2026): futureTime
20 of 22, 19 of 26 and 19 of 26 successful reads, with
noTime and badTime 0 on all three — so the
sentence was wrong about roughly two rows in three, not about an edge
case, and it sent whoever read it to a truck that was working.
The row now reads “movement unknown · we could not date this
fix”, which is the vocabulary the same card’s own group heading
already used, so the card and the rows under it stop disagreeing an inch apart.
It names no cause, deliberately, because the cause is not on the wire: the
backend counts noTime, badTime and futureTime
apart and fixTime collapses all three into one bare null
before anything on the screen can tell them apart, so the note says why we
could not date it is not recorded on this row rather than picking one.
Carrying the reason through to the row is a real improvement and a larger,
separate change. Measured: reverting the row’s four words reddens 4
assertions in tests/run.js and 12 of 289 checks in
tests/manual/since-moved.js; reverting the note’s sentence
reddens 3 and 5; and drifting the row to a different honest
wording — one the card does not use — reddens 2 and 8,
which is the cross-file guard that did not exist before.tests/manual/since-moved.js captured one phrase off Live Fleet, the
map popup and the dashboard row and compared them string-for-string — but
only the stale one. The undated renderings were each matched against their
own regex, so three surfaces could satisfy three patterns and still say three
different things, and any one of them could be edited alone without the suite
noticing. The suite’s own comment,
js/screens/02-map.js’s and
js/screens/01-dashboard.js’s all said otherwise. The undated
phrase now gets the same treatment, with the three surfaces established non-empty
first so an equality over two nulls cannot pass; a fourth surface, the asset
page’s tile, is compared to the same string; and a new family of refused
claims — anything asserting what the unit sent — is asserted
absent on all four. Three groups in
tests/suites/94-absence-claims.js run fixStatus and
undatedNote extracted by name out of js/app.js,
so nothing greps for a sentence and no second copy of it exists; the last of them
reads the dashboard card’s heading out of the screen and the row’s
words out of the shell and asserts the two agree, which is a claim about two
files and so runs on every npm test rather than only in the sharded
browser job. Swept green at eight forced clocks — Mon–Sun of one week
plus the year boundary — through tests/manual/lib/date-sweep.js,
whose positive control confirmed the skew reached the page on every one, with the
check count fixed at 289 on all eight.The deploy's timezone-offset guard accepted the exact typo its own error
message warned about. fdTzOffsetMin is the number of minutes a
bare Fleet Dynamics wall clock reads east of UTC, typed by an operator on the
backend deploy form; the Lambda takes it through parseInt and
uses whatever falls out, so a wrong value does not fail, it re-dates every
fix in the fleet. The check shipped with it tested that the value was a whole
number and that it lay between -840 and 840 — and
every hours-typed-as-minutes value passes both. Measured by extracting the block
verbatim and running it under bash: 8, -8,
12, 48, -0 and 0480 were all
ACCEPTED and appended to --parameter-overrides, while the error
printed when the range check did fire read “8 means eight MINUTES, not
eight hours”. Typed on a deploy, 8 dated every fix eight minutes
out, silently.
No bounded-integer rule can close that hole, which is why the fix is a
third axis rather than a tighter bound: 48 is a whole number
and inside the range, so integer-and-bounded refuses nothing. The axis
that works is quarter hours — every UTC offset in use anywhere is a
multiple of 15, including Eucla's UTC+8:45 (525) and the :45 zones
in Nepal and the Chathams — and no value from 1 to 14 divides by 15, so
one rule covers all 28 hours-as-minutes typos with no special case. The guard is
now shape, then range, then quarter hour: shape by regex because
parseInt truncates 480.5 to 480 and reads
0x1E0 as 0 in silence; range narrowed to -720…840,
UTC−12 to UTC+14, the asymmetry being the truth rather than a typo. The order is
load-bearing and measured: with no shape check in front, [ 480.5 -lt -720 ]
writes “integer expression expected” to stderr and returns a status the
if reads as false — the range test silently passing the value
it could not read — and 0480 clears the range test with no complaint
at all before the modulo aborts it as invalid octal. Everything is refused rather
than repaired, so +480 and 480 cannot become two
spellings of one value in a deploy log somebody later reads as evidence.
The reason this was invisible is that its tests read the guard instead of
running it. A regex confirms the message says the right thing and
cannot notice that the bash does the wrong thing. So
backend/tests-template-fd-knobs.js now extracts the block from the
file CI dispatches, substitutes the one ${{ }} expression and
executes it under bash -e — 37 checks to 109, covering 8
accepted values and 19 refused, each axis proved to fire alone with the
other two asserted to have accepted the value, and the blank / explicit-zero /
non-zero deploy-log distinction measured off the running block rather than
matched in the file. Verify-to-fail, measured on that suite (109 assertions,
passes + reds summing to 109 under every break, so nothing is gated): reverting
the whole guard to the shipped one reddens 23 — and the revert is
confirmed behaviourally, by watching 8 be accepted again
rather than by a diff — dropping the shape axis alone 24, moving
it after the two bash integer tests 14, dropping the
quarter-hour axis alone 12, and widening the range back to
-840 3. 0 in tests/run.js under every one
of them, which stays at 31,091: no front-end file is involved, and that
silence is worth stating rather than leaving to be assumed.
The deploy form described the offset in the wrong direction. The input
an operator reads said “Minutes a bare FD wall clock is behind UTC”, and
the arithmetic subtracts: fed 480, the deployed parser turns a
13:00 bare wall clock into 05:00 UTC, so the wall clock reads 480 minutes
ahead of UTC, which is what AWST is. Corrected to name the unit and the
direction together, with an assertion that runs the shipped parser and refuses
the old wording, so the sentence and the arithmetic cannot drift apart again.
Nothing about the value changed: 480 was and remains correct for
this feed.
The instruction to run the backend test suites is no longer conditional on
having changed a backend file. Guards that live under backend/
read other parts of the repository as their subject matter, so a change holding
no backend file at all can still make one of them fail — and one of them
fails when a known defect is repaired and its entry is left in the list of
known defects, rather than when something is broken. The developer instructions
said to run those suites only after touching backend/, which sent an
author straight past the one check that would have caught it: the change looked
like a front-end one, both of the checks the instructions named came back clean,
and the failure was found by CI after the work was already proposed for merge.
The instruction now says to run them either way, states the reason, and names the
five trees involved — measured today as .github/,
docs/, js/, tests/ and
tools/, read by 12 of the 77 backend suites. That list is derived
from the guards' own source on every test run rather than typed out, so a guard
that starts reading a sixth tree makes the test fail on the change that adds it
instead of leaving the instruction quietly short. Measured: restoring the old
conditional turns 10 assertions red, and adding a guard that reads a sixth tree
turns 1 red and names both the guard and the tree. The same defect caught this
change on its own first run — the new test file tripped a backend guard
while both of the other checks were green, which is the reported fault happening
to its own fix.
costPerDay and costPerKm on an
asset type have two writers, and they disagreed about what a figure change does
to the record of where that figure came from. The register editor calls
NF.Costs.noteRateChange, which clears the recorded source and any
confirmation, because the provenance described the previous number, so it
goes with it. backend/set-asset-costs.js assigned the two
numbers and touched no provenance field at all — so one
--write run over a rate somebody had confirmed would put a WA
benchmark on the row and leave it saying that person had checked it, on the
fields the Fleet Overview’s running-cost estimate rests on. It was latent
only because nothing is confirmed yet; it would have fired at the moment
somebody confirmed one. (#1157)tests/lib/sharedlayer.js’s own
file list and the rule has exactly one implementation. It reports three
answers, never two: a row whose figure and provenance moved together, a
row left alone and named, and a row it could not tell
about — one carrying a rate source the app does not recognise, which is not
the same fact as carrying none, or one the register holds twice under a single
id, where the rule would land on one row and the figure on the other. It writes
nothing at all to either. A retired asset type is left alone and
named too: nothing costs one, and now that a figure change retracts the
provenance beside it, writing there would erase the record of where that type’s
last live rate came from. The
guard behind it drives both writers over a fabricated register row and compares
the provenance they leave; the old behaviour is run through the same comparator
to prove the comparison can register a disagreement.A test guard that says "this name appears nowhere" can now see the name when
it is written inside a string. Several of this app's guards work by reading its
own source and refusing a name — that a screen sets no client tag on a geofence,
that the cab does not re-derive a sync stamp from a timestamp. Two of them read
the source through a stripper that blanks comments and string literals,
which meant they could not see the very thing they forbid whenever it was written
as a string — and a tag key, a storage key, a label or a data-
attribute is written as a string more or less by definition. The guards did not go
red when they went blind; they went on passing. Measured: a client tag added to
the geofence editor as a plain string is visible to the correct reader and
completely invisible to the one that was being used, so the guard reported "still
no client tag" over a screen that set one on every fence. Both guards now read the
stripper that blanks comments only, which is all the reason they were written for
ever asked for. Neither was failing, so nothing that was wrong has been fixed —
what has been closed is that both would have quietly stopped noticing. A new check
drives both readers over made-up source and pins the difference, establishing first
that each reader can find a name it should find, so that a zero from it is a
measurement rather than a broken matcher; and every remaining use of the
string-blanking reader is now declared with its reason, so the next one added has
to say which reader it wants instead of getting whichever came to hand.
The tool every regression measurement is taken with could silently rewrite the change it was asked to make — whenever the replacement text contained a dollar sign. The evidence a fix is guarded is produced by deliberately breaking the guard and counting what goes red, so a tool that quietly writes something other than what it was handed produces a count for a change nobody made. That reads exactly like a result.
Three shapes were affected, and all three are ordinary things to type: a doubled dollar sign arrived as a single one, an ampersand form silently expanded into the text being replaced, and a quote form swallowed the rest of the line. The doubled one matters most here, because it spells this codebase's own shorthand for "find every matching element on the page" — so a break meant to restore such a line wrote the shorthand for "find the first one" instead. That is a change in behaviour rather than a failure, which is why nothing noticed: it turned a harmless edit into a crash for one agent, and the number that came out of it described a different change altogether.
And the safeguard built for exactly this could not see it. The tool already counts the changed text back out of the file to prove an edit landed where it was meant to — but that count still came back correct, because the damage sat in whichever part of the replacement carried the dollar sign rather than in the part being counted. So "it changed something" and "it changed the thing you meant" came apart in the one place built to hold them together.
The substitution no longer goes through the mechanism that gave those characters a second meaning. One form that was already safe is left alone and said to be safe, rather than escaped along with the rest — escaping it too would have been guessing at the cause instead of fixing it. A check now types all four forms through the real tool and reads the file back, so the next change to this code that reintroduces the problem fails on the commit that makes it.
The in-cab app no longer says the driver register is empty when it has not been able to look. With nobody signed on, the tablet's main panel printed "No drivers on the register. This tablet has no driver to sign on. Add drivers in the dispatcher app, then reopen this portal." — a statement about the database and an instruction, over a register that is empty for at least three different reasons. The instruction was wrong in two of them: reopening the app does nothing about a document that arrived and would not merge, and where nothing has been read yet there is nothing for anybody to add. The panel now asks the same reader the sign-on screen asks, and gives the answer it actually has — the register really is empty (and then, and only then, it says who has to add a driver); a document arrived and this tablet could not use it, in the database's own words, saying in the same breath that it is not a statement that the register is empty; or no copy of the database has reached this tablet yet, which fills itself in when one lands and needs no call to anybody. The two we-cannot-tell answers are drawn hatched and the two we-know answers dimmed and dashed, so the difference is visible on the glass and not only in the words. A fourth case turned up while this was being tested and is fixed with it: nobody is signed on until somebody signs on, so the old sentence also claimed an empty database over a full one — the panel now says how many drivers are on the register and that this glass simply has no shift open on it. The sign-on gate is unchanged in every one of the four: a driver signed on as nobody is worse than a driver who cannot sign on.
A guard that forbade a mail link from being composed could not see a mail
link, in any spelling, and never could have. Several of this app's checks work
by reading its own source and refusing a name. One of them asserts that the
quote-chasing code composes no mailto: — which matters, because the
rule it protects is that no blank-addressed mail is composed when no recipient
resolves. It read the source through a stripper that blanks string literals as
well as comments, and mailto: is a URI scheme rather than a name, so
there is no way to write it except inside a string: every possible spelling was
blanked before the check looked. This was not a guard that might one day go
blind — it could not have failed under any edit, ever. Measured: with a plain
mailto: composition inserted into the very code the check reads, the
whole suite still reported ALL PASS; through the correct reader the same insertion
turns it red. A second check, over the cab's sign-on screen, was moved for the same
reason before it could go the same way — there the two readers agree on today's
source, so nothing was wrong and what has been closed is that it would have
quietly stopped noticing. Both now state in place why they read what they read.
A new check drives a scheme-shaped name through both readers in the five ways
somebody would really write one, establishing first that the honest reader finds
it, so that the zero reported is a measurement rather than a broken matcher; and it
pins the difference between a name that cannot be seen at all and one that is
merely exposed, which is what decides how urgent each case is.
The schedule board's PU / LH / DEL status chips are now round traffic-light
pips. Simon asked for them bigger and rounder, and the three letter chips on
each run are replaced by three round pips — 22px across, against the 8px
type the chips were drawn at, with the · ▶ ✓ ! glyph scaled
up with them. The fill is now the saturated colour the legend has always used
rather than a pale wash, so on the delivery-run background a red run reads at
4.68:1 where the pale chip it replaces read at 1.07:1
— measured. Grey deliberately did not get louder: the resting state
is 1.06:1 against the same card, identical to two decimal places to
the chip it replaces, because most of the board is grey and darkening it would
make "nothing to see here" the loudest row. It is a swap and not an addition
— three pips need 70px of the 95px status row, where three letter chips
needed 71px, so nothing wrapped and no run got taller than the pip itself.
Which phase a status pip is about is now read off its slot and its name, and the letter codes live in the legend. Pickup is still always the left slot, line haul the middle and drop off the right, and a run that does not cover a phase still leaves that slot empty rather than closing the gap — which is what keeps the slots lined up down a column of relay legs. The legend now teaches that mapping instead of just listing the codes: each entry draws a real three-slot row with the pip in that phase's own slot, beside the code, the name and the word left, middle or right.
Every status pip now tells a screen reader its phase and its state in words. Each pip carries a real accessible name — "Line haul — In progress: rolling since 07:05" — and not only a hover tooltip, which is unreachable by keyboard and unreachable by touch. It was previously the visible letters that named the phase; with those gone the name is doing the work, so it is written on all four states including the grey resting one, and read back out of the browser's own accessibility tree with the tooltip removed, because the browser falls back to a tooltip for a name and a check that did not strip it would pass over a pip that had none. The blank column-holders stay hidden from it: three unnamed circles announced on every run would make the board unusable to exactly the person this is for.
docs/session-handoff.md § Reserved suite ordinals is where a
coordinating session reserves a tests/suites/NNN- ordinal before
its agent writes the file, and until now nothing in the repository read
it. Measured at ea92b304, with the positive control taken
first — agent-dispatch is named by three .js files
under tools/ and tests/, session-handoff
by exactly one, and that one a prose citation inside a suite header. The cost
was live at that commit: node tools/suite-number-check.js --next
answered 126, free on disk and claimed on no branch — both true — while
the register reserved 126 for claude/569-portal-read-residuals.
--next and --free now withhold a number the register
reserves and name which; --claims reconciles the whole table
against the directory and every unmerged branch tip.briefNumberConflicts() is the cheapest moment to catch two sessions
taking one ordinal, and it compares brief files. A slice briefed verbally
writes none — tools/preflight.js --actual exits with needs a
brief file — so on every such slice the guard is never reached. Not broken,
not disabled, simply never run. The race fired twice on 18 Sep 2026, on 119 and
on 121, with the tool answering correctly each time. The register is the claim
source those slices do have.claude/1146-run-line-quals and
claude/1146-run-line-quals-v2 carries it, so two passes that
started from the table's own claimed by column read 124 as free.
A register row whose branch is on no ref at all has either landed or been
abandoned and those are indistinguishable from in here, so that answers
could not look and never a zero.--claims exits 0 always and
tests/suites/130-suite-claim.js is the gate — the same split as
tools/claim-census.js and tools/backend-suite-scope.js.
A gate over the tempting state, a branch holding an ordinal master already has,
would be red on master today over 18 ordinals, almost all of them
superseded refs whose successors already took a different number. The refusal
that pays for itself is unchanged: the check still refuses a new duplicate in
tests/suites/ itself.119-quote-recipient.js named the eight branches that in fact carry
119-cab-signon-empty.js — a confident wrong answer naming a real
branch, on the line somebody acts on. The read now carries a per-file map
alongside the per-ordinal one.tests/lib/unread.js
and docs/modules/testing.md both said
tests/preflight-check.js holds the entire guard surface over
the dispatch report. It does not, and it did not when the sentence was written:
the commit that added a group loading tools/preflight inside the
main run landed eight and a quarter hours earlier the same day. The
original sentence is kept and marked false with the commit that falsified it,
rather than restated, because the figures under it were sized against it. The
error ran in the reassuring direction — an author who changes that
tool, sees a green run and concludes "nothing here could have caught it"
is wrong. What survives exactly is stated too: the guard file is still
never loaded by node tests/run.js, an assertion broken inside it
still reddens 0 there, and the remedy beside it is still right.grep -rn "require(.*tools/preflight" tests/suites/, then break the
tool and count the reds — and a new suite asserts only the
direction, that the cover is not zero. ⚠ It reads through the comment
stripper and not through code(), because a module path is a string
literal: the issue's own corroborating grep -c answered 4
where one is a require, two are comments explaining the mechanism
and one is a fixture path, so prose about a guard read as four times the guard
that exists. The suite also caught itself: its own fabricated fixtures made it
count itself as cover, which would have left the claim satisfied by its
own test data for ever.check-sam-template.sh,
check-shared-layer.sh and check-ssm-grants.sh were
invoked only by CI: 0 occurrences in package.json, no suite
spawning them, and npm run wiring asserting they are wired
while never running them — so all four gate commands could be green with
three guards unexercised. They cost 0.12 s each and need no network, no
AWS and no credential, so npm run checks runs them (27 s to 28 s).
⚠ The three names are not the fix: the fourth script lands in that
directory tomorrow and is missed the same way, so the new suite reads the
directory and requires every entry to be named by an npm script or
exempt by name with a reason, with a stale exemption reported. It keys on
the directory and not on a check-* filename shape.npm run checks appeared
0 times in .claude/agents/developer.md and 0 times in
CLAUDE.md, and none of the four gates was named by its npm name
anywhere in that brief — which is the mechanism by which the guards stayed
unexercised in practice, and the same shape as the shared-checkout finding,
where the imperative lived in the dispatcher's documents rather than in the one
the agent loads. Deliberately not guarded by a check: a check that greps
a document inspects an instruction rather than a mechanism, which this
repository has already shipped four times.The guards that refused a detention charge over two disagreeing free allowances now mint that disagreement themselves. Until the organisation's free allowance was raised from 30 to 60 minutes to match the hour every quote states, the published demurrage card and the figure a wait was actually measured against disagreed on the shipped configuration, so the tests that prove the charge is refused rather than priced against either figure got that disagreement for free. Now that the two agree, those tests build the disagreement explicitly — derived from the card's own promise, so it moves with the card, and checked at the point it is made, because a fixture that quietly stops diverging looks exactly like a guard that still works. Nothing was skipped, weakened or removed: the refusal, the sentence that names both figures and whose decision it is, and the report of which accounts the unreconciled figure reaches are all still proven. The mechanism is not historical — the organisation figure is a box on Transport Configuration, so one operator typing a different number into it puts 10 of the 12 accounts straight back onto an allowance nobody has reconciled.
The reconciled state is now asserted too, as a measurement rather than a silence. With the two records agreeing, no account is on an unreconciled allowance — and that zero is now pinned by name, on a named denominator, and pinned to stay distinguishable from the two other ways nothing can come back: a customer book with nobody on it, and a book that could not be read at all. A measured nought and a question nobody asked must not read the same.
On a build carrying the sample fleet, no consignment raises a demurrage claim any more, and that is the arithmetic rather than a fault. The longest wait in the sample data is 45 minutes, so against the raised 60-minute allowance the claim engine measures a chargeable wait on 0 of 50 consignments where it measured one on 2. An empty demurrage list on a demo build is now the correct answer and not a reader that failed. It is named here because a test fixture was quietly depending on that sample wait being long enough, and was taking six assertions out of the run rather than failing when it stopped being so.
The charge screen could tell you every account was settled over a book it had not been able to read. The note under the detention allowance — the one that says which customers are measured against a figure nobody has reconciled — was built from a count of how many accounts disagreed. A count cannot tell we looked and found none from there was nothing to look at, so when the read failed the count was nought and the note opened "Every one of the 12 accounts on the book is measured against an allowance that is settled", immediately followed by "12 could not be read at all". Whoever stopped at the first sentence — the one written to be read first — was told the opposite of the truth. It answers three ways now: the whole book read, part of it read, or none of it. Where only part could be read it states the number it did read against the number on the book, never a bare total; where none could be read it makes no claim about settlement at all and says so in those words, while still naming the accounts, because that is the next thing anybody would ask. Both of the answers that rest on a failed read are drawn hatched, the same "we do not know" treatment used everywhere else — and a genuinely settled book is deliberately not hatched, because that is something we know and drawing it as an unknown would be the opposite mistake. Raising the free allowance to an hour made this reachable from a much smaller failure than before: until then it took the whole customer book failing at once, and afterwards any part of it failing was enough.
The clock tolerance on Fleet Dynamics timestamps can now be set from a
deploy, instead of only by hand on the stack. A truck's reported time is
discarded if it is dated too far in the future, and FD_FIX_SKEW_MS is
how far — two minutes by default, because two clocks are never exactly
aligned. The Lambdas have read it since the frozen-fix work; nothing could set
it. Measured before this change: no mention of it anywhere in the deployment
template or the deploy workflow, against five mentions of the sibling timezone
knob in the same workflow. The only routes to a value were a hand deploy or a
console edit, and neither leaves a line in any deploy log, ever. That mattered
from the moment a deliberate 0 — no tolerance at all
— started working, because it is the one setting that behaves differently
from the default and it was the one nobody could reach. There is now an
fdSkewMs box on the backend deploy, and passing 0 gets
its own log sentence so a deliberate zero is never mistaken for an empty box.
Nothing an existing deployment does has changed: the default is empty,
empty is what the reader already treats as "nobody has set one", and that is
checked by feeding the template's own default to the shipped reader rather than
by reading the file. The box refuses a value it cannot use rather than passing it
on: anything that is not a plain whole number of milliseconds is used at
the other end rather than rejected there — 1e5 is read as one
millisecond, which would refuse very nearly every position in the fleet —
and anything from 900,000 ms (15 minutes) up is refused because at that
width the future-date check can no longer catch a timezone mistake of any size,
which is the actual reason a fleet's timestamps go missing. The refusal names the
timezone knob instead, that being the one that fixes it. ⚠ The backend deploy is
run by hand, so this changes nothing until somebody runs one.
ApiUrl output against the hand-maintained API_BASE repo
variable, and it did that with a raw !=: a trailing slash, a
difference of case, an http against an https and a
genuinely wrong host all printed the one sentence — "The published site is
pointing somewhere else" — which is true of exactly one of them. That warning
has been firing on every backend deploy since run #151 without being resolved,
and the reason it could be scrolled past is that it said the same thing whatever
it had found.
::notice:: that names the component and deliberately prints
neither URL, because there is nothing to act on and a public log should not be
widened for a note. A difference in host, stage or scheme keeps the
::warning::, since that is the one that can mean the published site
is talking to a different API. Two more states that used to be silence get a line
of their own: an unset API_BASE, where nothing was compared
is not agreement, and an ApiUrl output that could not be read.
API_BASE and re-run the site deploy" is right for a
stale variable and repoints the production site if a custom domain in
front of the API is deliberate — in which case API_BASE is the
correct value and the stack output is not the hostname browsers use. The issue
measured that backend/template.yaml declares no
DomainName, BasePathMapping, ApiMapping or
Certificate — re-measured here, still zero of all five — and
read that as nothing in the repository supporting the custom-domain explanation.
The custom domain is in this repository, one directory along, in
infra/api-domain.yaml: a separate stack that maps this API onto a
custom hostname, kept out of backend/template.yaml on purpose
because a SAM stack's parameters are sticky and a routine backend deploy taking
the defaults would delete the mapping. Its own ApiBase output says
to set API_BASE to that hostname — which is exactly the state this
step warns about. The warning cites it, states the zero as evidence rather than
proof, and names the one describe-stacks read that settles which
cause it is.
backend/tests-api-base-compare.js (84 assertions) lifts the
comparison out of the workflow and runs it under bash against
a table carrying all three answers together, because a table of warnings only
passes against a step that warns about everything. Measured: reverting to the raw
!= reddens 43, restoring the silent skip on an unset
API_BASE 8, dropping the custom-domain branch from the remedy
7, drawing a cosmetic difference as a warning 7, and moving
infra/api-domain.yaml out from under the citation 3 — against
0 anywhere else in the tree, root suite and the other 78 backend suites
included.51-* file”) was 240 of 240 on 19 September
and is 241 of 245 here, because 4bbe8e82 deleted the two camera
suites and added 51-depot-writer.js in the same commit — so step 2 as
written would delete three post-#1131 branches — and which three changes
daily, because what the prefix over-selects is by construction the newest end of
the namespace: at the commit this was measured at, one of them was the head of
the repository’s only open pull request. Its one named casualty,
NF.Pools.allocationAdvice, really does return 0 code matches tree-wide,
but the board advisory it belongs to landed under #592 as erQualAdvice();
what is genuinely lost is an eight-word vocabulary against master’s six and one
sentence #601 argued for. Nothing was deleted, cherry-picked or renumbered
(#1206).docs/session-handoff.md § In-flight dispatches is now one row
per live dispatch (issue, branch, files leased, ordinal, session), taken in a
pushed commit before the agent starts, because every reader here reads
published refs — which is exactly how two branches once both minted the
same ordinal, each asking before the other had pushed. The tie-break is
deliberately arbitrary, the way the ordinal table's is: whoever pushed the row
first keeps both the issue and the branch. Two sessions that cannot talk to
each other then need no negotiation.
node tools/dispatch-claim-check.js reconciles it against every ref
and reports three collisions by name — two rows on one branch (two
writers to one ref, which is the incident), one issue on two branches
(duplicated effort), and two rows leasing the same path.git push -u cannot tell updating a branch from
resurrecting one somebody deleted. A recreated branch after a squash is
the worst case for orientation: every sha on it is dangling while the name reads
as live. node tools/dispatch-claim-check.js --branch <name>
reads every path the branch's own commits ever touched and counts how many are
already byte-identical on the base. Measured on the real branch: 13
commits, none reachable from master, and 24 of the 25 paths they
touched already identical — ancestry says entirely unmerged, content
says landed. no-such-branch is the only answer that means
free to create.preflight.js --prior-art classifies
a branch by patch-id; the new check classifies by path content.
Measured with both run over both branches: for a branch squash-merged weeks ago
whose files master has since edited, patch-id answers SUPERSEDED and
content answers a weak 1 of 8; for the branch that squash-merged and then
merged master back in, patch-id answers COULD NOT CLASSIFY and content
answers 24 of 25. Patch-id fails when a branch has merged the base in;
content fails when the base has moved on. Neither applies a threshold — a
line-presence probe was built and thrown away four times because 30.5% / 94.4% /
83.9% on three known cases separates nothing — and each names the other in
its own output, so a single answer cannot be read as the whole one..claude/agents/developer.md, the file
loaded into the process that actually runs git commit, on #657's
finding that an imperative living only in the dispatcher's documents does not
reach the agent that needs it.127 passed, 1 FAILED while
pristine master ran the same suite at ALL PASS. ⚠ And the remedy the
failure named would have been a lie — on master | taken is a
false claim about master while the file exists only on a branch. So the state
cell now has three values rather than two: taken (on master),
built (a branch has it and it has not merged — not
checkable from a directory, so counted and named rather than folded into the
pass), and everything else (nothing minted, so the ordinal must not be on disk).
The vocabulary is closed and a typo falls to the stricter check. The
original defect is unchanged and still reddens on master and on a branch alike.
What no directory can see — a built row whose branch has since
merged — is caught by two other things between them: --claims
has the branch read and reports built-but-landed apart from
built-and-still-carried, and the dispatch register's cross-check reddens
once the merged branch's row is struck.A workflow that GitHub would refuse to compile is now caught before it merges. A recent branch made the backend deploy workflow uncompilable. Every local check was honestly green and the change looked ready — and had it merged, the only way to deploy the backend would have been gone: not a deploy that fails, a deploy that is no longer offered at all. The cause was a sentence warning people not to write a particular kind of placeholder, written inside a shell comment, which the shell ignores and GitHub does not. That failure is the quietest kind there is: the run it produces has no jobs, so there is nothing to read in the logs, and every tool that reads these files as ordinary configuration parses them perfectly. The check that already measures these files now also refuses an empty placeholder anywhere GitHub would read one, naming the exact place it sits. It reads the files the way GitHub does, so a note written in an ordinary comment is left alone — and it proves it can still spot a real one every time it runs, so a clean result means it looked.
docs/decisions/350-open-questions.md answering three questions in
its summary table while the deep sections went on asking them sent readers to
book meetings that had already happened. Eight sites carried it.
npm run checks now runs
node tools/oq-currency-check.js, which holds the property: no
OQ- id a register's dated summary table records an answer for may
be mentioned below that table without evidence the mention was written after
the decision — a date on or after the decision's own, or a pointer to the
table's section. A marker's presence is deliberately not enough, because
that is the case that got through: the site a ±6-line sweep cleared
carried a 5 September marker against an 18 September decision. It
is a date comparison, and it refuses to run at all if it cannot parse a
decision date, rather than degrading silently to the test that missed it.
Measured over the pre-repair document: 7 stale sites across all 4 recorded
ids, of which 3 carried a marker older than the decision. Over the
repaired document on master: 30 mentions, 30 current, 0 stale. It does
not classify meaning — a word list wide enough for the eight real
sites fires on the retired sentences this repository keeps quoted inside their
own corrections, and the cheapest answer to a guard like that is to delete the
quotation, so it would destroy the record it exists to protect. What it cannot
see is printed on every run, passing or failing.docs/ records
a decision in a form a machine can check. Across 136 documents, exactly
one summary table names a decider and a date in its answer column, and
it records 4 ids — three answered and one answered by deferral.
Simon's other eleven decisions of 18 September are recorded in prose, in
bullets and in issue threads, so this shape of staleness cannot be detected in
them at all. That is reported as a count with the ids named, and never as a
rate; the run says how many tables it found, so a corpus that shrinks cannot
read as a corpus that is clean.A customer's browser no longer receives every field of every con note. The client portal is opened from an emailed tracking link and keeps a copy of what it is sent, with no expiry, on whatever device opened it. Until now that copy carried the whole con note: the contact names and phone numbers at both ends of every customer's freight, the GPS fix under every arrival and departure stamp, and — the moment a wait was decided — the dispatcher's own notes on why a demurrage charge was waived or queried, with the names of who decided. The portal now receives only the fields it actually shows. The arrival and departure times still arrive, so the demurrage notice a customer is owed still renders exactly as before (checked end to end, byte for byte); what is under those stamps, and what the office wrote about a claim, does not. A visitor whose browser already held a whole copy has it trimmed on the next read. The portal also stops sending con notes back to the database except the bookings it makes itself, so a trimmed record can never overwrite the full one. One consequence is recorded rather than hidden: the portal's own "this notice was shown" mark no longer reaches the office, so the office reads "untold", which is the safe direction. The recipient's printed name and the tracking link's own token are deliberately still sent, for reasons written beside the table; whether the recipient's name may be shown at all is a decision for Simon. This binds only after the next backend deploy.
When a customer opens their shipment page and sees a demurrage notice, the office record now knows it. The demurrage notice is what tells a client, on their own tracking page, that a load waited beyond the free allowance in their terms and is being charged for it. FleetView keeps three different facts apart here, on purpose: nobody has told them, it was on their screen, and a named person told them, by a named means, at a recorded time. The middle one is worth strictly more than the first and strictly less than the last, and it is the one that stopped being recorded when the client portal was narrowed in September so that a customer's browser no longer receives — or sends back — fields it has no business holding. The portal went on marking its own copy of the page, and the office copy went on reading "not told", so the demurrage report could never show that anybody had seen a notice. Under-stating it that way was the safe direction and it was still wrong. The showing is now recorded straight onto the office record, using the private link the customer already followed from their email as the proof of who they are, so the portal still never sends back a con note it merely received. Three things it deliberately will not do: it cannot create a demurrage claim that nobody in the office has raised and attributed; it never overwrites a record of somebody actually ringing the client, because that is the stronger record; and if it cannot reach the database it says so rather than reporting the notice as unseen — a question nobody managed to ask is not an answer about a customer. A second visit to the same page changes nothing and writes nothing.
The Dispatch Board no longer says it checked a driver's compliance before it has read the register. Every card on the board carries a chip saying whether anything will refuse that driver at departure. Until now those chips were drawn the same way whether or not the database had actually been read, so on a device that had not yet synced — or where the read had failed — three cards told a dispatcher "we looked, and none of the rules that gate a departure applies" over a register nobody had opened. That is the wording reserved for something we know, printed for something we could not look at. Those chips now say plainly that the register has not been read on this device and that nothing on them is a statement in either direction, and they are drawn in the hatched style the board already uses for what it does not know. A chip that refuses a driver is deliberately left alone, because refusing when unsure is the safe direction — it now adds that the file has not been read, so "no record on file" is not mistaken for "nothing is on it".
The board's drag test now checks which card it actually dragged. The browser check that proves a dispatcher can move a job between columns asserted only what happened afterwards — the job's status, the roster line, the toast — and never that the card the mouse picked up was the one it aimed at. Those are not the same thing: the board's columns are several thousand pixels tall, the page scrolls while a card is being dragged, and the pointer can end up over a different card between pressing and releasing. The check now reads which card the browser itself began dragging, and it settles the drop point while the mouse is down instead of trusting one measured beforehand. Nothing about the board itself changed.
Eleven test suites re-run themselves in several timezones to prove a date is derived the same way everywhere, and the number they printed afterwards was not the number of timezones they had covered. Each decided which zones still needed running by comparing names against a setting that is usually blank, so on an unconfigured machine one zone was run twice and the total said four where the truth was three. The count is now measured rather than assumed: a zone already running is recognised however it is spelled, a zone name the machine does not know is run anyway rather than quietly counted as covered, and the line printed at the end states how many distinct zones were covered and how many processes it took, so the two can never again be read as one number.
Three backend test suites defended themselves against a day boundary using
the wrong clock, and one of them was red in a whole band of timezones.
Each pins the clock to midday so a fixture cannot straddle a day boundary, and
each pinned it to midday where the machine happens to be — while
every row they write is keyed by the Perth day. The distance from the boundary
was therefore a property of the machine rather than a constant, and it falls to
zero wherever local noon lands on 16:00 UTC. Measured: the speed-event suite
failed four assertions under America/New_York on 19 September and
was green under UTC, Perth and Chatham in the same sweep. Continuous integration
pins no timezone and the container is UTC, four hours clear of the boundary, so
nothing could see it. All three now pin to midday Perth, derived from the same
offset the day key itself uses, and each asserts it is at least six hours into
its own day — six rather than ninety minutes so that restoring the old pin
goes red in the timezone the build really runs in.
The demo-data purge decided which records the sample fleet mints by comparing two loads in two timezones, and it named one of those timezones outright — so on a machine already in it, that half of the comparison quietly did nothing. The field it exists to catch is the one every job thread carries, so on such a machine 65 threads were listed as seeded‑but‑edited rather than as sample data: the exact mistake the second comparison was added to prevent, on the report somebody reads before deciding what to remove from a database that cannot be restored. The comparison now picks a timezone this machine is not already in, says in the report which one it picked, and refuses outright rather than falling back if no candidate differs — a comparison that measured nothing must never read as one that measured and found nothing.
A check could be added to the list a developer runs before committing without ever being added to the build, and nothing would say so. The existing guard ran one way only: it required every check the build runs to be runnable locally, which cannot notice a local check the build does not run. One had already slipped through that way and sat unnoticed. The reverse rule is not the answer either, and measuring it is what shows why — the build runs ninety-two scripts the local list deliberately leaves out, including every browser check. What is asserted now is the narrower thing that matters: every entry in the local list has a step in the build, so running it locally is a gate rather than a habit. The one entry that does not is recorded by name with the reason and the three edits it needs, and that record is itself checked, so the day it gains a step the test asks for the record to be deleted rather than letting it become permanent.
Two of the repository's own prose checks were reading other people's
checkouts and reporting what they found as this repository's problem. Both
sweep the tree looking for a sentence that has since been corrected, and
neither knew to stop at the edge of a second checkout sitting inside it — the
working copies the agent harness leaves under .claude/worktrees/.
In the shared checkout on the build machine, one of them reported 57 places
still carrying the old wording where the repository has 5; three assertions
were failing there and nowhere else.
The reason it mattered more than the noise suggests is which way it was wrong. Every single phantom was reported as still-broken, and it could not have gone the other way: a stale checkout is by definition from before the fix. So the check reported work that had been done as work still outstanding, and it did it on the machine where somebody would be reading the result. A red that nobody believes is a red that nobody reads.
A sweep now stops at any directory that is itself a checkout, which is detected by what every checkout carries rather than by the folder name the harness happens to use today. The two checks report identical numbers in every tree. A related finding is recorded rather than quietly fixed: deliberately breaking the second check first proved nothing, because it could only ever fail on a machine that already had the problem — so its own scan was restructured to build that situation instead of waiting for it.
The rule that says customer email leaves the production build and no other
is now asked at the browser's send seams, rather than only printed on a
banner. NF.Env.maySendCustomerMail() (FR-AO-05) shipped with
exactly one consumer per app — lines.push(maySendCustomerMail()
.message), the environment banner's own sentence — which made it a
display function rather than a gate, and left the requirement stated
everywhere and enforced nowhere in the browser. That is the shape this
repository has recorded four times: a complete, correct mechanism nothing calls.
NF.Intake.envSendGate() is the one reader for it in the shared
layer, and both seams now come through it — sendPath(), which gates
the unattended send, and NF.Mailbox.capability(), the enquiry
desk's Send button, which now says so before a person presses it instead
of letting POST /mail/send refuse after the attempt. The allow-list
stays in NF.Env: production and nothing else, so a declared
test, a typo such as prodution and nobody having said
anything all land on the same refusal, printed in NF.Env's own
wording rather than a second one.
It withholds the send and never the draft, which is the shape
backend/src/mailquote.js chose deliberately — refusing to draft
as well would lose the work and tell nobody anything extra. On a
non-production build in autosend the enquiry is still parsed,
priced, drafted and auto-approved; only the send is withheld, and the record
says reason:'environment', which is the server's own word for the
same refusal and is kept apart from no_send_path — "there is no
route from here" is a build waiting on a deployment, while "this build may not
write to customers" is a deployment nobody labelled, and folding the second into
the first sends whoever reads the record off to deploy a mailbox that would
change nothing. A realm with no NF.Env at all — the Lambda, which
brings the shared layer up without an app shell — is a third answer,
asked:false, and it passes, because reading that absence as
a refusal would stop an unattended send the production stack is entitled to
make; that realm holds its own gate, and the claim that no browser can reach the
pass-through is asserted against the three HTML files rather than trusted.
And the warning that sent this work is corrected in place rather than
swapped out. All three app shells carried a ⚠ reading "NOT YET CALLED AT THE
SEND SEAM … and neither is writable on this branch". Its backend half had been
false since #242 — backend/src/mailquote.js has asked the server
twin once per run since then — and its branch clause had outlived the branch
that wrote it, so it read as a standing fact about the repository and was the
clause most likely to stop somebody picking the work up. The original wording is
kept verbatim as a quotation under a retraction that names what falsified it and
says which half survived, and the lease is dropped by name; the same lease is
corrected in the suite's own header. A lease is not a property of a repository
and does not belong in a comment that ships.
One thing broke on the way in, and it is recorded because the shape
recurs. The refusal a screen prints carries two facts — why we may not
send and what that means for the draft — and the second of them,
"nothing is marked as sent", lived only inside the missing-route
refusal's prose. Giving sendPath() a second reason therefore took
that sentence off the Transport Configuration panel on every non-production
build, which is every build there is. It is this module's own demurrage refusal
— v1 stamped demurrageNotified the instant a driver tapped depart,
the KPI counted them, and nothing was ever sent anywhere — so losing it is the
defect that panel exists to prevent. It is NF.Intake.NOT_MARKED_SENT
now, appended to both refusals rather than copied into the new one.
node tests/run.js could not see it at all (no DOM, no
NF.Env): dropping the clause reddens 0 there against 2
in the browser suite. And the browser suite found it only by accident, because it
pinned a constant rather than the model's live answer — which made it go
red over a screen that was right. It reads sendPath().why now, so it
cannot go stale the next time a reason is added.
A sentence in a decision brief that was wrong when it was written, rather than
overtaken by later work, is retracted and marked as such. The backload-readiness
brief explained why the driver pool holds five people by saying the driver list was
"not in snapshot() — so it is a code seed, not a register". It was in
snapshot(), and had been for nineteen days before the brief was written:
the key was added by the same change that gave the master records an add/edit/archive
screen, which is an ancestor of the commit the brief says it measured against. It is
also in the other two places a collection needs to persist. So no version of the
repository ever made that sentence true, and it is retracted on those terms instead of
being described as having gone stale — the original wording is kept as a quotation
under the retraction, with the commands that settle it and a control showing the same
check reports the key absent before that change.
The figure the sentence sat beside is unchanged, and correcting the reason makes the brief's case stronger rather than weaker: a driver list that simply has not been wired up yet would be fixed by wiring, whereas a register that really is saved to the database, holding five people against a workforce of about forty, is fixed only by somebody entering the data. The brief's own summary table already said it that way, three sections further down, which is how the error survived. The brief's answer — that a backload optimiser cannot be built on the data held today, on eight stated preconditions — does not rest on the retracted sentence and is untouched. A second brief flagged for the same class of error was checked and already handled it correctly, so it was left alone.
NF.Pools.allocationsOn() derives what is on the road from
those two fields and nothing else. Measured by walking every distinct day key
the generated schedule carries, at eight clock settings: the trailer pool peaked
at 1 allocated unit and the dolly pool at 0 at every one of them,
against a driver and prime-mover peak of 9 to 19 through the same walk. So the
pools rail, the utilisation panel and the cross-pool double-booking guard had
nothing true to show for half of the engine room's own stated premise. Each run
line already declared the trailer it pulls, and a lane whose trailer is typed
Roadtrain set (2x) is running a double — so the rig now rides the job the
seed mints for that line, and the dolly that couples the double follows from the
trailer's own type rather than from a hand-picked lane number, which is the shape
that silently rotted for the board's two deliberate clashes. After: trailers
6 to 20 and dollies 1 to 2 on the busiest generated day, stable
across twelve clock settings including Saturdays and Sundays. A lane's rig is
claimed once per day, so the conflict profile is byte-identical to before —
zero trailer and zero dolly double-bookings added — and a
dispatcher can still be shown the guard firing by deliberately double-booking
one. NF.Pools, allocationsOn() and
conflicts() are unchanged; the defect was in the fixture and so is
the fix.The asset register's data quality can be re-derived with one command, and a
read that was refused is no longer reported as a register holding nothing.
Three data-quality findings about the register — the untyped tail, sixty-three
Port Hedland fleet numbers mangled into a composite like NF1020/LV614,
and one fleet number sitting on two rows — were measured on the live document
on 3 September 2026 and written into a decision brief. The script that produced
them existed only as pasted text inside that brief: measured, nfMangled
returned no match in any JavaScript file in the repository, so re-deriving the
figures meant copying a script out of a markdown file. Four currency passes over
the parent issue re-confirmed the part that was blocked and never re-took the part
that was not. tools/register-quality.js is that measurement as a
runnable instrument, classifying the register through the shared layer's own
normalisation and status-word readers rather than a second copy of them.
Its whole contract is that it refuses to answer what it did not measure: an
unauthenticated read of the live state answers HTTP 403, and a 403
recorded as “no mangled fleet numbers” is a confident wrong answer on the
one screen that decides whether anybody has to do anything. So a blocked read
reports every count as a dash and names which of four blockages it met, while a
register genuinely holding none still reports a real zero — the two are never
folded together.
A bulk correction to the asset register now refuses to start until the current values have been exported, because the change log cannot recover them. An asset row carries no record id on either path that writes one, so the whole register is recorded in the change log as a single value rather than one entry per asset — and a register of this size is far past the per-field size limit, so both the before and the after come back as a marker saying the value was omitted. Measured by driving the real change-log code: a one-row, one-field edit to a 1,190-row register leaves the log holding that the register changed, by whom and when, and neither the old nor the new value of anything; the same edit on rows that do carry an id names the row and both values. A batch is therefore refused outright until an export has been taken, and there is deliberately no way to correct a single row through this path at all — a partial pass across a naming convention is worse than none and silent, because the count a screen reports is re-evaluated as it goes, so the first row fixed can make the screen read clean while the rest are still wrong.
The Port Hedland fleet-number correction is held, and says which decision it is
waiting on. A row whose plate reads NF1020/LV614 holds two real
identifiers. The asset record has seven fields and none of them is a second fleet
number, so rewriting the plate to NF1020 would destroy the light-vehicle
number with nowhere on the record to put it — the type field already carries
LV, which is the class rather than the unit number. There is also a second
fix for the same rows that needs no register edit at all, in the matcher rather than the
data. The two are alternatives, and choosing between them changes what the matcher will
join rather than only what it reports, so the tool names both and rewrites nothing.
A reason given in the app's own source for how a driver's qualifications are
stored was never true, and is retracted where it was written. Two comments in
js/data.js — one of them an instruction telling future developers not
to change something — explained the separate, editable map of driver
qualifications by saying the driver list itself "is not in snapshot()", so
anything recorded against a driver's own record "would not survive a sync". The driver
list is saved, and has been since the change that added the screen for adding, editing
and archiving master records — eight days before the comment saying otherwise was
written. It is in all three of the places a collection needs to be in to persist. So no
version of the repository ever made that sentence true, and it is retracted on those
terms rather than described as having gone out of date. The same false reason is
retracted in the crew module's notes, which carried it as the recorded justification for
the original decision.
Nothing about how qualifications are stored or read has changed, and that is the point. The decision the false reason was defending is correct for the other reason given alongside it: a set of qualifications arriving from an import or a future integration has to take precedence over what somebody ticks by hand, so the driver record's own field stays reserved for that and the editor still does not write to it. The instruction not to repoint the editor is kept, and deliberately kept forceful, since a retraction that softens into a doubt invites exactly the change it exists to prevent. The code is unchanged — byte for byte once comments are removed — and the precedence itself is already covered by existing tests, which is why the conclusion was never at risk while the reason for it was wrong. One further copy of the same false sentence, in a file this change did not have the lease on, is named in the notes rather than left to be rediscovered.
NF.Pools.jobAssets() reads trailers /
trailer off a job and nothing else, and the demo seed wrote
the relay's trailer onto the con note's legs and onto no job at all — a
shape the app's only runtime writer of leg.trailer,
NF.Components.handOver, never produces, its own comment saying why:
the rig goes onto the new job at birth, because NF.Pools decides an asset is
busy from the jobs on that day. So two shipped readers answered one question
two ways in the same realm on the same day: NF.Trailers.contentsOf()
reported a live component riding on the unit with a named holder, while the
board's spare-trailer list — the list whose whole job is to say which spare to
move onto a double-booked run — offered that same unit. Measured over eight
consecutive clock settings, 9 components declared a trailer and 0
of the jobs behind them carried one, and the disagreement surfaced on 3 of
the eight. Every one of the four ids the relay chains used was also some run
line's own default — 4 of 4 — which is what had been hiding the
invisibility: copying the leg's trailer onto its job on its own would have put
3 to 5 genuine trailer double-bookings on the conflicts panel every demo
runs against, so the chains are given four trailers of their own at the same
time, and none of the four spares the board can offer is consumed. After:
8 of 9 components allocated (the ninth is the one nobody has been put on,
which correctly pulls nothing), intersection with run-line defaults 0,
board trailer conflicts 0 and spare-while-carrying 0, at every
clock setting swept. NF.Pools, allocationsOn() and
NF.Trailers.trailerFor() are unchanged; teaching the allocation
reader about legs would have been a second rule about where an allocation lives,
free to disagree with the first, which is the defect rather than a fix for it.tests/suites/103-levy-basis.js re-spawns itself as three
node tests/run.js children, one per timezone — the documented
fix for a real defect, and it must keep re-spawning. Each peaks around 1.5 GB,
so with eight or more runs live on a 16 GB machine the OOM killer takes one and
spawnSync reports { status: null, signal: 'SIGKILL' }.
The parent read status alone, so a killed child and a child that ran
and disagreed were indistinguishable, and the killed one was scored as failures.
Measured twice independently on 20 Sep 2026 at 191af22f:
33360 passed, 7 FAILED under concurrency against
ALL PASS — 33367 run alone — seven reds on a tree nobody
had touched, and the usual “run it against the commit before yours”
recovery gives red both ways and settles nothing.
tests/run.js now has a third counter beside ok
and eq, printing ~ rather than ✓ or
✗, and a run holding one reads
391 passed, 0 FAILED, 17 COULD NOT LOOK rather than
ALL PASS, with a block under the verdict showing
391 + 0 + 17 = 408 so it reconciles against a clean run, and naming
the child, the signal, memory pressure from concurrent runs as the likely
cause and re-run serially as the remedy — the whole cost of this
defect being the minutes between seeing red and thinking to do that. The count
does not move either way, so a killed child consumes no assertions; the run
still exits non-zero, because an unmeasured thing is not a measured zero.
tests/lib/childstate.js, the one reader). A non-zero exit code with
no signal is a child that ran and disagreed and stays red for ever: the tempting
one-liner — treat any non-zero child exit as could-not-look — would
turn every genuine child failure into a shrug, which is worse than the flake.
The guard drives real spawned processes rather than hand-built objects, including
a child that genuinely exhausts a 24 MB heap (which aborts, arriving as
SIGABRT — a different signal on the same cause, which is why
the rule is a signal, any signal), and then drives the real runner on the
real suite under two probes: killed children give 0 FAILED with the
named third state, children that ran and disagreed still give
16 FAILED with no could-not-look anywhere. Reproduced from outside with kill -9 on the running
UTC child: master gives 401 passed, 7 FAILED — the
issue's seven — and this branch 399 passed, 0 FAILED, 9 COULD NOT
LOOK; the two extra are assertions master passed about the dead child
(three distinct zone lines, one of them empty; one zone still behind Greenwich).
Full suite ALL PASS — 33450 on master and 33481
here, the +31 all in the new guard. Each break re-run three times and reproduced,
every red in 88-runner-early-exit.js: reading status
alone again reddens 5, the tempting fix 7, a classifier that ignores
the signal 13, a withheld assertion that stops being counted 8,
ALL PASS printed over a could-not-look 4, and the third state
dropped from the exit expression 2.docs/session-handoff.md that every branch appended a row to at the same point; measured on 23 Sep 2026 with git merge-tree, seven of seven open branches conflicted with master on that file and on no other, none of them contesting the same work, so landing n branches cost on the order of n²/2 hand resolutions. A dispatch is now docs/dispatch/<branch-slug>.md — the changelog.d/ pattern, copied — carrying the same five fields in a ----fenced block, read by the same cell readers the table used; striking one is deleting its file. The rows the table held were migrated rather than dropped, including three that existed only on their own branches; a row still written into the old table is read as a legacy registration (so a branch open when this landed loses nothing whichever way it merges) and reddens the gate with the remedy. tests/suites/138-dispatch-claim.js keeps every assertion it made, reads the new shape, lists the directory itself so a reader that reads nothing cannot pass, and shows an empty directory failing the cross-check a populated one passes.NF.loadKeyOf (#280), so two components of one relay load sharing a trailer are one
load, while the drag guard, NF.Pools.conflicts(), had no such pass and warned about
the relay's own other half. Measured before the fix with the demo seed untouched: 4 to 10
such warnings across the seeded board at four clock settings, every one between two components
of one load, against 0 on the panel — and one hand-over added 2 more, the panel
still at 0. The guard now asks NF.sameLoad, the pairwise form of the same rule,
for the trailer and dolly pools only, exactly where the panel folds. A trailer on two
different loads on one day is still a critical clash naming the other run, and the
prime mover and driver are still checked across components as before. A test drives both
readers in one realm and asserts they give the same answer on a relay and on a real
double-booking, so they cannot drift apart again.CLAUDE.md and checks the ones it can against the tree. It
chose a claim's subject as the nearest usable backticked name within 260
characters, and its scope as every path within 300 characters either side. It
did not look at where a clause ended, and it did not check that the name it
picked was the one the figure was about. So a draft correction made it print a
confident holds for failed, which was one item in a list
of six answers in the previous sentence, searched over two paths taken from two
different clauses.
Each resolved row on CLAUDE.md at 182e0076 was re-read
against its own sentence. Nine rows resolved. Three were
wrong through these two faults:
workflow_dispatch was checked as the subject of a zero that
belonged to the grep pattern permitFor|permitsFor|…. The window
had skipped the pattern because it is not an identifier.dayRate was checked as the subject of "so that grep would
return zero".arrivalSlot's printed scope included
docs/requirements from the clause before a semicolon. The verdict
did not change, because that folder holds no .js files.log.contract is a sentence about commit d1fc49f4, and
it was checked against today's tree. Three rows were partial checks: each
checked one member of a list the figure was about. Two rows were right. All
four wrong rows were confident passes, and none of the nine was ever red.
A subject is now the backticked token attached to the figure, inside the
figure's own clause. A sentence stop, a semicolon or a table pipe ends a
clause; a dash does not, because this house writes a claim's own scope in a
dash aside. The search stops at the nearest token. If that token is not code,
the claim is refused; the census no longer skips it to reach a name further
away. A list attached to the figure is checked in full. A bare "A
and B" pair is refused, because it may be the object of another
verb. A new noun phrase after "and" or "so" takes the subject away. Scope and
the "outside X" exemption are read inside the same clause, and
never further than the old window reached. A refusal prints could not
resolve a subject with the reason, and every verdict still prints the
subject and scope it used. Four of the nine rows still resolve. The other five
now report that they could not look: three of them were wrong, one was partial
and one had been right. The suite's two order-of-magnitude floors therefore
move from five to three.
Verify-to-fail: putting the old extractor back reddens 30, all
of them in the census suite and none anywhere else in the tree. Breaking the
thirteen rules one at a time reddens between 1 and 10 each. Five of those
rules first reddened 0, because no fixture exercised them. Each
now has one. The --docs report shows the same pattern: of 13
resolved rows there, 8 were wrong, and 6 of the 8 were wrong through these two
faults. No application code changed. (#1270)The test guard against suites that abort on an empty list now sees the
three shapes it missed. A suite that asserts a list's length and then reads
its first element throws when a break empties the list, and a throw stops the
run at that file — so the break is scored as one red instead of every assertion
it really turned. This happened while #283 was being verified: one break scored
1 red where it was worth 32, and the guard built for this
(backend/tests-abort-shapes.js) was green throughout. It missed all
three sites in that suite, each for a different reason, which was shown by running
it on the suite as it was before the fix: a length assertion that was itself written
defensively, eq((c.deckPos || []).length, 1), hid the unguarded index
under it; a map lookup by a computed key, read inside the assertion, matched no
arm; and (xs || [])[0].seq guards the list but not the element, so it
throws on exactly the path its own guard exists for. The guard now catches all
three. Those three sites, copied verbatim, are fixtures it must flag, and the fixed
versions are fixtures it must not. Run on today's tree, it finds 14 more
live sites (11 of the half-guarded kind, 2 keyed-length, and 1 behind a defensive
assertion). They are listed as a known budget rather than fixed here. The list
fails the build if it grows and again if an entry goes stale, so nobody can add
a new one and a fixed one has to be struck off. A read taken straight off a
function's return, such as placementOf(…).bay, is still not covered,
and the guard says so.
The in-cab browser check that the sync pill heals once the office answers no longer fails at random — and the cab was never at fault.
The check paints "Synced", "Syncing…" and "Not checked" on the pill itself, then lets the held read through and waits for the store to say "synced" again.
The wait looked for any "synced" in the list of events seen so far, and the check had put one there itself. So the wait was already satisfied before the read was answered.
It returned in 7 ms, and whether the check passed depended on whether the tablet had finished merging the reply by the next line.
Reproduced on purpose by holding the reply 1.5 s: the old check failed with the exact reported words (syncState reading, emitted ["synced","pending","reading"], all three of them the check's own events), and 4 s later the same page answered synced with a real "synced" from the store.
The check now waits only for a "synced" the store sends after the reply.
A new check, taken while the read is still genuinely held, proves that wait cannot be satisfied early.
Measured: going back to the old wait reddens that check on every run, not only on busy machines.
A reply that never comes reddens the healing check with "waited 20s and the store never said synced".
A store that says "synced" but still answers reading reddens it too.
No application code changed.
01-dates-zones-scheduling, 29-fuel-levy,
42-pipeline, 54-compliance-escalation and
118-bare-time-of-day each re-spawn node tests/run.js
under three timezones and read the child's exit code alone — the defect
103-levy-basis was fixed for. They now read through the same one
classifier, tests/lib/childstate.js, and the same third counter,
couldNotLook(): a killed, timed-out or unstartable child is
we could not look, and a child that ran and exited non-zero is still red.
Each suite makes the same number of assertions either way.
kill -9 on one running child of each, before and after:
01 went from 385 passed, 15 FAILED to
381 passed, 0 FAILED, 19 COULD NOT LOOK; 29 from 808/8 to
806/0/10; 42 from 572/6 to 566/0/12; 54 from 215/5 to 215/0/5; 118 from 119/5 to
113/0/11. Thirty-nine false reds naming five suites are gone.
null offset) is distinct from two real
ones. In 42 the same arithmetic passed both controls that say the zones really
differ. In 01 both disagreement
controls passed on a null, because New York is the only zone of the
three with a transition. In 118 all three writer checks compared
undefined with undefined. And in 01 the suite's own
total moved. A note was printed once for each child whose two digests
agreed, and two nulls agree, so it passed the false sentence
“America/New_York has no transition all year”. That note is now one
real assertion per child: the two forms agree exactly where the zone has no
transition. So a clean run of 01 is 400, not 399. Every comparison across
children also now requires each child to have printed what is being compared.
That closes the hole 103-levy-basis was left with, where three
children that all failed passed “BYTE-IDENTICAL IN ALL THREE ZONES” on
three null digests. 54 also gains the 180 s spawn timeout the
other sweeps already carry.
88-runner-early-exit now drives each of the five end to end through
two probes at the top of each suite. The probes do nothing unless that sweep's
own spawn sets its variable. It runs each suite once with every child killed,
which must give 0 FAILED, a named could-not-look and a non-zero exit. It runs
it once with every child running and disagreeing, which must still redden, with
no could-not-look anywhere. The two runs' totals must reconcile, and under the
disagreeing run every sweep assertion must be red. Taken per suite, against
runner-early-exit plus that suite, restoring the status-only read
reddens 4 for each of the five. The tempting fix, where any non-zero exit
counts as could-not-look, also reddens 4 for each. Dropping the
every-child-printed-it check from one comparison reddens 1 for each of the
five, and 1 for 103.
Every break reconciles with its baseline. The guard costs about 25 s. The
backend re-spawners were not checked.tests/suites/66-asset-docs.js read a truck's list of
documents straight out of the store, and that list is exactly what goes
missing when an archive is broken so that it deletes rather than retires. On
that break the read threw, the file stopped after 97 of 402 checks and
the run showed 1 failure. Both reads are guarded now, and the list being
there is a check of its own, so a missing list is reported as a failure, not a
crash. The count taken before the archive is pinned to the one document just
added, so a missing list cannot make both sides read zero and pass. Measured:
making the archive delete the truck's list now fails 8 checks, and the
file runs to the end (397 passed, 8 failed, 405 in total). Making the cab's
document read delete the list fails 5 with the file running to the end,
where before it stopped after 208 checks. That second case needed a third read
on the next line guarded as well: guarding only the one read the abort-shape
check listed still stopped the file, after 210. The abort-shape check
(backend/tests-abort-shapes.js) listed both sites as known
problems. Both entries are removed, because that check fails when a listed
problem has been fixed.6dbb69a8.
NF.settings.demurrageFreeMin ships 60 — Simon Anderson picked
the figure on 18 Sep 2026 so the standard honours the unconditional
“one hour free loading and unloading” every quote already states.
Verified in an isolated worktree by mechanism rather than by line number, and
measured through the shipped resolver on the shipped book at
demoData:true: 10 of 12 accounts read
freeSource:'default' at 60, Coates reads customer at 60,
and 1 of 12 — Water Corporation's negotiated 45 — is still short of the
promise. The stale decision status was the half that cost a dispatch:
docs/modules/charges.md routed the choice to
“Simon's with the GM” and
docs/decisions/256-discovery-sessions.md's running order still put it
on Tanya's invoicing agenda, five days after it was answered. Eight sites across
four documents now carry a keep-and-mark correction: the original wording verbatim,
the falsifying commit named, and the surviving residual stated as what it actually
is — Water Corp's 45 now sitting beneath the standard, and the promise being
a hard-coded literal in js/integrations.js that no card and no setting
can edit, so it is a wording decision and not a number anybody can configure.
Two of the eight were beyond the issue's list:
docs/decisions/331-charge-record-shape.md asserted a live factor-of-two
disagreement between NF.Charges and NF.Demurrage that
“is running now”, and both readers now answer 60 —
allowanceFor('detention') returns reconciled:true, gap:0
where it returned unreconciled before.
NF.Demurrage's own DEF_FREE stays 30 and is deliberately
not part of the correction: it is the builtin rung meaning nobody
has set a figure anywhere, a different fact from the organisation default, and
rm-band-triage.md's instruction that “DEF_FREE moves
with whichever is chosen” is retracted outright — raising it would leave
builtin and default numerically identical, which is
exactly how “nobody configured this” became indistinguishable from
“somebody chose this” the last time. Docs only; no behaviour change.A test suite can no longer take the shared sample-data realm away from
every other suite in the run. tests/run.js evaluates each suite
into one shared script scope, so a suite declaring its own top-level
const NF shadowed the runner's realm for the rest of the process —
and because the damage lands on async work that settles later, the
resulting failure printed under the shadowing suite's name while the file
that actually failed was never named. Measured in this repository: a clean
ALL PASS — 34338 became 34314 passed, 2 FAILED with
23 assertions never run, and a run of the offending suite on its own
was green. A new guard derives the reserved names from the runner's own publish
list and reads every suite file off disk, so a binding published tomorrow or a
suite added tomorrow is covered without anybody remembering; the runner carries the
same warning at the evaluation site, and docs/modules/testing.md
records the mechanism and the cost.
.catch, so a
throw in the first skipped the other two and scored its single red under the
third one's name. tests/suites/10-compliance-evidence.js farms
out to eleven run entry points; ten were written one .catch per
step, and NF.Desk's run(), runSend() and
runSwitch() were written as three .then links behind a
single handler with two more trailing it. A .then link is not a
guard — the first rejection short-circuits the rest — and the two
trailing handlers could never fire, because the first one had already settled the
rejection. Measured with a throw at the top of run(): the file went
from ALL PASS — 5491 to 5243 passed, 1 FAILED,
247 assertions short of itself, and the one red it did score read
"automation switch tests threw", sending whoever read it to the wrong
suite. Each run now carries its own .catch: the same throw costs
185 and is named "enquiry-desk tests threw", while
runSend()'s 42 and runSwitch()'s 20 assertions still
run. The three entry points were broken in turn and the arithmetic closes
— 186 + 42 + 20 = 248, the exact population the chained shape lost in one
go..catch again — and that proves it can register a
positive before any clean reading from it is believed.
tests/suites/147-suite-catch-pairing.js sweeps every
.js under tests/ off disk with two independent
predicates: a .then link calling a run* entry point
must be immediately followed by a .catch, and no two
.catch lines may be consecutive anywhere — the second names
no entry point, so it still fires on a chain whose steps are spelled something
else. Both read stripcomments.code() rather than raw text, without
which the suite would flag its own header and its own fixture. The census it
settles was derived off disk rather than assumed: over 524 files in
tests/, js/, tools/,
backend/, driver/ and portal/, all
11 run-entry .then links live in that one file and the only
two unguarded ones were this defect — so there was no second instance, and
the guard is what keeps that true. A first draft of its own predicate matched
nothing across the whole tree and read exactly like a clean one, which is
why the positive control runs first and why the repaired fixture is
checked too: blinding the predicate that way now reddens 14 of the
suite's 22 assertions instead of passing silently.tests/run.js keeps exactly one current group:
group(name) sets it and ok() files a failure as
heading → label. Every .catch on
tests/suites/10-compliance-evidence.js's promise chain called
ok(false, …) without opening a group first, and every one of
them settles asynchronously — so the red landed under whatever heading the
previous step left open, which is a step that worked. Measured on the shipped
chain, one injection at a time: a throw in store-sync's
run() filed under "Posted speed limits — the cache is
actually written, and read back", one in enquiry-desk's under
"POST /mail/send — the rate limit is a brake on our own domain
reputation", one in release-race's under "#519 The
automation switch". Each now opens its own heading naming the entry point
that threw. Twelve handlers on that chain, and a thirteenth found off disk
in tests/suites/46-dispatch-job-id.js — the same defect in a
second file, which the issue's own list did not have. This is the companion to
the earlier fix that corrected the failure's message: counting reds and
locating them are two measurements, and until now both signals pointed at the
neighbour..catch under tests/
scores a red without opening its own group first.
tests/suites/148-catch-group-heading.js takes each handler's body
by balancing parentheses rather than matching to the next ),
which is the only way to see a handler written across several lines, and reads
stripcomments.code() so that balance is over real punctuation and
not over a bracket inside a string. It runs its positive control first:
a fixture carrying the retired shape must report 2 of its 3
handlers as defective and name lines 3 and 5, and the same fixture
repaired must come back clean — because a predicate that matches nothing
is byte-identical in its output to a clean tree, and "fires on everything" is
not a control either. The sweep then asserts it found a real population to be
clean about: 286 files, 323 handlers, 14 of which score a
red. Its last section is derived rather than transcribed — for each
handler it reads the entry point out of the .then link above it and
requires the heading to name that module and that function, so a heading can go
stale only by going red.The kind of self-disabling regression test that was swept out of this project twice now fails loudly the moment somebody writes another one. A test here can be written so that a precondition is checked first and everything after it is abandoned if it does not hold — and where that precondition is a fact about the software being tested rather than about the sample data, the fault the checks exist to catch is the same fault that stops them running. An earlier pass repaired forty-one of those. What nothing held was the population itself: thirty-nine of them existed when the problem was first counted and fifty-three a few weeks later, so fourteen more had been written, by people doing ordinary work, while the document describing the defect was being drafted. Nothing on disk could have seen the fifty-fourth. There is now a check that re-derives the whole population from the files every time the tests run and compares it against the list of sites that are genuinely about the sample data — so a new one has to be classified rather than noticed, and a site that gets repaired has to be struck off the list rather than left rotting on it. Two ways the earlier count could have been fooled were closed and measured at zero, each with a control proving the measurement could have registered a positive. It detects rather than repairs, and says so: measured on the whole suite with a new one planted deliberately, fifteen checks still leave the count on both trees — but where the old tree reported a single unexplained complaint, the new one names the file and the arithmetic.
Forty-one regression tests that could be switched off by the very fault they watch for now fail loudly instead — and the worst of them was switching itself off without printing anything at all. A test in this repository can be written so that a precondition is checked first and the real checks are abandoned if it does not hold. Where that precondition is a fact about the sample data, that is right. Where it is a fact about the software being tested, it is the opposite of right, because the fault the checks exist to catch is the same fault that stops them running. Four earlier passes had repaired the version of this that abandons a block of checks; this pass took the version that abandons everything after it, which is unbounded — one complaint is printed and an unknown number of checks simply leave the count, so the run comes back looking almost clean. The whole population was re-derived from the files on disk rather than read off the earlier list, and each site traced to the function that actually answers it rather than to the words in its label. Fifty-three sites: forty-one were wrong, and are fixed. Two had been recorded as safe on their label and are in fact the board's own con-note resolver and the writer that saves work to the browser's offline cache — both of them software under test. Measured on the whole suite: with the board's resolver made to answer nothing, the run previously reported 25 failures and was 27 checks short of itself; it now reports 41 and the arithmetic reconciles. On the freight-import tests, where the same break previously lost 43 checks, nothing is lost at all. The sharpest find was not a precondition: inside the import tests, a check on a consignment record that no longer existed caused the test to abandon eleven further checks about what a driver is shown — with no failure reported anywhere, which is worse than the ones this work set out to fix. That is repaired too. Twelve sites really are about the sample data and are named so nobody re-reads them. No check was added, removed, weakened or skipped — the suite reads the same total before and after.
CN-1 delivered saved an edit to CN-2, CN-1 went back to booked, and the save showed synced with no conflict. The API now names consignments and tmsJobs beside the board in the capability that turns on #1321's "send only what you changed", so the untouched record is never sent; and each record carries a revision that is a content hash of the row as stored, recomputed on read and again at save time, so a genuine two-person edit is reported in the board's own conflict words and never refused — a change made by the mail poller or the FMS import is caught too, because the row moved whoever moved it. No new column, no migration. The conflict notice now says Con note or Job thread rather than calling everything a "Job", and says who changed it is not recorded where the revision cannot know — it knows the row moved, not by whom. A check that could not run is reported as could not check, never as clean. Needs a backend deploy (deploy-backend.yml is run by hand) before any of it binds; until then the loss is still reachable. The other sixteen sharded collections and every singleton are still last-writer-wins and are named in docs/decisions/1310-store-concurrency.md §9.Two people working at the same time no longer quietly undo each other's work. Every browser tab reads the whole database once when it opens, and it used to send the whole thing back every time anybody saved anything — so a tab that had been open since the morning would put its morning copy back over whatever had happened since. Nobody was told. The screen said Synced, and a consignment somebody else had marked delivered an hour earlier was quietly back to Booked. Reproduced exactly, start to finish: one person marks a con note delivered, a second person who has never opened that con note edits a different one, and the first person's delivery is gone.
A save now says nothing at all about the parts of the database it has nothing to say about. On a realistic office database, changing one setting used to re-send 60 consignments, the whole address book and all 33 of the other lists — 17,809 bytes of a document nobody had touched. It now sends 701 bytes: the setting that changed, and nothing else. The tablet's own offline copy is still the complete one, so a driver out of signal is unaffected, and a stamp a driver has captured but not yet sent can never be mistaken for something the office already has — that case is checked on every test run.
One part of this needs a server update before it takes effect, and it is named rather than glossed over: the case above where two people hold the same list and one of them loses an edit is fixed in the browser and waits on a small change to the server, which is deployed by hand rather than automatically. The much more common case — a tab restating work it never touched, on a list nobody in it has changed — stops as soon as this ships. Service history from Odoo is deliberately left exactly as it was, because changing how the browser speaks for it is a decision about Odoo rather than about saving.
The automated safety net could lose a batch of checks and still report that everything passed. FleetView's test run is what stands between a change and the live site: it is the first gate the deploy waits on, and its verdict is read as a statement about the whole system. Some of those checks run in the background and report back a moment later. If anything went wrong inside one of those background checks — a lookup that found nothing, a record that was not where it was expected — the run simply stopped scoring the rest of them and never said so. No warning, no failure, nothing on screen: the run finished and announced all passed, over checks that had never been carried out.
That is now impossible. A background check that fails is reported as a failure, named, with enough detail to find it, and the run can no longer claim a clean bill of health while it has one. Just as important, the run is honest about what it cannot know: when this happens it says plainly that its total is a floor rather than a total, because the checks that were skipped can never be counted — nobody got as far as running them. It does not invent a number for them, and it does not overstate the damage by throwing away a verdict that is sound for everything else.
Measured, and worth stating because it is the point of the change: on the very first full run with the new reporting in place, a failure that had been hidden this way turned up immediately in the compliance checks — a single mistaken line that had been quietly stopping four later checks in that area from running at all, under a green result. It is repaired, and the four run again. The honest summary is that recent green runs were a little less green than they looked, and from now on they mean what they say.
A failing check now names the part of the system it is actually about. The automated checks that run before anything reaches the live site print a heading above each group of results, and whoever investigates a failure starts by reading that heading. Checks that have to wait for something — a saved record coming back, a page finishing drawing — finish out of order, and until now a failure in one of those was printed under whichever heading happened to be on screen at the moment it finished. That is usually a completely different part of the system, and one that was working perfectly, so the first hour of looking into a problem was spent in the wrong place. Measured on the checks as they stood: 732 results across 18 files were being filed under a heading belonging to another file. Every one of those now re-states its own heading, so a failure says where it came from. Two earlier rounds of this fix covered two of the three ways it happens; this is the third and by far the largest. A standing check was added that reads every test file and refuses a new one written the old way, and the handful of places that could not be corrected in this pass — because somebody else was editing those files at the time — are listed by name with their exact counts, so they cannot be quietly forgotten and cannot quietly grow. Nothing a customer, driver or dispatcher sees has changed, and no check was weakened, skipped or removed: the total number of checks is exactly what it was.
tests/run.js at that file and skips every file after it, so
the run prints no verdict line whatever. That makes anything downstream
of the crash impossible to check in either direction: deliberately break a rule
to prove the test catches it, and instead of a count of failures you get a
crash, which reads exactly like a small one. Six such reads, in five files,
formed a single chain — each one hiding the next, so every repair simply moved
the crash along.
RUN TRUNCATED — 10 of 166 suite files executed43 of 16622 of 166
The asset register can now report its own duplicate identifiers, on both
register screens. Three master collections could already do this — depots,
and customers/locations/contacts on the FMS duplicates screen — and the largest
one, the roughly 1,190-row asset register, could not, so an NF number sitting on
two rows was invisible to everybody including the people who can see the live data.
#/assets and #/register now both carry the report.
It is built on how the register actually resolves an asset rather than on a
list of columns. An asset has no id field at all — its key is
its Fleet Dynamics unit ID falling back to its NF number — and the register
index writes both of those fields into one namespace, keeping whichever row
comes first. So the report is over that one namespace, and it separates the three
ways a clash arises: the same NF number on two rows, the same unit ID on two rows,
and the case a column-by-column report cannot see at all — one row's unit ID
being another row's NF number, which leaves the second row unreachable by its own
number. Each is named with the rows in it, and with which field each row claimed
the identifier through. Counts and names, never a percentage.
Three things it deliberately does not do. It never repairs: there is no merge in FleetView for any record anywhere, the panel carries no button, and it says so on screen. It does not fold the status-word rows into the count — the NF-number column doubles as a scratchpad, measured at 222 rows over 13 values such as SPARE, U/S and 3G, and counting those as duplicates would bury the one fleet number that matters; they are reported under their own heading instead, saying that the index does resolve each of them to one arbitrary row. And it does not read an unread register as a clean one: whether the database has been read is asked of the register's own read reader, not inferred from the list being an array, because on the deployed shape that list is empty before any read lands. An unread register is drawn hatched and refuses to call itself clean, every count is a dash rather than a zero when nothing could be read at all, and a duplicate found in a cached copy is still named — the caveat goes above the finding, never instead of it.
Archived rows are counted and flagged, which is a stronger point here than elsewhere: the register index does not filter them and keeps the first row it sees, so an archived row ahead of a live one is what the app answers when something is filed against that number. The count of clashes that exist only because archived rows are included is reported separately, so this report and the command-line register-quality instrument — which excludes them, correctly for its own question — stay reconcilable rather than drifting into two unexplained numbers.
ordinal: and the free number is derived, so reserving one no longer needs an edit to the shared ordinal table (stage 1 of #1332). The table in docs/session-handoff.md § Reserved suite ordinals made a merge conflict between concurrent slices certain: #1332 measured 15 of 15 pairs conflicting over six open PRs, every one on the same hunk, because every one deleted the | 152+ | *free at time of writing* | | sentinel row and added it back lower down, while the ordinals they claimed did not collide at all. node tools/suite-number-check.js --next and --free now work the answer out from four sources: tests/suites/, the suite files on every unmerged branch tip, the ordinal: of every docs/dispatch/ file in this tree and on every unmerged tip (a claim file sits on its own branch, and master holds none), and whatever rows the table still has. --claims reconciles table rows and dispatch ordinals as one register and prints the derived free-from, which is the range the table's N+ row used to state by hand. An ordinal a branch has already minted is still withheld with no dispatch file and no row, which covers the four branches that held 152, 153, 155 and 159 at 35294f2f with no registration. The table itself is not edited, because four open PRs append to it. Stage 2 retires it by deleting the table and writing <!-- suite-ordinals: derived (#1332) --> under the heading, and the tools already read that shape as retired. Measured by hand with git merge-tree --write-tree over six slices claiming 160–165: done today's way (a row plus a moved marker), 15 of 15 pairs conflict and landing them one after another conflicts 5 of 6 times. Reserving the new way is 0 of 15. Reserving and minting the new way, with the table retired, is 0 of 15 pairwise and 0 of 6 in sequence, and all four gates pass with all six merged (node tests/run.js reports 35,715, which is the unmerged 35,709 plus the six slices' one assertion each), with no tool or test change needed for stage 2. While the table's N+ row stands, the change that lands an ordinal still has to move it past its number (a minting slice that leaves it alone gets 1 red), so the conflict at mint time goes away only after stage 2.--next answers unknown and offers no number. A file that does not parse could name any ordinal, so no number counts as verified free while it stands. The same goes for a branch tip whose docs/dispatch/ could not be listed. Every candidate is still printed, under unverified. An ordinal that is written but does not read (ordinal: 16O, #160, 1600) is now refused by name rather than read as no ordinal, since reading it as empty would hand out the very number the file reserves. An unreadable table keeps its old behaviour: the answer is still checked, and the output says the table was not read. tests/suites/130-suite-claim.js checks all 18 combinations of branch read, table state and dispatch read. It shows that deleting a dispatch file gives its number back, turns a branch-held reservation into unregistered, and moves the derived free-from; that the answer is the same with the table's range row and without it; and that a retired table is read as a source holding nothing. tests/suites/138-dispatch-claim.js runs the tip reader over a fabricated git and flags two dispatches in one tree reserving one number.docs/session-handoff.md is retired. Suite numbers are worked out from the repository, and a number is reserved in your own dispatch file (stage 2 of #1332). The table ended in a N+ | *free at time of writing* row that every slice minting a suite had to delete and re-add lower down, so any two concurrent slices conflicted on it (#1332 measured 15 of 15 pairs). The table has been replaced by <!-- suite-ordinals: derived (#1332) --> under the heading, which the tools read as retired. To reserve a number, write ordinal: NNN in docs/dispatch/<your-branch-slug>.md on your own branch. To find a free one, run node tools/suite-number-check.js --next, which reads the suites here, the suites on every unmerged branch, and every dispatch file's ordinal:. Nothing needed moving when the table went: 37 of its 38 numbered rows were suites already on master, and the one reservation (162, for #344's branch) was already in that branch's own dispatch file. 160 is a deliberate gap. The instructions in that document that said to add, move or rewrite a row now point at the dispatch file. tests/suites/130-suite-claim.js now fails if an ordinal table is written back under the retired heading, since that would bring back the shared line. The same suite and tests/suites/138-dispatch-claim.js used to stop the whole run when that heading's marker was deleted (RUN TRUNCATED — 7 of 9 suite files). They now report 12 failures and finish. Re-measured by hand with git merge-tree --write-tree on real master (ba20aa4b), with six slices each reserving and adding one suite at 164–169. Done the old way (a table row, the moved N+ row and the suite) 15 of 15 pairs conflict and landing them in turn conflicts 5 of 6 times. Done the new way (their own dispatch file with ordinal:, and the suite) on this change it is 0 of 15 and 0 of 6, and suites 130 and 138 pass with all six merged. This is the measured answer to #1332's AC4: 15 of 15 becomes 0 of 15.NF.Rates.priceQuote
billed a per-km or hourly quantity as Math.max(value, minimum or 0)
with nothing in front of it. Measured at 0b23b525 on a $4.25/km
rate: −5 km struck the line haul at 0 km, $0.00, and reported the
quote complete at $121.00, which is the collection fee and GST on
their own. −2 hours on an hourly rate with no minimum did exactly
the same. Against the seeded three-hour minimum, −2 hours billed three
hours and labelled them a minimum set against “−2 worked”,
which looks like a sensible price. A figure below zero is now refused by name, as
negative_distance or negative_hours. It sits in the
same unpriced list where no distance was given and not a basis
already appear, so it adds nothing to any total and the quote cannot be presented
as the price. There are three answers, not two. A missing figure is still
no distance or no hours. A 0 is still priced, because a
typed 0 is an answer. Every non-negative case measured (0, 120 and 250.5 km;
0, ½, 4 and 6 hours) prices byte-identically to before, to the
cent. Where no rate applies at all, the negative is the refusal the quoter is
shown, so a typing slip does not send them to the rate card. Hours typed beside
a job priced per load are still not read at all.negative_hours the same way, with no change to the
form. The price check on #/rates reads the same
nqBasisAsk() since #1352, so a negative distance typed there is also
the pricer's refusal now. It prints in the check's list of what could not be
priced, not in the check's own error box, and the browser check asserts this.
Negative hours typed into that panel's Hours box should behave the same way,
since the panel calls the same pricer, but that case was not checked in a
browser. An unreadable
distance is still the form's to refuse. A number input hides what was typed:
1e reads back as an empty box, and only
validity.badInput shows otherwise. The pricer would be handed nothing
and would say no distance was given, which is false about a box somebody
typed in.NF.Quotes.autoPrice the tier and nothing else, and autoPrice fell
back to the stored quote for the customer, origin, destination and tier but
read the vehicle class, vehicle type, hours, distance and rate basis
only from what it was handed. Measured through the real writer on the
seeded Perth → Pilbara lane on 25 Sep 2026: a Road train quote went
$17,254.60 → $13,406.80 — the recorded $3,847.80
understatement, to the cent; per km at 1,640 km went $10,241.44 →
$13,406.80, re-struck per load and still marked complete; an hourly Rigid
quote at 6 hr went $1,558.48 → $0.00. autoPrice now takes each
of the five off the record where the caller does not supply one, so the fix
reaches every caller rather than one button: the basis a quote
asked for off its stored rate-basis stamp, the class it was priced on,
and the hours or kilometres off its own derived cartage line. Re-priced, all
three come back unchanged to the cent. A quote with no stored basis is
re-priced with no basis — whether it recorded that nobody asked, or
was priced before the ask was recorded at all — never as per load
and never as whatever its line happened to be struck on; on a lane priced only
by the hour, per load would have turned a priced quote into an unpriced
one. An option a caller does supply always wins (a blank is an explicit
none, a typed 0 is an answer), and a value on the record that cannot be
read refuses the re-price in its own words rather than being dropped. Two
things it deliberately does not do: it never recovers an hours figure from the
billed quantity (under a minimum that is the minimum, not the work), and
it never reads the hours off a cartage line somebody overrode, because the
writer keeps an overridden line and derives a second one beside it — a
double charge that is live today on every per-load quote with an overridden
cartage line, reported and not fixed here. The vehicle type is stored on
no quote, so only its class comes back.node tools/dispatch-claim-check.js worked out DOUBLE-CLAIMED, ISSUE CLAIMED
TWICE and LEASE OVERLAP from this checkout's own docs/dispatch/ folder and
nothing else. A claim file lives on its claiming branch and is deleted when the work
lands, so on master that folder holds only its README. From the master checkout a
coordinator actually uses, the tool printed 0 dispatch(es) while four claims
were pushed (measured 25 Sep 2026). Two agents could claim one issue, one branch or
one file and the tool would print a clean zero over both. The checks now read every
claim file at every unmerged branch tip as well as this tree. They use the same tip
reader the suite-ordinal half already had, not a copy of it. One claim seen in several
places, here and at its own tip or on a fork, counts once. A claim whose fields differ
between two places is reported as DRIFT, not as a double claim. There are three
answers, never two. If a tip could not be listed, or a claim file anywhere does not
parse, the collision answer is COULD NOT LOOK. Any collision that was proved is
still printed beside it. A clean result is now said in words and names how many tips
it read. Before, a clean result was silence, and a read of one empty folder was silent
too. Its first live run from master found a real overlap nobody had seen:
claude/1344-media-retry and claude/569-link-scope both lease
js/integrations.js.git log over every tip: 0.3 s against 11.8 s for one diff per branch,
measured over 296 tips. On its first live run the two newest entries were the parallel
session's claude/1339-reprice-inputs and
claude/1310-conflict-report. Both touch files #569 leases.suite-number-check.js --claims no longer points at a table that is
gone. An unregistered suite ordinal was described as "named by NO row in the
register. Nobody reading the table would know it was taken". That table was
retired in #1349, and the sentence also had it backwards: the branch-tip read already
holds such a number back from --next. The line now says where a number is
actually reserved, a dispatch file's ordinal:, and what is missing: a
claim made before the file existed.A delivery photograph that fails to upload is now retried, instead of staying inside the con note for good. Before this, when a signature or photograph could not be uploaded — no signal, a timeout, the server mid-update — the cab kept it inside the con note and nothing ever tried again. And one "service unavailable" answer (what the server gives during a deploy) switched uploads off for the rest of the session, so every later photograph stayed inside the con note too. A POD with a signature and several photographs is the record most likely to be refused by the database as too large, so a driver in a dead spot could end up with "Not saved — call the office" on the record that proves the delivery happened.
Now a failed upload is queued on the tablet and retried on its own: after 30 seconds, then 1, 2 and 5 minutes, then every 10 minutes while nothing gets through. Taking more photographs does not make it ask sooner, and restarting the app does not either. A POD taken in a dead spot costs one request, not one per photograph. The photograph stays in the con note, unchanged, until the office's server confirms where it has stored it. A request still in flight is not treated as a confirmation. Neither is a hotspot login page that answers "OK". Measured over a simulated hour with no signal: ten requests, where before it was one per capture for ever, or none at all after a single 503. "Service unavailable" and "not found" are temporary now. A photograph the server refuses for good (too large, or a file type it does not accept) is not retried, and the cab says so.
The job's docket on the cab has a new Uploads row. It shows three separate answers, never one: uploaded, waiting to upload (with when it will next try and what the server last said), and could not upload (in the server's own words, with "tell the office"). A photograph taken before this change shows as on this tablet only. The row updates while the docket is open. The privacy screen's "work still waiting" card now counts photographs as well as times.
Not covered yet: form photographs, a credential's evidence and a session signature are still not retried, because they are stored somewhere other than a con note. Photographs already held inline from before this change are not re-uploaded by the tablet. The server's own clean-up on save still handles them.
NF.Quotes.autoPrice kept every overridden line and wrote the rate card's fresh
line-haul line beside it, both with the same description and both in the total. Measured through the
real writer and the re-price button's own call, on master at ee5973cf: a per-load Road
train quote overridden to $200 went from $6,149.00 to $24,299.00, still marked complete; across a
battery of 1,692 overridden quotes on three dates, 1,692 of 1,692 came back with two line-haul
charges — 990 as two priced rows, 702 as the override beside a "not priced" line haul. Hourly and per-km quotes went wrong the other way: the button handed the card no
hours, so it priced no line haul and dropped the collection fee and fuel levy with it ($1,757.80 to
$1,320.00 on the issue's 6-hour Rigid quote), with the line haul listed as "not priced" beside the
override that priced it. The override now takes the card line's place and the card's line is not
written: after the fix the same 1,692 carry exactly one line haul each and not one total moved.
The override is the one that stays, because somebody chose it on purpose with their name and a
reason on it; a caller that wants the card's figure passes keepManual:false, as before.
Two re-prices are now refused by name, with the quote left as it was: when the rate card
cannot price the line haul itself (a retired agreement, or an hourly quote priced before the hours
were kept), because every charge it takes of the line haul would fall off, and when two line hauls
were set by hand. A quote nobody overrode re-prices byte-identically: 3,780 of 3,780 quotes
(six customers, 21 lanes, ten asks, three dates, 756 of them carrying a typed charge), first priced and
then re-priced, matched master to the byte in their rows, their pricing record and their total.
rates rows coded cartage: one with auto:true and no
override (the card's), one with an override object naming who and why and
source:'override'. An accepted one froze both rows into the job's accepted price, and an
invoice built from it copied them. The quote letter (NF.QuoteAccept.messageFor) prints
every row as "description — amount" and names neither as an override, so the customer saw two
identical "Line haul" lines and a Total including both. A quote sent from the enquiry desk keeps the
sent body on its email trail; one marked sent from the quote screen keeps only the total. How many
production quotes carry the doubled line is not known: we could not look, because production
cannot be read from here. One re-price mends a quote carrying the card's copy beside one override.tests/suites/152-store-prune-shards.js) used to wait a fixed
2.1 seconds — the 1.5 second save delay plus a 600 ms guess —
and then inspect the save it expected, and waited a fixed half-second or so for each
simulated browser tab to read the database. When the machine was busy the save or
the read arrived after the guess and the check went red about nothing. Measured on a
4-core machine with four to six full test runs side by side: 1 of 11 completed runs of the
old version reddened two of these checks. Every wait is now on the thing itself
— the tab reporting that it has read the database, and the save's reply having
come back and been handled — with a 60 second ceiling that reports a named
failure rather than carrying on. Nothing a customer, driver or dispatcher sees has
changed, and no check was added, removed or weakened: the file still runs 65, and
deliberately breaking the save-pruning it guards reddens the same 8, 4 and 6 checks
before and after.NF.Quotes.autoPrice
kept the override and wrote the rate card's own line of the same charge beside it, both with the same description and both
in the total. Measured through the real writer and the re-price button's own call, on master at abb751cf, on an hourly Rigid
quote of 6 hours with the charge overridden to $50, identically on 14 Aug, 25 Sep and 31 Dec 2026: an overridden collection fee
re-priced from $1,361.80 to $1,482.80, an overridden fuel levy from $1,166.00 to $1,482.80, and a Hot shot uplift on a
per-load Road train from $26,747.60 to $34,915.10. Each was an ordinary success with two lines of that charge on the quote.
Now the override takes the place of the card's line and the card's line is not written, for every charge the card prices. After a
re-price there is exactly one line per overridden charge, and it is the override, with the name and reason of whoever set it.
The override is the one that stays, for the same reason as #1348: somebody chose that figure on purpose. A caller that wants the
card's figure still passes keepManual:false. The mechanism is #1348's own, generalised by charge code rather than
duplicated. Two hand-set figures for one charge now refuse the re-price by name (colldel_ambiguous,
levy_ambiguous and so on, as cartage_ambiguous already did), and the quote is left as it was.
complete keeps #1348's meaning, so complete:true still
means every charge carries a figure. A quote whose only gap is covered by a hand-set figure stays complete:false, because
NF.Quotes.price counts that entry and every screen, the desk's send gate and the invoice read it. Before this change such a
quote came out the same way, minus the label.
rates rows with
one code (colldel, levy, tier or loading): one auto:true with no
override, and one carrying an override object. How many production quotes carry one is not known: we could not
look. One re-price mends a quote carrying the card's copy beside one override.A split shipment reads as one shipment on the customer portal again.
Since the portal was cut down to the con note fields it shows (#1222), it had
been receiving a split part without the two fields that say which shipment the
part belongs to and which part it is (parent and
splitNo). So the portal could not fold a part into its shipment.
We measured this on a con note split into three parts with the real split tool
and drawn by the real portal page. A part's tracking link showed the part under
its own number. The shipment's own page lost its "Your loads" card. The account
list showed the shipment and its three parts as four separate shipments. With
the two fields restored, all three views are identical, byte for byte, to what
they were before #1222. Both fields are references inside the customer's own
booking and name nobody. Who split the con note is still withheld.
The check that should have caught this compared the portal's answers
before and after the cut, but the sample data holds no split con note, so it
never tested one. It now builds a split through the real writer and compares
every page against the whole record. Taking either field back out now fails 16
checks across two backend suites. This binds only after the next backend
deploy (deploy-backend.yml runs by hand only). The tracking
link's narrowed read (#569) already carries the whole split family, so nothing
else has to change for the link page to fold.
The enquiry desk now asks "is this the right client?" before a drafted reply can be sent. When the mail poller cannot be sure who a message is from, it drafts a reply instead of sending it (#1370). Before this change, nothing on the desk then asked whether the client was right. Once a quote existed, the desk said the client came "from the quote record", however it had really been found. Measured on the desk before the change: a draft whose client was guessed from the sender's email domain, and one where nothing recorded how the client was found, both showed a Send button. Neither asked "right client?" anywhere on the page. Now the reply card asks that question above the Send button. Pressing Send first refuses and says why, in place. Pressing Yes records who confirmed the client and when, and it needs a name. Only two kinds of match skip the question: the sender's exact address is in the address book, or a person has already chosen the client. These are the same two the poller will send on. A quote raised by hand is not asked, because no sender's email was matched to a client.
The desk no longer says "Chosen by a person" when nobody chose the client. A message could carry a client with nothing recording how that client was found. The desk reported that as a person's choice, and the queue labelled it "Chosen by a person." It now says, on a hatched background, "Not recorded how this client was found", and treats it as needing a check. Picking a client with the desk's Save button now also records who picked it. Front end only, and live on merge. (#1373)
NF.Events and
stopped there, so an expired MC licence was still visible only to somebody who
opened #/compliance or scrolled the log. NF.Reco now
carries a compliance source, and it is a projection of the ladder's own
notices rather than a second reading of the register, so the panel and the
Escalation tab cannot answer differently about who has been told what. Worst rung
first and by name (“2 with the ops manager · 1 with their
supervisor”), never a percentage. (#233)#/compliance was the only one of the eleven compliance reports with
no as at anywhere and no coverage at all — it printed a list of
waivers and left an auditor with no way to tell three waivers out of a register
of twenty-two people and six rules from three out of nothing. It now leads with
names and counts: how many waivers are on record, how many are in force,
how many are not, the person × requirement space they were read against,
how many rules are published in total, and the time of the read — with the
people currently working under an exemption named rather than totalled,
and no percentage anywhere. Its empty state now names which empty it is:
no rule is published, so nothing could have been waived, nobody is on
the register and no waiver has ever been granted are three different
phone calls, and the grant form already told them apart while the register did
not.
revoked or active off one field, so a waiver that had
lapsed, and one carrying no end date at all, both exported as
active — the exact two-state defect the screen beside it had already
been repaired for, surviving in the artefact that leaves the building and is read
months later. The file now takes the screen's own words through one reader, in
the screen's own order, and carries a plain In force yes/no column beside
them. Measured: reverting the export reddens 5 assertions in
node tests/run.js and 1 in
tests/manual/compliance-reports.js — and 0 in the
browser until that fixture was given a waiver that is not in force, because with
only a live waiver on the page the old expression is right by accident.
Removing the coverage head reddens 9 and 5; collapsing the three
empties to one reddens 5 and 0.node tests/run.js and
5 in tests/manual/compliance-reports.js; collapsing the three
empties to one reddens 6.NF.Compliance.waive() and
NF.Compliance.voidCredential() have existed, tested and refusing
self-approval by name since v0.111.0, and until now nothing anywhere in the
app called either: a waiver could be revoked and exported but never granted
except by editing data in a console, and a certificate filed against the wrong
driver could never be taken off their transcript. Grant a waiver sits on
the Waivers register and on every requirement of a person's training record;
Withdraw sits on every certificate on that record. Neither writer
changed — this is the form that reaches them.waive()'s refusals and both of
voidCredential()'s are driven for real by the browser check and
read back off the page — including "Separation of duties: <name>
cannot approve their own waiver", which is then left alone for 3.2
seconds, longer than a toast lives (TOAST_MS is 2600), and
asserted still on screen and not in the toast. A refusal also no longer
costs the operator their typing: the draft is held in state rather than read
back off the DOM, so it survives the repaint the refusal causes.activeWaiver() — the reader the dispatch board and the
driver's own screen consult — already refused a revoked waiver, a
lapsed one and one carrying no end date at all; the register that
auditors probe first printed active for two of those three. It now names
each: lapsed on <date> and not in force — no end date
recorded. All three are things we know rather than things we cannot tell, so
none of them is hatched. Measured: reverting that column to revoked-or-active
reddens 2 assertions in tests/run.js and 8 in
tests/manual/compliance-reports.js, the latter under four skewed
clocks whose observed day keys are asserted all different, so the sweep cannot
pass vacuously.voidCredential() stamps voidedBy only when it is given
one, so a record withdrawn before this form existed carries none — and a
blank cell there reads as "nobody did it", which is a claim. The transcript now
says who withdrew it is not recorded, drawn in the same hatch
.cmp-pill--indet uses, because that is a gap in our records rather
than a fact about the withdrawal. The browser check reads it as a real gradient
off getComputedStyle, never as a class name: a class no loaded
stylesheet defines ships flat and passes CI. The record itself is kept, struck
through, with its reason — archive, never delete.POST /mail/send) and the scheduled poller replying unattended in
autosend — and until now neither asked which deployment it was
running on. The refusal had been written since the environment banner shipped and
the only thing calling it was the banner. Both now ask
backend/src/env.js, which reads the stack's own
ENVIRONMENT, and both relay its sentence unchanged so the desk, the
record on the job and the banner say the same thing.A customer can now say no from the quote link they were sent. Until now the only way to decline a quote was to ignore it — and a quote nobody answers is recorded as expired, which is the state for silence. So a customer who had read the quote and decided against it left a record that was indistinguishable from one who never opened it, and the two are different phone calls. The acceptance link now carries a Decline control beside the Accept form, with its own name box and its own confirmation so one tick cannot mean two opposite things. A decline closes the quote as lost, never as expired, and it says so on the conversion report's own breakdown by reason. Telling us why is optional and deliberately so: a picklist somebody has to satisfy is friction on the one act we most want recorded, and the alternative is not that they pick something, it is that they close the tab. The reasons offered are the ones the app already counts — never a second list — and the quote record, the customer's confirmation page and the office's own outcome panel all read the same one.
An outcome recorded from a customer's own link now says, on the record, that we do not know who clicked. There is no sign-in behind an acceptance link, it can be forwarded, and nothing checks who opened it — so a name typed there is who said they declined, and a panel reading "recorded by Dan Reilly" with nothing beside it states a Northfleet record on an unverified stranger's word. Every outcome now carries which desk it came off and whether anybody was signed in, and both the customer's page and the dispatcher's quote screen draw the unverified case hatched — the same "we do not know" treatment used everywhere else in the app. An outcome recorded by our own office is not hatched, because that is a thing we know exactly, and drawing it as an unknown would read as a gap somebody should go and fill in. Declined by the customer is also a new reason in the outcome vocabulary, and it is refused from the office picklist by name: recording it there would claim somebody pressed a button nobody pressed, and the refusal points at the reasons that do belong there.
NF.Quotes.recordOutcome — eight
reasons, a named person, a note, and the thread move where the state machine
allows one — shipped with zero callers anywhere in the dispatcher,
the portal or the driver app; the only thing that had ever run it was a test. So
the outcome breakdown on Reports could never be anything but empty, and it said
so honestly for ever: measured on the seeded dataset,
NF.Quotes.conversion({}) answered “7 of the 7 decided
quotes carry no recorded outcome reason” with all eight codes at zero.
The wording and the hatching were both right and the mechanism was absent, which
an honest empty does not fix. Meanwhile the one gesture on the quote screen that
closed a quote — Decline — called
NF.Quotes.setStatus(id, 'declined'), which moves the thread to
lost and writes no reason, no person and no note, so a quote beaten on
price and a quote nobody ever answered were the same record afterwards.
no_response maps onto expired rather than onto lost
precisely because those are different phone calls. The quote’s Workflow
card now carries an Outcome panel that reads
NF.Quotes.OUTCOMES verbatim rather than restating it, and the
reason-free close is gone: there is no way left on that screen to close a quote
without saying why and signing it.background-image and not the class name, and asserts the recorded
state is not hatched — without that comparison the hatch is
unfalsifiable. Measured: removing the hatch rule from the stylesheet while
leaving the class in place reddens 2 checks in
tests/manual/job-thread.js and 0 assertions in
tests/run.js, which is the whole reason the claim is read off the
rendering. #/quote/<id> also drew nothing whatever in its
Workflow card for an expired quote — the one status whose entire
meaning is that nobody came back — and now says so.tests/manual/engine-room.js (407 → 457 checks):
removing the strip reddens 39 assertions, collapsing the four answers to two
reddens 7, counting roster markers as bookings reddens 6, and swapping the
hatched and dimmed‑dashed treatments reddens 5. Day-independence is shown
rather than asserted — the clock is faked inside the page and the whole
fixture rebuilt against each faked day's own window, across seven consecutive
days from Saturday, with an identical answer on every one.A timing clash is now found when one of the runs crosses midnight. The schedule's timing check compared the runs on one day only. So if a driver had an overnight run from 22:00 to 04:00, and their 02:00 run the next morning was marked timing critical, nothing was flagged, even though the two overlap by two hours on the same driver and the same truck. Measured by putting those two runs on a real run line and letting the board make their jobs: the check found 0 clashes on either day, at four clock settings including a month end and a year end. It now compares a day's runs against the day before and the day after as well, so the conflicts panel lists that pair once, says it is across midnight, and names each run's own date. The answer is the same at five clock settings, including a month end and a year end.
A run whose time cannot be read is no longer treated as clear. When one
of two runs on the same driver or truck is marked timing critical and the other
run's start or finish is blank, or cannot be read as a time (for example
7am), the check used to skip the pair without saying anything. Now
the panel lists it, hatched, as timing not checked. It names the run and
the time that could not be read, and says this does not mean the two runs are
clear. The panel does not count it as a conflict. A pair that cannot overlap
whatever the unread time says (for example, a run that finished at 15:00 the day
before) is still cleared, and a board with nothing marked timing critical still
shows nothing.
The schedule board's Projects group now holds the rows recorded as the projects board, not the rows whose caption happens to mention a crane. Which group a row landed in was decided by searching its free-text caption for the words "crane" or "project", while the answer built to record it — the row's own Hired in? control, set to Projects division — was ignored. So it went wrong both ways: a row recorded as projects with an ordinary caption was not pinned at the top, and one of our own rows captioned "Cranes / Projects" was lifted out of its depot and out of that depot's "no work today" count, under a note saying it is managed outside this board while it was being scheduled on it. On the sample board that was four rows at each of the three clock settings tried: three of our own crane rows and a grey-fleet row, all now back under their own depot. On a live board, where every caption is typed into the Run lines form, the caption now moves nothing: to put a row in the Projects group, use its Hired in? button and pick Projects division. The strip that lists bookings beyond the board's last day tags projects rows off the same answer.
A row whose carrier the board cannot read now says so, hatched, instead of passing as one of our own. A row can name a carrier record this browser does not hold (added on another device, or the carrier list not loaded yet) or a kind this version does not recognise. The carrier list quietly treats those as our own driver in our own truck. The board now keeps such a row out of Projects and under its depot, which is recorded, and marks it hatched: Carrier not on file on the row, naming the id it could not find, and 1 row — carrier not readable in the depot's header, which is still visible when the group is collapsed. The header says plainly that the row is still counted in that depot's driver figure, because that is the carrier list's own fallback, and that this is not a statement that the row is ours. A row that records no carrier at all is unchanged: that is the long-standing rule that an unset row is one of ours, and it is drawn as the ordinary row it is. An archived carrier still counts as known, because last week's runs still name it.
The office can now rename an in-cab tablet. Settings → In-cab tablets has a Rename button on every active tablet. Until now a tablet kept whatever name it gave itself when it was first put in a truck: the register could rename one, and had been able to since it shipped, but no screen offered the control. The form saves through that one register writer and nothing else. If the name is refused — a blank name, for instance — the reason is printed inside the form, in the register's own words, and what you typed stays in the box. The form also says plainly what the system does with the name: a tablet's row has no per-record conflict check and the last copy to reach the database is the one kept, so a tablet that has not picked up the rename (because it was out of signal, or has been open since before the change) can put the old name back the next time it syncs, and nothing detects or warns when that happens. Measured by breaking it: a Save that never calls the writer reddens 15 checks in the main test suite and 9 in the browser check; a refusal left out of the form reddens 3 and 3; removing the sentence about the old name coming back reddens 5 and 4.
A tablet can now be assigned to any truck on the fleet register. The cab's truck picker offered only the operational vehicle list and whatever the board had a lane for, so a tablet in any other truck could not be bound at all — and since only a bound tablet is enrolled, such a truck could never reach the device register either. Measured in Chromium with 40 register-only prime movers seeded: 0 of 40 were offered. It now reaches the register, with a find box because that list is searched rather than scrolled.
The list also says what it left out, which it did not: how many trucks fell off the page, and how many trailers, dollies and plant items were excluded because a tablet is not mounted in one. A register row nobody has typed is offered rather than dropped — we cannot tell that it is not a truck — and says so on itself, hatched.
Three empties are told apart, where there had been none: we have never read the
fleet register (hatched — a fact about this tablet's copy of the database, not
about the fleet), we read it and it holds no truck, and nothing matches
what you typed. The first is answered by NF.readComplete(), this
being its first call site anywhere in driver/.
On Settings → In-cab tablets, a refused revocation is now shown inside the revoke form, and the reason you typed is kept. Before this, if Request revocation was refused (for example because nobody was named under Recorded against), the reason for the refusal appeared in a box above the whole table, away from the form it was about. The form then redrew with the Reason box empty. A repaint of the screen while somebody was still typing, which happens whenever the database syncs, also emptied both boxes. We checked this in a browser before making the fix: the refusal appeared only in the card-level box, and the typed reason came back empty.
The revoke form now works the same way as the rename form. The register's own refusal sentence is printed inside the form, above the Request revocation button. Both boxes keep what was typed through a refusal and through a repaint. Nothing about what a revocation does has changed: it is still a request, and the tablet still has to acknowledge it.
The rename form now warns when the name you type is already used by another active
tablet. The warning names that tablet by its ID and by the truck it reports being in.
It appears as you type. The name is how somebody finds the physical device, and two
tablets with the same name cannot be told apart by it. The comparison ignores capital
letters and extra spaces. Archived tablets are not counted, and a tablet saved by an older
build with no active field is treated as active.
This is a warning only, and the warning says so. The register still accepts a duplicate name, so Save still renames the tablet. The register's own rule is unchanged. It could not simply refuse duplicates anyway: a newly enrolled tablet is named after the truck it was bound in, so two tablets that have been in the same truck can be given the same name automatically, with nobody renaming anything.
NF.EWD.stateFor() had two answers where it needed three, and the missing
one is the one a stock build is in for every driver: we hold nothing at all. Measured on the
shipped adapter, with nothing connected and nothing captured it returned
warnings: [], breaches: [], remainingWork: 12 h
and remainingContinuous: 5¼ h — a full legal allowance, derived from
nothing, and exactly the figure an in-cab break warning would read as "you may drive for
another five and a quarter hours". No hours recorded is not hours available. The adapter now
reports basis — provider, local or none —
holds no headroom figure at all under none, and carries
assessed:false so an empty breach list cannot be read as "assessed, and clear".
Collapsing the three states back to two reddens 13 assertions; restoring the manufactured
headroom reddens 3.config.js was enough to print
"Hours shown from logmaster" — which is false every time that provider has not answered,
and the adapter falls back to the caller's own local figures in exactly that case. Measured:
with a provider configured and no payload received, it returned source:'local' and
the caller's own 2 h work total under that sentence. An inaccurate disclaimer is worse than
none, because the inaccurate one is itself a compliance claim.
NF.EWD.notice(reading) and disclaimer(reading) now describe the figures
actually in view, in three kinds, and "Hours from logmaster" is said in the one case where
it is true; asked with no reading they describe the connection and claim nothing about any figure.
Hours & Fatigue stopped telling a dispatcher its table was "a guide based on the
times you record here" on a screen that records none. Reverting the wording to the
configuration reddens 9.backend/src/edi-coates.js) held its own parser for
c.deliveredAt, written on 3 September when the shared layer had no
reader for a stored date-and-time stamp. NF.readStamp landed four
days later as exactly that one reader, and the two then disagreed about
8 of 15 shapes that reach the field — four of them with
the milestone answering where the one reader refuses: a stored
2026-02-31 09:02 derived a dated, complete milestone, and
09:75 shipped as a time. complete is the flag a
transport is told it may send on, and Coates reads this into a performance
measure they run commercially. NF.readStamp is now an injected
reader beside NF.poOf and NF.custRefValue; the
record-only fallback stays for a caller with no shared-layer realm and is
narrowed so it can never be wider than the one reader (real month
lengths, minute and second bounds, the same 2000–2100 years). Re-measured,
the two now agree on every shape except the two the module declares,
and the fallback and the bound reader answer identically on 16 of
16 — so whether a realm was available is no longer a fact about
the caller that leaks into what a customer is told. (#253)NF.readStamp uses the reader’s local one, which is
right in a browser in Perth and UTC in a Lambda, and this module’s whole
guarantee is that it holds no clock and derives the same payload forever. So a
stamp ending in Z or ±HH:MM is passed through
verbatim under its own gap, delivery_time_zoned — which is a
different sentence from “we could not read it”, and the two
are never both named for one stamp. resolution.deliveryStamp now
says which of the two readers answered. (#253)en-AU text, so the request states what we actually hold: the
precision (to the second for a POD captured today, to the minute for older
records, and a few with no time at all), that nothing is ever padded to
midnight, and a third state it did not previously name — a stamp we
hold and cannot read, which is neither dated nor undated and which we
would withhold rather than repair into a plausible date. Still nothing is
emitted anywhere: emit() refuses even a complete dataset, naming
the specification that has never been obtained (OQ-04, owner
Anthony French), and the module remains free of any network primitive. (#253)OQ-03/OQ-15 — one POD sheet at the end, or a signature per
drop — was decided by Simon Anderson on 18 Sep 2026 (one per drop) and recorded in the
decision register's summary table. The split_family_head refusal in
backend/src/edi-coates.js still said the question “is not decided”,
and docs/modules/coates-edi.md said the same at three places, one of them inside
the specification request written to be sent to Coates as it stands. All four are corrected,
with the old wording kept on record. None of them now says settled: the decision covers
our side only, and nobody has confirmed with Coates that their system can take one milestone
per part, or tested it. The only check on that message tested that the question's id
was named. The id is there under both wordings, so the check stayed green straight through
the decision. backend/tests-edi-coates.js now checks what the message
says against the register, read through tools/oq-currency-check.js, the
register's one reader. So a later decision moves the check without anybody editing it.
Measured: putting the old message back reddens 4 assertions where the old check
reddened 0. Reopening the register row reddens 3, and moving its decision date
reddens 2. tests/run.js reddens 0. Text only: the module still
sends nothing and nothing calls it, so no behaviour changes, and the new message goes live
only with a backend deploy.NF.settings.demurrageFreeMin, which shipped
30. Measured through the shipped resolver on the shipped book:
10 of 12 accounts had nothing negotiated and were billed from
30 minutes, and 11 of 12 were measured against something that
was not the promised hour. The standard is now 60, so
11 of 12 are measured against the hour the document states.
Ruled by Simon Anderson on 18 Sep 2026. (#254)NF.Charges.allowanceFor('detention') compares the published
demurrage card against what a wait is actually billed against, and answers
unreconciled when the two disagree — which refuses the
charge rather than raising it against whichever figure the code happened to
read. The card said an hour and the standard said 30 minutes, so that
disagreement was the shipped state and detention was unchargeable for ten of
twelve accounts. Reconciling the two figures turns the charge back on. This was
not what the change was made for and is the larger half of what it did. (#254)The estimate promised an hour of free waiting time and did not say what the invoice
bills from. The allowance sentence on every quote is a hard-coded literal — one hour
loading and one hour unloading per trailer — while a wait is measured against
NF.Demurrage.termsFor(), which is the customer's negotiated allowance where they
have one and NF.settings.demurrageFreeMin (shipped 30) where they do not.
Measured through the shipped resolver on the shipped customer book with the clock pinned:
11 of 12 accounts were billed against an allowance that is not the hour the document
promises — ten at 30 minutes with nothing negotiated, and Water Corporation at 45 minutes,
whose estimate contradicted itself on the same page, its own 45 printed two lines
under the unconditional hour.
The estimate now states the figure it is actually measured against, and where the published rate card disagrees it names the size and direction of the gap and says the two have not been reconciled. Three answers, never two: demurrage that is not charged at all gets no line, because it has no allowance to state; a figure that cannot be read says so in those words and that it is not an allowance of nothing; a figure that reads is stated. Where the rate card holds no free-hours figure the comparison is dropped rather than defaulted to an hour — an assumed hour would manufacture the very agreement the sentence exists to check.
The promise itself is unchanged. Reconciling 30 against 60 is a commercial decision — 60 gives away wait time the business bills today, 30 in the quote changes what customers are promised — and a composer does not take it. What changed is that the document is no longer silent about the disagreement.
it.offered = true, pushed a row onto a module-local
const backloads=[], raised a green “Backload listed”
toast and returned nothing — and the card then reported the leg as
“already listed as a backload”. Measured on this tree by driving
the real NF.Store.save() and reading the cache back out: the flag
lives inside NF.schedule, which snapshot() emits and the
five‑second autosave PUTs, so it persists for every dispatcher; while
backloads was pushed to by that one line and read by nothing
anywhere — not a screen, not snapshot(), not the backend
whitelist — so it died on reload. The suppression persisted and the
listing did not, which is the wrong half of the pair and the opposite of
what docs/decisions/255-backload-readiness.md §8.3 recorded
(“persists nothing”). So a sellable empty return left the one card
that surfaces them, permanently, on the evidence of an unattributed, undated
boolean, and a positive operational claim was made about a listing that exists in
no store, on no screen, in no feed and in nobody’s inbox — #604’s
own defect one click downstream of it..ui-indet hatch sits on the
mark, because the outcome of the mark is the thing nobody knows — two
textures, three inches apart, for two different facts. Each marked leg is named
with both ends of its return and a stamp, capped at six and saying so when it
caps; a mark made before this shipped carries no stamp and reads
“no time recorded” hatched rather than being dated to now; and
offeredBy is left absent where no verified identity exists
— the shipped default, since config.js ships
sso: null — so the card says “nobody is
recorded” rather than a name nobody typed.{ok, error, message}, printed in place
on the card rather than in a toast that slides away. It refuses three ways in
three different sentences — a run line no longer on the board, a leg moved
since the draw, and a second press on a leg already marked, which is refused
rather than silently re‑stamping who marked it and when — and none of
them throws. It stamps offeredAt and saves through
NF.Store.save(), never a second queue. backloads is gone
from both screens rather than repaired, because a store that only looks like one
is the illusion the card’s claim rested on; giving a mark a real destination
is a new collection and a backend deploy, and is not built here.
Verified to fail, measured off each runner’s own summary line and never
estimated. Restoring the old writer and the old wording wholesale reddens
13 of 471 checks in tests/manual/engine-room.js — and
crashes tests/run.js rather than reddening it, on
ReferenceError: toast is not defined, which is itself part of the
finding: the old shape could not be driven outside a browser at all, and a crash
is not a measurement. So the node half is measured in six narrower breaks, each
sum‑checked against the 330‑assertion baseline of
tests/suites/85-empty-legs-honesty.js: the false wording 17,
the three refusals turned back into silent successes 12, the named panel
7, the offeredAt stamp 6, the hatch on the mark
4, and the NF.Store.save() call 4. On the full
unfiltered run the wording break is 17 again (25,961 passed, 17 FAILED
against a 25,978 baseline), so nothing outside this suite depended on the old
sentence. Removing the hatch reddens only 2 in the browser, and both are
computed background-image reads rather than class‑name checks,
which is the whole reason the claim is taken off the rendering.$0.00 · $0.00 · $0.00 · —,
where the first three are a measured nothing and the fourth is an unknown, and
the only thing telling them apart was the colour of a glyph. On a wall board on
the thirty-first that reads as the gap is nil. Every tile whose figure
this app could not produce — the gap with no target, a weighting with too
few decided quotes, a stage holding loads nobody could price — now carries
the same hatch the rest of the app uses for “we do not know”, and a
measured $0.00 deliberately does not. (#261)none: the rule sat in
the pipeline’s own stylesheet but the shared panel style loads after it and
sets the background shorthand, which resets the pattern — and
it was a two-per-cent tint in any case, roughly a 0.4% step against a white card.
The browser suite asserted that the panel existed, what it said and how big its
type was, and never once how it was drawn. That is the same shape as the yard
cameras’ hatch (#99). It is now the house pattern, identical to the tile
above it, and the suite compares the two computed backgrounds rather than the two
class names — so removing the stylesheet reddens four checks instead of
passing. (#261)day,
because ensureJobDay() stamps today onto any run line without
one, on every client at load — so two browsers open on different days would
have put the same money in different months. No state stamp is used either:
acceptedAt says when we took the order, not when it runs, and work
booked in August for September would have landed in August. (#261)$0.00,
records that exist and none could be priced (never 0, and it says in
words not to total it as one), a day nobody recorded, and a date that was recorded
and could not be read. An unset monthly target exports as NOT SET
— never 0, never blank — and the gap to it is declined
rather than computed against a target nobody has set. (#261)backend/import-odoo.js is the
route issue #267 names — run it, then record the numbers in the issue with
the date — and against the live API it printed 0 vehicles read / 0
matched / 0 in Odoo, not in the register / 0 ambiguous. Four true lines
that together read as a match rate of zero, over a fleet nobody can currently
see. GET /odoo/assets has stamped meta.verdict and
meta.total since #577 and the register screen has read them since
#641; this script read meta.available alone, so the packaged
measurement route was the last surface reporting a bare count for an unreadable
population. It now prints the figure with the denominator it is a count of, and
tells the three states apart: we looked and here they are, we looked
and Odoo holds none — counted, archived included and we could not
look. Re-measured live on 12 September 2026, unchanged in eight days:
verdict: "none-visible", total 0, archived-inclusive total
0, on all three models. That is UNKNOWN, and the report says so.--write now refuses an empty Odoo read, in both of its
flavours. The script has always refused to merge into an empty register
(“refusing to merge into nothing”) and had no counterpart on
the other side, so it would happily merge nothing into something: a
whole-document last-writer-wins PUT that races every open tab and, through
mergeOdooServices([]), deletes every imported service record on the
strength of an absence and reports the deletion as 0 service
records. A dry run now names that removal before anybody passes
--write, and a read carrying no vehicles refuses the write —
naming which empty it is, because a counted zero and an unreadable fleet have
different owners.NF.odooIdParts
splitting Odoo's composite name
(NF835/Mercedes/Rigid/1HOT690) and looking up the segments carrying
a digit. No test anywhere can see a change to that format, because no test talks
to Odoo, and the failure it produces is silent: every vehicle reports as
“in Odoo, not in the register”, which is a claim about the
register. The report now names the cliff where somebody would work the list,
prints the first name exactly as it arrived, and leaves the list itself intact
— a caveat, not a suppression.The dispatch board now says when it could not check whether somebody else had edited a run. Since the two-schedulers fix shipped, a save that landed on somebody else's change has raised the "Somebody else was editing this too" notice. What was never drawn is the third answer: the database could not compare the records at all — the row read failed, or one save carried more records than it will check in one go — and it has been saying so on every such reply, in a field nothing in the app read. Measured on 12 September 2026 by driving the real API: one transient database error, three run lines, all three genuinely written over, and the board stayed silent — identical to a clean save. It now says so, in its own hatched notice that names which part of the board it could not check, never claims anybody was written over, and does not auto-hide on the next autosave.
c.legs, and the error codes, CSS classes, DOM ids and data
attributes are untouched. A screen may store a leg; it may not print one.NF.legPrice().note said "the legs never carry their own"
and was rendered by nothing — a dead string, confirmed by walking every reader
of it and of legConnote's priceNote re-export across
the whole tree. Beside it the con note restated the same rule twice more, in
two further wordings free to drift from the model and from each other. Both
screens now render the model's own sentence, so there is one wording of one
rule. For a split part that is a correctness gain as well: the sheet
used to claim "one price for the whole job" flatly, where the model names the
parent con note and says the figure must not be summed across the family.Leg on the line after a heading counting “3
components”; the consignment detail panel said “3 legs,
allocated per leg” directly above the panel headed “⇄
Components — 3”; and the load & dimension sentence the
component sheet prints — which has exactly one renderer — said
“This leg's driver has not filed…”. All of it now
says component. (#277)legs, and that is the point.
Nothing in the model, no id, no storage key and no collection name changed:
renaming c.legs is a rewrite pass over the live database plus a
dual-read shim across a dozen readers for zero behaviour change, and this
repository has no migration runner. So the screens say component, the record
stores legs, and the Components panel goes on naming the field in a
<code> span so a grep for components is not a dead
end — the same call this repo already made in legsPanel. What
is no longer allowed is one screen saying both, and two paired guards hold it:
one over the screens' own source in tests/run.js, one over the
rendering in tests/manual/locations.js. The pair earned its keep
immediately — the rendering half read innerText, which
reflects text-transform, so a case-sensitive matcher was green over
a <th>Leg</th> that reads back as LEG, and
only the source half caught it. (#277)PU / LH / DEL chips read
c.legs[i].covers first and fall back to the chain's geometry, and
the tick-boxes that write it had landed only on the con note create flow.
Measured on the sample data: 50 con notes, 55 component rows, and
none of them reachable from any screen in any of the three apps, because
the only writer path ran on a draft before a record existed — and
“Back to derived” had no caller anywhere. The same editor is now on
the schedule board's run editor, aimed at the component the open run is, so a
scheduler correcting somebody else's con note has somewhere to do it. Zac's case
— “if he's just doing a pickup, you can just tick pickup
only” — is one a single-component con note can now state, which
the geometry never could: one leg always derives all three phases, and 46 of the
50 con notes on file have one leg.#/consignment/<id>), one
editor per component. They had exactly two homes before and both were aimed at a
component that already has a run — the create form writes the first component of
a draft, and the board's run editor is aimed at the component the open run
is — so a component carrying no run on the board could not be reached
from any screen in any of the three apps, and a multi-component con note whose
run matched none of its components was told by that very panel to "open the con
note to set coverage on a component by name", where there was nothing to tick.
It is the same shared editor and the same one writer, drawn as a list; no second
copy of the rule was written.scCoversSet/scCoversClear — the writers
behind the con note's pickup · line haul · drop off tick-boxes — lived
inside js/screens/07-schedule.js, where another dispatcher screen
could reach them only because the screen files share one closure, and the client
portal and the driver PWA could not reach them at all. They are now
NF.Components.setCovers / clearCovers /
coversReadiness in js/data.js, beside
handOver and planLegs, which is where
docs/modules/traffic-light-chips.md has said they belong since 3
September. Nothing about what covers means has changed:
NF.legCovers is untouched, the derivation is still asked of
the model rather than restated, and the screen keeps only what draws the panel.tests/suites/56-component-covers.js, and removing the empty-set
refusal reddens 7.seq,
or a seq-less entry taking a position number another entry declares — cannot say
which component a write means. That is a third answer,
ambiguous_component, distinct from "that con note is not on file"
and from "that number is past the end of the chain"; the read reports it too, so
a panel can draw it rather than pick one silently. Collapsing it back into the
"not there" refusal reddens 10.A scheduler can now record freight being moved between trailers in the yard, and see every move that has been recorded. Forklifts and ramps reorganise loads at a depot, so freight often leaves on a different trailer from the one it arrived on. The board could already read such a move — it is the strongest answer to "which trailer is this freight on" — but nothing anywhere let anybody record one, so the answer could never change. The schedule board's run editor now carries a Move this freight onto another trailer panel beside the existing trailer controls: it records the destination trailer, the yard, who moved it and what moved, and lists every move already on file. It is not a hand-over — nobody's component changes hands, no driver loses credit, and the board's own allocation is left exactly as it is, which the confirmation says in words. A move is appended, never edited, there is deliberately no un-do, and a correction is a new entry that supersedes the old one; the superseded entry stays on file, dimmed and struck with both trailers still readable.
What the freight came off is recorded only when somebody actually stamped it. A trailer that is merely allocated to a run is a plan — the normal state of the board, days before anything is loaded — and a road train cannot say which of its two or three trailers a given con note is on. Both of those are drawn hatched, and the move records nothing for where the freight came from rather than copying a plan in as though somebody had seen it. The form says which of the three it is looking at before anybody presses the button. A move naming no destination trailer is refused by name, because it would be filed, reported as recorded, and then read by nobody; a move with no yard named is recorded and the yard is reported as unrecorded, because refusing the whole record to extract a place name would lose the fact that matters.
NF.Trailers.observe with via:'driver' — the same
writer the schedule board uses, so the two cannot word one trailer two ways. It
rides out inside the ordinary whole-document save, which is the one queue the
POD already takes: no second transport, no second retry timer and no second ack.
(#282)active:false with who withdrew it and when, so what the board was
told at the time is still readable; it is drawn dimmed and struck with the
number still named, never hatched and never blank. The control is offered on
the driver’s own readings only — withdrawing what a scheduler was
told by somebody else is a different act with a phone call behind it, and the
board already has that control. A newer reading is always appended and is simply
the later fact. (#282)driver/driver.css and asserted on the computed
paint rather than the class name, because driver/index.html loads no
css/ stylesheet at all and a hatch borrowed from the dispatcher
would ship flat in the cab and pass CI. (#282)The interactive load plan — where each load rides on one trailer. Drag a con note
onto the deck at #/loadplan/<trailer>, or pick a bay from a list and press
the button; both do the same thing, so nothing here needs a drag. Reached from the trailer
panel on a run and from the manifest beside it, and the manifest now prints each load's
position in its own column.
It is the half of #283 that creates a deck position, which nothing in FleetView held
until now — measured before the work started: deckPos, onDeck and
loadPlan returned zero across every script and stylesheet in the three apps and
the backend. Where a load rides is stored on the con note and keyed by the trailer, so a
trailer hand-over keeps it (the freight does not move on the deck when the trailer
changes hands) and a re-home does not carry it across (a forklift has put the load
somewhere else, and nobody has drawn where yet). No new collection and no backend deploy.
Three answers, never two, on a sheet that is printed and carried out to a trailer: a plan
that could not be read is hatched, prints “not read” rather than
0, draws no deck and says it is not a statement that nothing has been laid out; a
trailer nobody has laid out is dimmed and dashed, and says the freight is still on it.
And every position is a plan: nothing records where freight physically sits, so a
placement is drawn in its own dashed mark and never in the manifest's “recorded”
ink.
The trailer manifest no longer says a deck position is unheld — it prints one, as a plan. The sheet used to state, correctly, that nothing in FleetView recorded where on the deck anything was riding. That stopped being true when the load plan landed. The wording is kept and marked on the sheet's own face rather than quietly swapped, because the old sheet was printed and carried out to trailers.
A printable manifest for one trailer, so nothing gets left behind in the
yard. The schedule board could already say which trailer a con note was on
and hand a whole trailer over to the next driver, but there was nowhere to see
what is on this trailer — the sheet the yard and the driver actually work
from, which today is drawn by hand and thrown away. Open it from the trailer
panel on a run, or go straight to #/manifest/<trailer>: one
line per con note, with the customer, the goods, both ends of the leg, the
driver, and a tick box to mark each one off as it comes off. It prints, and the
print drops the chrome and keeps the sheet.
Three things it deliberately will not do. It will not claim a trailer is empty when nobody could read the database — that case is drawn hatched, says in words that it is not a statement about the trailer, and leaves the counts blank rather than printing zero, because a sheet that says "nothing" for a read that never happened is how something gets left behind. A trailer we did read and found empty is drawn differently again, dimmed, and keeps the distinction the model already makes: a trailer allocated to a run is not the same as one somebody has loaded. And every line says whether the freight was recorded onto that trailer or is only allocated to the run — an allocation is a plan, most trailers are in that state for days before they are loaded, and printing a plan in a stamp's ink is how a manifest becomes a document that lies. An assumed line is marked, never dropped.
It is not the load plan, and the sheet says so on its own face: nothing in FleetView records where on the deck anything rides, so drawing an outline with the freight arranged on it would be inventing the one fact we do not have. That half is still to come. The sheet writes nothing — the tick boxes are printed glyphs on paper, not buttons.
c.items, the booked manifest, under that heading.
Nothing in FleetView records what actually came off the truck: NF.Odoo.recordPOD
writes a name, a signature, photos, a stamp and a condition tick, and no line at all, and the
tokens deliveredQty, pickedQty, actualQty,
qtyDelivered and qtyPicked return 0 across the shared layer, the
driver PWA and the portal. So on a partial delivery — “they picked up seven
vehicles but the con note said one vehicle” — the document an invoice rests on was
stating a count nobody had taken. One reader, NF.podItems, now heads the section
Goods booked and prints a hatched caveat saying the delivered quantities were not recorded
separately; a con note nobody itemised is drawn dimmed rather than hatched, because
we looked and there is no manifest is not we do not know.NF.splitManifest and worded as a gap in our records rather than as
freight that did not travel — an untagged line may well have travelled and nobody tagged it.
A con note that was never split reports null and says nothing about trailer loads it
does not have: before that guard, an ordinary unsplit con note (CN-24809) reported 2 of 2
lines unplaced against loads that do not exist. Per-item delivery capture is deliberately
not built — that is the itemisation depth still blocked on the granularity decision and on
OQ-13.A proof of delivery no longer changes whose it says it is when a run is re-crewed. The con note's "who has done what" trail attributed a POD to whoever was allocated to the run at the moment you looked rather than to whoever captured it, so moving a job onto another driver — the ordinary act of re-crewing a board — silently rewrote the attribution on an evidentiary record. Measured on the sample dataset: a POD signed at 08:55 read as Dev Sharma's, and one field change made the same POD read as Wayne Carter's, with nothing on the record altered and nothing on any screen saying so. That derivation is removed: a capture now names a person only where one was recorded at the time, and the flat trail no longer falls back onto the current allocation either. A time stamp names nobody and now says so rather than borrowing a name.
The cab records which driver captured a POD. The signed-on driver is stamped on the delivery at the point of capture, and the con note's Proof of delivery panel, the components trail and the driver's own docket all read it through one reader so they cannot disagree. Four answers, and two of them are not "nobody": a recorded capturer is named; a POD taken before this shipped carries no capturer and is drawn hatched saying in its own words that it is not a statement that nobody captured it; an id we hold and cannot place against the driver register is told apart from holding no id at all; and a delivery with no signature and no photograph has no capture to attribute. Nothing is backfilled — inventing a capturer for a POD already on file would be manufacturing evidence. What is stored is the driver's id and never their name, because a consignment record reaches a customer's browser whole and whether a customer may see a driver's name is a separate, undecided question.
A refusal to split a con note that already has work against it no longer prints the word "undefined". It read two field names the record has never carried, so a dispatcher was told A driver has already recorded "undefined" instead of what was actually captured. Found while removing the attribution above; every existing test stayed green because they all check the refusal's name rather than its sentence.
A con note now lists the drops it owes a signature for, and says how many have one. Following the decision of 18 September 2026 that a proof of delivery is captured per drop rather than once at the end of a run, the con note screen carries a per-drop ledger: one row per load on file, whether a signature was captured for it, who captured it, and when. On a booking split across several trailers this is the first time the dispatcher app has been able to answer “how many of these loads have been signed for” at all — the split head showed nothing, because a head does not deliver, and no part said anything about its siblings. The count is stated as a floor and never a ceiling: each record holds one proof of delivery and a second capture replaces the first, so a load delivered in more than one drop is counted once, and the screen says so. On a con note that is not split, how many drops there really were is drawn as something our records cannot answer rather than reported as one. A load that was withdrawn but still carries a signature is counted as a drop that happened, and named as withdrawn; one withdrawn with no signature is a load that never ran and is not listed. Who captured a signature is read from what the cab stamped at the moment of capture and is never worked out afterwards from whoever is allocated to the run now.
Marking a con note delivered on the board no longer prints a proof of delivery nobody took. Dragging a job card into the board's Delivered column sets the con note's status and nothing else — no signature, no recipient name, no image. The con note screen keyed its whole proof-of-delivery panel on that status and then drew a tick and the words “Signed on device” over a record holding no evidence at all, with the recipient shown as a dash. The same defect was corrected on the customer's own shipment page in an earlier release; this was the other copy of it. The panel now asks whether an artefact was actually captured, and a record carrying a recipient's name but no signature image says so in those words instead of claiming a signature was taken on a device.
Delivered quantities are named as not recorded, rather than left unsaid. Nothing in the system records what came off the truck line by line, so the new per-drop ledger counts signatures and never items, and says so on screen — naming the open decision it is waiting on, and stating that a drop with no signature is not a claim that nothing was delivered. This deliberately builds none of the per-line capture: the depth question (do we itemise to individual items, or track at con-note or trailer level) has not been answered.
active:true, and no quote path in the app could
reach it — NF.Rates.priceQuote takes a basis and a distance,
NF.Quotes.autoPrice forwards both, and the form a dispatcher quotes
from passed neither (measured on master at 35294f2f:
basis:, km:, nq-km and
nq-basis 0 in the screen file, against hours: 2). It
is #286's own “full trailer price per km … you won’t see
that in the quote”. The form now carries a Priced per select,
worded from the rate card’s own NF.Rates.BASIS_LABEL, and a
km box drawn only for per km; both the preview price and the save’s
re-price are handed the same ask, so a quote priced per km is stored as
asked per km. The select starts on Not asked, which is a third
state and not a default: it sends the pricer no basis at all, prices to
the cent what the form priced before, and is stored as nobody chose the
basis, hatched. Reading it as per load would not have been harmless
— measured, four hours inside one zone prices by the hour when nothing is
asked and is refused outright when per load is. Every refusal the pricer
returns (no distance was given, not a basis any rate is held
on) is printed on the form in the pricer’s own words, and the toast
that used to announce “N charge(s) could not be priced” is
gone, being a second copy of a refusal that slides away mid-read.-5 km, NF.Rates.priceQuote bills
Math.max(-5, 0) = 0 km, prices the cartage at nothing and reports
the quote complete — a silent nil charge, measured on the seeded
Perth → Pilbara lane. The new-quote form refuses a negative or unreadable
distance in place, quoting back what was typed, and prices nothing on it. The
pricer itself still accepts one (and a negative number of hours, by the same
Math.max); that is a js/data.js finding, reported
rather than fixed here.NF.Quotes.rateBasisStamp the save writes to
the record, so the preview and the stored quote cannot state the basis
differently.aeb7365f, that was the literal truth: quote.pricing carried
eighteen keys — tier, zones, vehClass, levy,
levyBasis and the rest — and not one of them was the rate basis. It was recoverable only by
reading it out of the prose in a line's via ("… · per hour"), which is not a field anything can reason
about. NF.Quotes.autoPrice now stamps quote.pricing.rateBasis beside the levy stamp it
already writes — three keys and no stored sentence, so the wording is composed from NF.Rates.BASIS_LABEL
at render time and there is only one copy of it.
The distinction that does the work is between a basis somebody ASKED for and one the rate card's fallback order
CHOSE, and today every quote in this app is the second: measured, zero call sites across
js/screens/, js/integrations/ and js/integrations.js pass a basis at all, so
NF.Rates.priceQuote's own attempt order (per load then per hour across zones, per hour within one) picks
it every time. Knowing which basis the money came off is not knowing that anybody agreed it, so the derived case is
drawn hatched on the shared derivation panel — the same panel the price check, the new-quote form and a
quote's own screen all use.
An ask we do not hold is refused by name rather than defaulted. NF.Rates.resolve coerces an
unrecognised basis to 'job' and prices it unpriced:false with nothing saying the ask was
dropped — measured, 'pallet', 'tonne', 'HR' and '' all answer
basis:'job', rate:9150 on the seeded Perth → Pilbara lane, and "the three-pallet minimum" is
#286's own phrase. The coercion itself is a js/data.js change and is not repaired here; what is repaired
is the record agreeing with the silence. The panel now prints the ask back in the asker's own word, names the three
bases that are held, and says what the cartage was struck on instead.
Four other states, and none of them is folded into another. Nothing priced is dimmed and dashed, never hatched
and never a basis of "per load" — we know exactly that no basis was struck. A vocabulary that could not be read
answers we could not look rather than calling the ask invalid, because a list that failed to load is not a
list the ask is missing from. And a quote priced before this shipped reads nobody has ever looked: it is
deliberately not derived from the rows the quote panel composes, because that composition hardcodes
basis:'job' on every non-percentage row and would confidently call an hourly quote a per-load one.
Removing the stamp reddens 27 assertions in tests/run.js and 13 checks in
tests/manual/rates.js; folding the refusal away reddens 3 and 5; and painting the hatch
flat with a background shorthand while leaving its class in the markup — the trap that ships a green
suite over a pill that says "we know this" about something we do not — reddens 3 browser checks, because
every rendering assertion here reads getComputedStyle(el).backgroundImage rather than a class name.<script> for leaflet.js and a <link> for
leaflet.css, each its own request, its own cache entry and, since the integrity
work, its own hash — and the Drive screen asked
typeof L === 'undefined', which is a question about one of them. Measured on a
1280×800 cab tablet with the stylesheet blocked and the script served: the guard
passed, L.map() ran, and the map drew nine tiles laid out wrong (every
position:absolute Leaflet relies on lives in that stylesheet) with the truck's
own dot and the dashed leg line absent from the picture and the "map unavailable"
note still hidden. A map-shaped thing with the driver's position not on it, and nothing
saying so. The cab now tests both halves — the stylesheet by effect, because a
sheet refused by an integrity check is still listed in document.styleSheets and
its rules are unreadable cross-origin — and refuses to draw a map it cannot position.
It is not a hypothetical. driver/sw.js precaches per URL under
Promise.allSettled, deliberately, so that one unreachable file cannot fail the
whole install; "script cached, stylesheet not" is therefore a state the worker is designed
to be able to reach, after which cacheFirst serves the script from cache on
every load while the stylesheet keeps failing — which on a tablet parked out of signal
is every load until it is next online.navigator.onLine === false is the one cause the cab can actually see and is
still named as such; where it is not false the note says what was observed
("the map library did not load", "the map's stylesheet did not load") and stops. Naming the
wrong cause sends a driver looking for coverage they already have.tests/manual/map-offline.js has said since v0.124.0 that "the driver PWA already
survived it (driver/sw.js precaches the library)", and asked the cab no
questions at all — a sentence that is only true of a warm tablet, since a
precache helps after one successful load and a new tablet handed to a driver whose first
shift starts underground has never had one. The dispatcher and the portal were driven cold;
the cab was not. It now is, in three states: no Leaflet at all, half of it, and both halves
present (so the absence checks cannot be satisfied by a cab that never draws a map). The
note is read as a rendering — computed display plus a real box
— and not as the absence of a hidden attribute, because
driver/index.html loads no css/ file at all, so a note whose rule
lives in the wrong stylesheet would pass an attribute check and be invisible in the cab. The
suite goes from 117 checks to 146. Verified to fail four ways: restoring the one-half guard
reddens 4 of 146, restoring the unconditional "no connection" wording 2,
removing only the stylesheet half of the test 3, and hiding the note in
driver.css while leaving its text intact 2.unpkg.com serves ~147 KB of executable code into the dispatcher (the
page that takes a password), the client portal (opened by a customer from an email) and the
driver PWA (a tablet bolted into a truck), and not one of the six tags carried a
integrity hash — so a compromised or substituted CDN response was executed
silently. All six now carry a sha384- hash and
crossorigin="anonymous", which turns a tampered file from executed into
refused. Both hashes were verified against the bytes unpkg actually serves rather
than copied from the brief, on five independent channels: a live fetch, unpkg's own
content-digest response header, the npm registry tarball from a different host
(cmp-identical for both files), that tarball's sha512 against npm's published
dist.integrity, and its sha1 against dist.shasum. This does
nothing about unpkg being unreachable — that is what the precache and the honest
"map unavailable" panel are for. This is interim hardening of the status quo, and it
settles nothing about where Leaflet should live. Whether to vendor the library into the
repository instead of loading it from a CDN is still an open decision and still Simon's;
this was chosen precisely because it is compatible with either answer, since vendoring would
replace these CDN URLs with same-origin paths and the integrity attributes would leave with
them rather than having to be unpicked.L is undefined and the app draws its
honest "map unavailable" panel — the same screen a genuine CDN outage produces, so the
failure looks like a network problem in the Pilbara rather than a typo. Measured: with the
hash wrong in all three apps and in the suite's own pinned constant, every static
check in the repository passes (ALL PASS, 24,593) while the browser suite reddens
13 of 117 — so the browser check is the only thing that can catch it, and it now runs
at the point where unpkg is dead and the library comes from the service worker's cache.
Two further measurements are recorded rather than assumed. Omitting
crossorigin breaks the first-ever visit only — the network load is
blocked, the next load is served from cache as a readable CORS response and succeeds — so it
is invisible to anyone who has already opened the page. And because the page now checks a
hash, the request mode both service workers use became load-bearing: they fetch in CORS
mode, so the cached copy stays verifiable, and a switch to mode:'no-cors' would
cache an opaque response and kill the map offline only, in a cab, with CI green.vendor/leaflet/1.9.4/ and served from our own origin.
170,256 bytes in the repository (leaflet.js 147,552 +
leaflet.css 14,806 + five images 6,503 + the licence 1,395), at most
50,438 bytes on the wire with the text gzipped, which is 1.80% of the
deployable site. Nothing about the honest "Map unavailable" panel changed: the file can
still fail to arrive — a file missed by the deploy's own sync, an edge error, a
proxy refusing a script — so every degrade path, and the cab's refusal to draw a map
it cannot position, is exactly as it was.integrity hashes left with the CDN, and their evidentiary value was
kept rather than discarded. Subresource Integrity pins bytes fetched from somebody
else; a same-origin file arrives over the same connection as the page that asked for it,
from the bucket our own deploy wrote, so there is no second party left to pin against
— and a stale hash on a file we serve ourselves fails closed on the next
legitimate Leaflet upgrade, which on screen is indistinguishable from the file being
missing. Their absence is now asserted rather than merely allowed. The two
sha384 values themselves are not thrown away: the committed bytes are hashed
against them on every test run, so the vendored copy is proved byte-identical to what
unpkg actually served — which matters because committing 170 KB of third-party
code makes a reviewer the only thing between the repository and a modified library, and
nobody reads a 147 KB minified diff. Those hashes were verified five independent ways when
they shipped and every browser has been enforcing them since; they now guard the diff
instead of the wire. The five images and the licence get their own digests for the first
time, measured off the npm tarball whose own sha1 and sha512 matched npm's published
dist.shasum and dist.integrity. The three marker PNGs are the ones
that matter: leaflet.css fetches them at runtime from a relative path and from
no tag at all, so integrity is an attribute with nowhere to live and nothing
has ever checked them anywhere. That was the residual this issue stayed open for.vendor/leaflet/leaflet.js, and taking that literally would have
introduced a silent defect: /sw.js caches its allow-list cache-first under a
constant cache name that no deploy busts, and its own header rests on the URL pinning a
version — "a cached copy is the same bytes forever". That sentence is true of
unpkg.com/leaflet@1.9.4/ and of vendor/leaflet/1.9.4/, and false
of a versionless path, where an upgrade would leave every browser that ever loaded the old
one serving it out of that cache with nothing able to replace it. So the brief's
"keep sw.js with local paths — a no-op that costs nothing" is
not a no-op, and the path carries the version instead. Upgrading Leaflet is now a
new directory and six href/src edits that cannot half-apply.tests/manual/map-offline.js made its whole cold phase work by blocking the
unpkg.com host. Vendored, that block proves nothing — the library
loads from our own origin regardless, and every degrade assertion behind it would pass
vacuously while testing nothing, which is the worst available failure of a test. The block
moved from a host to the vendored paths, which is a different and more likely failure
(a file missed by aws s3 sync, an edge error, a proxy refusing the script) and
not a weaker one. The suite goes from 146 checks to 200, and because #572 had
already scoped the harness's Leaflet routes to the CDN host rather than to
**/leaflet.js, the vendored path is fetched from the test server — so
these suites exercise the bytes the deploy will publish instead of a devDependency
copy that could drift from them. Measured against a baseline of ALL PASS — 31,910
assertions, 144 of 144 suite files and all 200 browser checks, and reconciled
back to both afterwards. Verified to fail five ways: putting one tag back on the CDN
reddens 6 in node tests/run.js and 4 in
map-offline.js; a versionless vendored path in sw.js reddens
10; flattening the images/ directory 24; renaming the licence to
LICENSE.md — which the deploy's --exclude "*.md" would
silently keep out of the bucket, the one place the redistribution we are obliged to make
actually happens — 9; and restoring the old unpkg.com host block
reddens 64 of 200 browser checks, which is the measurement showing that leaving that
block in place would have left two-thirds of this suite asserting nothing.images/, and renaming the licence, both made an unguarded
fs.readFileSync throw — on a file whose presence is the very thing under
test. The runner scored the whole suite as "suite did not finish" and printed
neither ALL PASS nor N passed, M FAILED, so two real
breaks produced no verdict at all: a guard that cannot be verified to fail is not
yet a guard, and a crash reads on screen as a smaller event than the defect it is hiding.
Every read in the suite is now guarded so a missing file is a red that names itself. The
same two breaks then reddened 24 and 9.unpkg.com
reddened tests/manual/map-offline.js by 2 of 200 and
node tests/run.js by nothing at all — and CI shards the browser
suites separately, so npm test alone was green over a panel sending somebody to
ring a third party about a file we serve ourselves, on the one screen opened because
the map is missing. The existing CDN matcher could not see it and is right not to: it
requires //…leaflet… because it is a claim about where a page
loads the library from, and prose naming a CDN loads nothing. Two different claims,
one guard. The new group in tests/suites/131-vendored-leaflet.js asserts both
panel branches name vendor/leaflet/1.9.4/ and that neither blames a CDN, reading
strip() and never code() because the panel text is a string
literal, which code() blanks — so asked of code() the whole
group would pass vacuously for ever. Two positive controls run first, since every assertion
under them is an absence. That break now reddens 2.02fa5611 — this branch merged with origin/master at
adc095c2, a tree whose vendor/leaflet/1.9.4/ and all six load sites
are byte-identical to what landed on master as df0f6be4 — the baseline is
ALL PASS — 32,415 assertions, 149 of 149 suite files, with npm run checks (18 scripts), npm run wiring,
the 78 backend suites (13,900 assertions across 77 of 78, the one uncounted suite reported as
unknown rather than as zero) and map-offline.js (all 200) green.
Re-measured, each reconciling exactly against that baseline: pointing index.html's
stylesheet tag back at the CDN reddens 7 in node tests/run.js (5 in
78-marker-precache.js, 2 in 131-vendored-leaflet.js) and
17 of 200 in tests/manual/map-offline.js; deleting
vendor/leaflet/1.9.4/images/marker-shadow.png reddens 4; and the panel
break above reddens 2 in node tests/run.js and 3 of 200 in
map-offline.js.
⚠ ONE FIGURE CARRIED HERE WAS WRONG, AND IT IS CORRECTED RATHER THAN SWAPPED OUT.
The one-character typo in driver/sw.js's precache path was carried as 2,
and at this base it is not 2 and is not one number at all: which character is typed
decides it, and the entry never said which. Typing the VERSION segment
(1.9.4 → 1.9.5) reddens 5, because it trips the
version-pinning assertions as well as the precache ones; typing the FILENAME
(leaflet.css → leaflet.cs) reddens 3. Both reconcile
exactly against the baseline (32,410 + 5 and 32,412 + 3), and both were confirmed in the
restored state as well as the broken one. The direction of the finding is unchanged and is
stronger than the retired number claimed — a typo'd precache path is caught, by
more assertions than were said, not fewer. What the retired figure actually records is a
break named by its SHAPE rather than by its exact text, which is not reproducible: the same
class of defect as a figure carried over from another commit.
All three of those breaks are caught by a second gate too —
tests/precache-check.js exits 1 and names the missing file and why it is
silent, reporting 1 problem for the deleted marker and 2 for the version typo (the path
loaded but not precached, and the path precached but not on disk):
"install() swallows a 404 per URL, so it is cached by nothing and the failure is
completely silent until a driver is offline."
One instrument correction worth carrying: de-duplicating reds with
grep '✗' | sort -u | wc -l returns 14 where the truth is 7. The runner
prints each failure twice and the two copies are textually different — the
grouped-summary copy carries a Group → prefix — so sort -u
does not collapse them and neither does stripping that prefix. The runner's own
N passed, M FAILED line is the only count that reconciles.
⚠ AND CITE THE MECHANISM, NOT THE SHA. This work was SQUASH-merged onto master as
df0f6be4, so every branch commit the paragraphs above anchor to —
ca7fd3e0, e25a9882, 02fa5611 — is unreachable from
master's history and git show on any of them fails for anybody reading this after
the merge. The shas are kept because they are what the measurements were taken at and a
correction that deletes what it corrects leaves the next reader unable to tell a fixed claim
from one nobody ever made; what makes them re-derivable is the file state, not the commit id.
Re-take any figure here by applying the named break with node tools/break.js and
reading the runner's own verdict line — never by trusting a sha in this file.deploy-backend.yml is run by
hand). The Clients form’s note that still warns about the old wording is now out of
date and is being handled separately.#/client/<id>)
told the dispatcher that, for a client with a rate agreed and no free allowance,
the quote prints “your account terms: first N minutes free” and so
passes off our standard allowance as the client’s own. The quote letter was
corrected on 27 Sep 2026 and no longer does this, so the warning had become false.
The note now says, for each figure, how the quote will describe it: a figure the
client negotiated as their own account terms, and anything else as our standard.
Checked on test accounts with our standard at 30 minutes and $165/hr. A client
with only a $145/hr rate is now told that the quote calls the $145/hr rate their
own account terms and states the 30-minute free allowance as our standard. A
client with only a 45-minute allowance is told that the quote calls the
45 minutes theirs and states the $165/hr rate as our standard. A client with
both is told that the quote calls both theirs. In each case the note agrees with
the quote letter on which figure is whose. The note gets this from the same
readings the quote itself uses, not from a sentence of its own, so it describes
the unsaved figures in the boxes as you type and it cannot drift from the quote
again without a regression test failing. Nothing on the quote or the invoice
changes. This is a change to the dispatcher’s screen only, and it is live as
soon as it merges.NF.Charges.allowanceFor('detention') answered 60 min off the
published card while NF.Demurrage.termsFor() measured a wait against
30. Measured on the shipped customer book, 10 of 12 accounts were on a
figure nobody had reconciled with the one on the published card, and the two that
agreed were settled by their own contract rather than by anything checking. The
charge card asserted the opposite in two rendered sentences — “the
same rate NF.Demurrage bills a client wait at, so there is one
detention rate rather than two”, printed once per vehicle combination,
and “they are held in one place”. Both are retired, quoted
inside their own correction rather than deleted, and the rendered text now names
the other reader. (#315)NF.CHARGE_ALLOWANCE_KINDS gains unreconciled beside
undefined, because nobody has defined one and two people
have and they do not match need opposite acts from different people, and one
sentence for both sends somebody to define a figure that is already defined twice.
It carries both figures, the gap and which way it runs, is drawn hatched,
and chargeable()/price() refuse by the name
allowance_unreconciled — with allowance_unreadable
kept separate, since a claim engine we could not read is not a finding that the two
agree. No number was picked: which of 60 and 30 moves is #315’s open
item and is Simon’s with the GM. Nothing anybody is billed changes today
— NF.Charges has no caller on any invoice path. (#315)allowanceFor takes an optional {customer, consignment}
and asks NF.Demurrage rather than holding a second opinion, so a
client who negotiated 45 minutes is measured against 45 by both modules instead of
against the card’s hour — an agreement we already held and were not
honouring. Beside it, allowanceAccounts() answers in names and
counts, never a percentage: which accounts are settled by a contract of their
own, which are left on the unreconciled pair, and which could not be read at all,
with an empty customer book named as an empty rather than printed as everybody
agreeing. (#315)#/client/<id> gains a Demurrage section — free waiting time,
hourly rate, and whether demurrage is charged to this client at all. The model has
preferred these three over the organisation figures since demurrage was rebuilt, and
no screen rendered them: Coates’ 60 minutes at $145/hr and Water
Corporation’s 45 minutes were values in the code that only an engineer could change,
on the terms that decide what a client is billed for waiting. An empty box means
nobody has negotiated one and a claim then names the organisation figure it fell
back to; a typed 0 is a real negotiated term and is kept as typed.a632d69f, 25 commits on: the four buckets and
all 33 memberships are unchanged, every issue is still open, and
six individual measurements moved — which is the finding,
because a currency check that only re-derived the buckets would have reported
that nothing had changed over a document that had gone wrong about the work in
four of them. The Leaflet tags now carry sha384 (0 → 6);
NF.Quotes.recordOutcome went from zero callers to a
real Outcome panel, so the brief calling it “shipped” was true of the
writer and false of the feature; FR-AO-05 is now enforced at both
server-side send paths; and the 26 remaining contrast pairs are
0 of 256 scored. Two claims were wrong when written rather than
stale: the retention policy’s line count, and
FR-CH-11’s “the three states already exist on
the record and no screen projects them”, which is wrong in both
halves — the raised/waived/disputed states are on the
demurrage claim and three screens do project them, while
NF.Charges holds no record at all, so that slice is not a screen.
The new section on de-confliction records the measurement that matters:
zero of the last 40 master commits name a wrong issue,
four name no issue at all, and the two proxies a tool could read agree
with each other by construction — both record the issue the work was
dispatched under, never the issue whose gap it closed — so the
mis-numbering rate cannot be measured from this repository, and the only
instrument that works is grepping master for the caller of the
mechanism. Also: the seventeenth day-independence occurrence is on the register
(tests/manual/stop-rule.js, where the cause was the picked
job’s status and not the day), and six browser-suite check counts
across two module docs were re-derived, four of which had gone stale together.
(#320)docs/requirements/ was written on 21 Aug 2026 as the
base for "rebuilding FleetView as a production system", and the decision
it rested on — matrix requirement FR-DV-01, "treat the existing
prototype as a technology demonstration only, and build the production platform
separately" — has been retired. FleetView is the production
platform. The id itself was already gone (0 occurrences repo-wide,
which is why this read as done to a grep), while the premise
wrapped around it was in 10 of the folder's 11 files: 52
occurrences of rebuild on 46 lines, of which 45 were the
premise and are rewritten and 7 were ordinary English about rebuilding a
thing — a habit, a customer record, the asset register, the
disposal-analysis feature — and are deliberately untouched, because rewriting
them would change what three requirements, a problem statement and an open
decision say. Seven requirement statements had their subject
re-baselined with no id, priority, status or meaning changed; the count
check still reports the same 629 / 117 / 25. The new
01-rebuild-premise-retired.md carries the decision record: what is
retired, what is read as intent, and the matrix-to-in-repo citations
(FR-DB-17 → PM-505/TR-090/XP-911,
FR-DB-27 → DP-870–DP-879, and
eight more) so traceability runs both ways.FR-DB-* retirements from "DynamoDB single-table", and
#415 landed the next day (3b7cd5c2, 23 Aug 2026). Measured at
57864669: 29 tables across backend/sql/, so
FR-DB-02 (host on RDS or Aurora) is simply met;
FR-DB-01 moves to partial, because the tables exist and the
constraints do not — 0 REFERENCES, 0
UNIQUE and 0 CHECK across both DDL files, with
the schema recording the opposite decision in its own comment
(001_business.sql:94-96); FR-DB-06 is 18 of 50
collections holding their own table and 32 still sharing
app_singleton; FR-DB-25 moves from impossible to
buildable and not built (0 replicas in the template); and
FR-DB-29/30 becomes specifiable rather than met, because
apply-ddl.js says in its own first sentence that there is no
migration runner and refuses any statement that is not
CREATE … IF NOT EXISTS. Every original verdict is kept as the
verdict it was and marked FALSE-since-#415 beside what is measured now —
the ground moving is deliberately not written down as the requirement being
met, and for four of the six it is not. OQ-27's RTO/RPO stays
open; a decision record transcribes a decision and does not take one.Status column. The 520-row matrix is
not in this repository — 0 tracked .xlsx/.docx,
and neither its filename nor its own figures occur anywhere in
docs/ — so this is we could not look and is written down in
those words, with a five-step list of what somebody holding the file needs to
do, including which six rows to take from the decision record instead of from
the issue body. Nothing in the email-corpus evidence is touched — 126
emails, 48 measured findings, the fuel-levy dispute, the permit obtained for
the wrong dimension — nor are the 117 invariants, the operator quotations or
the 25 open decisions. Only the premise wrapped around them.E1062 was a field on a
quote thread, so an enquiry that never became a quote was counted nowhere and
one enquiry could not deliberately carry several quotes. The register is one row
per issued reference, carrying its own subject, customer and state, and
reading it back needs no job at all. Which quotes belong to it is still read off
the threads themselves — one fact in one place, so nothing can disagree
about it. (#322)E1062 on paper, so a merge never re-issues or
rewrites it: the losing enquiry stays on file for ever and its number goes on
resolving, to the enquiry it was merged into, naming who merged it, when and
why. #/enquiry/E1062 says so in place. Splitting every
thread off an enquiry is refused by name, because that is a renumber rather than
a split. (#324, FR-ER-06)#/enquiry/<ref> is addressed by the number a
customer reads out over the phone, and after a merge no quote thread carries the
losing one — so the page answered “a reference we have never
issued” over a number that had been quoted and then redirected, on the
one screen somebody opens because a customer rang about it. It now asks
the enquiry register first and answers six ways, with a redirect, a redirect
loop, an ambiguous reference and a read that failed each getting their own words
instead of falling into one negative. (#322, #324)FR-ER-08)NF.Jobs.byEnquiryRef was a first-match
.find() over NF.tmsJobs. With one reference per thread
that is correct and fast — which is every record in the live database
— so the honest statement is that a duplicate was undetectable and
unreportable, not that the wrong thread was returned. Worse,
NF.Desk’s threadIndex() was the mirror image
three thousand lines away in the same file: byRef.set(k, j)
inside the loop, i.e. last-wins. With one holder the two agree; with two
they named different threads, and each named whichever this client’s own
merge order put at that end — apply() merges by id and
appends what it has not seen — so two browsers could disagree about one
reference with nothing on either screen saying so, which is #542 on a number a
customer reads out over the phone. NF.Jobs.resolveEnquiryRef() is
now the one reader and answers three ways: none, one, and
more than one, and here they are by id. The third is asserted not to
collapse into either neighbour — it carries no job, so it
cannot read as one, and a non-empty ids, so it cannot read as
none. A fourth, unreadable, is about the reader rather than the
data and carries count: null and never 0: the matcher
under it is deliberately unguarded, because a tmsSafe with an
[] fallback turns a read that threw into “nothing carries
it”. Measured on tests/run.js, off its own N passed, M FAILED
line, with every run reconciling to the same 25,869 total so none of them
aborted: making the reader pick the first match again reddens 19, and
re-wrapping the matcher in its own tmsSafe([]) reddens 3.NF.Jobs.byEnquiryRef is two-valued and therefore cannot carry the
third answer, so it returns nothing where two threads hold a reference
rather than whichever sorted first: a two-valued function cannot tell none
from many, so the honest move is to declare the narrowing instead of
picking silently. It is unchanged for every record that exists.
parseNumber reports the ambiguity by name with both thread ids, and
the half that matters is that it no longer falls through to the digits
— that answer is J-<digits>, a real, live, unrelated
thread that does not hold the reference at all, which is the failure its own
comment is about; removing that branch reddens 6. It still answers
stage: 'enquiry', so NF.poOf cannot start
invoicing a shared reference as a customer’s purchase order. Matches are
totally ordered by id rather than left in scan order (reverting that reddens
2), so the answer is a pure function of the data and not of which browser
is asking. Putting the desk’s index back to last-wins reddens 3 and
making the ticket never report a reference as shared reddens 2; deleting
the queue chip or the ticket banner reddens 1 each. The enquiry desk still has to draw a ticket for the mail that arrived,
so it does choose — the lowest id, a total order every client agrees
on — and every ticket carries the other threads by id and says which one it
is showing. #/desk draws a hatched “On N threads”
chip beside the reference and #/enquiry/<ref>, a screen
addressed by the reference, prints the model’s own sentence above
everything derived from the thread that was chosen. Hatched, not dimmed:
we know exactly that two threads hold it and we do not know which one the
customer means, so it is an unknown rather than a known negative.NF.Jobs.create({enquiryRef}) honours a caller-supplied reference
precisely so increment 2b can hand several threads one enquiry’s
reference with nothing renumbered — that is what the stored-not-derived
decision exists for. A uniqueness constraint here would refuse the feature the
field was built for, and making an existing write start failing is a migration
rather than a slice. So a third thread on one reference is still created and the
reader simply reports three, asserted so it stays a decision. No collection, no
promoted column, no whitelist entry and no backend deploy: the whole
change is a derivation over tmsJob# records the backend already
stores, and grep -ci enquiry backend/src/collections.js
is still 0.Every enquiry reference the app has issued is now a list you can open. The enquiry desk shows tickets, and an enquiry that has not become a quote yet does not have one — so until now the register was reachable only by typing an enquiry's own web address. Enquiry register on the desk opens it: every reference, its state, how many quotes are on it, the customer's own numbers and when it was opened, with merged references listed rather than hidden because the number on a customer's paperwork goes on working for ever. When there is nothing to show the page says which nothing it is — we looked and there is none, we have never looked, or we could not — and prints a dash rather than a nought for any figure nothing was counted for.
A customer's own reference numbers are now kept on the enquiry, and there can be several. A hire contract up front, a works order from site and a purchase order a week later are three references for one job, and that is ordinary. Each is stored exactly as the customer wrote it and compared with its spacing and punctuation ignored, so the same number in a different spelling is refused as a duplicate rather than recorded twice. Where nobody said what kind of reference it is, the app says so instead of calling it a purchase order — a value starting “PO” is not read as one. One order covering several enquiries is allowed and the other enquiries holding it are named. Nothing is ever deleted: a number quoted at us and then withdrawn is kept as retracted, because that is a different fact from one never quoted, and an invoice query six months later turns on which.
The search box at the top of the screen now finds what a customer wrote, not only a number. It reached connote ids, quote ids, purchase orders, customer names and routes — all structured numbers — while the whole pre-booking conversation is held together by a subject line somebody typed. Measured on the real email corpora this reference system was built against: 29 con notes appear and not one quote or enquiry number. The box now also searches enquiry subjects, the customer's own references on an enquiry, and the subject and body of every message that has come in, and each result says where the match was — so a row whose words you cannot see never appears without a reason. A message the app could not attach to an enquiry links to the desk rather than to a reference invented to make the link tidy.
quarantine occurred 0 times across js/screens/.
The reply card on #/enquiry/<ref> now draws the check's own
sentence directly above the message box. Each answer is drawn its own way, and so
is the case where there is no answer at all:
*include dropped pin for the delivery
address, (list per combination) — and never content for
the customer. The analysis of Northfleet's two real mail corpora measured
49 emails carrying one, with all 24 Perth quote emails leaking
at least one, and one of those was pasted back into a BHP thread by a peer
carrier. Every one of them was composed and released by a human, which
is exactly why the approval step never caught them. There is now a catcher
between the parser and anything a reviewer or a customer sees
(NF.Intake.quarantine), and the comment in js/data.js
that has named this as a missing mechanism since the intake was built no longer
describes the code. (#323)fleetview@ is quote traffic
and is deliberately read in as such — which means the automation would
otherwise draft a reply built on our own reading of the job and send it
to the customer we had just sent it to. It is now declined by name, before any
enquiry, reference or trail entry is minted, and the refusal says what the
message is. Where one of our notes turns up in a message the customer
sent — the peer-carrier case — the enquiry is still drafted and only
the words are held back: refusing that would refuse a customer's job because of
our mistake.The enquiry desk now asks "is this the right client?" when it cannot tell how a message's client was found. A message can carry a client with nothing recording how that client was chosen. The ticket treated that as if a person had picked the client, so it skipped the "right client?" question that a client guessed from the sender's email domain gets. The details panel also said a person had chosen the client. Measured on the desk before the change: such a ticket asked no question, the domain-matched ticket beside it did, and pressing Read it and draft a reply without choosing anything recorded that a person had chosen the client. It then drafted a reply and offered Send. It now asks the same question a domain match asks. It says, on a hatched background, that we could not tell how the client was found, and pressing Read leaves that unrecorded instead of inventing a choice. Only two kinds of match skip the question: the sender's exact address is in the address book, or a person picked the client. Any other kind, including one added later, asks. These are the same two kinds the unattended mail poller will send on (#1370), and a test holds the two lists together. This is the browser half of that change. It is front end only and live on merge. (#324)
With automation set to send on its own, a quote is no longer emailed to somebody we only guessed was the customer. When a message came from an address we did not know, at a company whose email domain we did, the unattended reply matched it to that company, priced the quote on that company's own rates, and sent it to the sender with nobody reading it. A shared domain, a forwarded email or a new person at a known company all match that way. Measured before the change: a message from an unknown address at a known customer's domain was drafted and sent. It is now drafted and kept, marked as not sent, with the reason written on the quote: the customer was placed only by the sender's email domain, and a person must confirm the client on the desk before it goes out. Only two kinds of match may send on their own — the sender's exact address is in the address book, or a person picked the client — so any new way of matching added later drafts until somebody decides otherwise. A customer recorded on a message with nothing saying how it got there is also drafted, under its own reason, rather than being treated as a person's choice. Draft and approve-without-sending modes are unchanged.
This needs a backend deploy before it takes effect: the change is in the
mail poller, and deploy-backend.yml is run by hand, not on merge.
Until it is run, the deployed poller behaves as before. (#324)
Storing a customer's attached files is now recorded as waiting on a decision, not on engineering. Detecting an enquiry that promised a file and carried none shipped last week; storing, de-duplicating and classifying the files themselves cannot start, because no attached file has ever reached this system — the mailbox reader records only whether something was attached and never asks for it. Two questions have to be answered before it does, and both are business decisions rather than technical ones: whether attached files may be kept at all while the retention policy is still open, and on what terms a customer's own document may sit in our storage. The second question turns out to be larger than it was thought to be, and that is now written down with the measurements behind it: there is nothing in the system that removes a stored file, no rule that expires one, and the address a stored file is served from requires no sign-in — so the honest answer is permanently rather than for seven years. Two things believed to be ready were measured and are not: the upload route accepts images and PDFs only, so it refuses a customer's spreadsheet and a Word terms document — the two formats this work names by name — and doing the join as its own record store would quietly stop accepting rows after about five months, so the file details belong on the message record, which is already split per message and has room. Nothing about how the app behaves has changed; what has changed is that the block is stated, sized and guarded, so it cannot be re-discovered from scratch. The four measurements the case rests on are now pinned by tests, so if one of them stops being true the write-up is known to be out of date instead of being believed.
An enquiry that says "see attached" with nothing attached is now named as a gap, and a quote will not be priced against it. Enquiries whose entire freight content is in a file that never arrived have been quoted against nothing, and the gap was invisible: the flag telling us whether a message carried an attachment has been read from the mailbox and stored on every message since the poller shipped, and nothing anywhere read it. The enquiry reading now answers four ways, and only one of them raises anything. Promised and not there joins the list of things we still need on the enquiry ticket — in the information request's own vocabulary, not a rival banner — and the message to the customer quotes their own words about the file back at them rather than saying "nothing in your message states this" about a file they plainly named. Promised and present is stated narrowly: we hold no file name, so it says a file arrived and never that the right file did. Nothing promised draws nothing at all. And we cannot tell is drawn hatched, blocks nothing, and says which of four different cannot-tells it is — the promise sitting in quoted history rather than in what the sender wrote, wording that points at another message or at a file still to come, a body the poller cut short, or a message whose record never stored whether anything was attached.
Only the one answer we are sure of raises a gap, deliberately: a prompt that fires on every enquiry is worth less than no prompt. A promise found in quoted history is a promise somebody else made in an earlier message, so it is never read as one made now — and a promise in every generation other than the sender's own and the customer's own request is ignored outright, which is what stops the flag firing on every reply in a long thread for ever. The subject line is not scanned at all: this app's own sample data carries the subject "Quote request — attachments to Bunbury", which is a place.
The terms screen no longer denies a gate it already has. Its file-header note said the publish gate was not yet the one that was asked for — fifty lines above the code that had already made it so. The note is corrected in place with the retired wording kept and marked as retired, because a correction that deletes what it corrects leaves the next reader unable to tell a fixed claim from one nobody ever made. The half that is still true is kept and labelled as still true: the gate is advisory while the signed-in identity is unverified, and the screen says which it is either way.
The code said a published terms version was still being thrown away, four days after it stopped being. Terms and conditions versions are stored by the database and have been since 14 September, and live on the server since 18 September — but three places in the repository still said, in the present tense, that a published version was kept in one browser only and dropped on the next save. One of them was the comment beside the code that reads terms versions off the database; the second was a test suite that quoted the first as corroboration; the third was the triage document a reader consults to find out what is left to do. Measured: two independent-looking sources for a defect that had one source and no longer existed, and a piece of work was scheduled to fix something already fixed and deployed. All three now carry the retired sentence word for word, marked false, naming the change that fixed it and the deployment that made it live — a correction that deletes what it corrects leaves the next reader unable to tell a repaired claim from one nobody ever made. The prose is now checked by machine rather than by somebody remembering: a "not deployed yet" warning naming a collection the database really does store is a failing test, read off the database's own list of collections rather than off a second copy of it. Measured when that check was written, two of the two such warnings in the shipped code were out of date; the second belongs to another piece of work, and is recorded as a known exception that fails the moment it is corrected, rather than quietly excused for ever.
termsVersions was in
snapshot(), in apply(), in the change log’s own
filter list and in the server’s PORTAL_STATE_COLLECTIONS
— and in neither backend/src/handler.js’s
COLLECTIONS nor backend/src/collections.js, the
registry the write loop is built from. So the API was configured to
send a customer’s browser a collection it never stored:
three of the four places, plus a projection over the missing fourth. A version
published by an administrator round-tripped that one browser’s
localStorage and was dropped by the next whole-document save, with
nothing on any screen saying so. It is a col# singleton and
audited: true — a handful of rows a year, and a terms edit is
a legal and commercial fact, so who published which version, and when is
the first question behind a disputed clause. The read side already worked:
assembleState installs any col# row generically, so
the write loop was the whole defect. Needs a backend deploy
(deploy-backend.yml is workflow_dispatch only); until
it is run, the front end is unchanged in behaviour and a version is still cached
in one browser.seniorManager role owning the
published wording, gating a new terms.publish action. Neither
existed, so #/terms gated on roles.edit and said so on
the screen — a gate that is not the one that was asked for, presented as
though it were, is worse than no gate. Both are now in the one role catalogue
and the screen gates on the real action. The two halves had to land
together, and the failure mode is the quiet one:
NF.StaffIdentity.can() permits an action it does not know
and flags it unknown, deliberately, so that a typo in a call site
cannot disable a working screen — which means switching the screen without
adding the action would have let everybody publish rather than nobody.
The gate note now reads the action map instead of naming roles beside it, so it
cannot claim a role the gate does not grant, and it answers three ways: the
roles, no role holds this action (a gap in the catalogue rather than a
decision), and the action map could not be read. The new role is narrower
than the one it replaced — it grants terms.publish and
nothing else, and notably not the role editor. OQ-19 is
not closed by it, and the screen says so: naming who may press publish is
not naming who is accountable for the wording being correct.termsVersions was pinned as absent from the backend registry
in three separate places — the snapshot cross-check, the portal-scope
excusal, and the change-log viewer’s own
AUDIT_PENDING_BACKEND, which printed “not recorded on the
server yet” rather than the ordinary “no changes match these
filters”. Every entry is deleted rather than weakened, and both
lists are now empty — which is stated in an assertion, because a pin over
an empty list passes by running no comparison at all and reads exactly like
agreement. The viewer’s list is about the source tree and not the
deployed Lambda, and that is now written down where the next reader will stand:
a registration lands with a commit and goes live when somebody runs the backend
deploy by hand, a lag that applies equally to every collection registered since
— two others are inside that window today and neither is listed. Adding
termsVersions to the registry also forced a classification in the
production purge, by design; it is kept, because it is configuration and
is the one collection on that list an issued quote already points at by
version number, so emptying it would turn every quote ever sent into
“wording no longer held” for a record we really did hold.2026-07-22 09:02 and 22/07/2026, 09:02:03.
Measured: the shared date helper answers NaN for the second outright, and reads a slash date month-first when it
parses one at all — so 07/08/2026 becomes 8 July. 7 August 2026 is the day the organisation levy
moved from 24.47% to 32%, so a month-first read of that exact stamp would have priced the comparison for a
delivery on the changeover day against the superseded rate. Both shapes are now parsed explicitly, day-first,
and anything that is neither is refused by name rather than turned into a plausible date.A quote promised prior notice of a fuel levy change and said nothing about how much — now it says what the record says, and is silent where the record is empty. Every estimate carries the line "Fuel levy is subject to change with prior notice.", and until now it was printed regardless of anything the app held. The notice period has been recordable for some time, on Transport Configuration, and the document that makes the promise never read it — so the promise on the page and the figure in the system could not disagree, because the page was not looking. The sentence now reads off that record. Where a period is on file the estimate states it ("That notice is at least 14 days."). Where nobody has set one — which is what ships — the estimate is composed exactly as it was before, word for word: a document going to a customer must not change because a decision has not been taken, and "our own record is empty" is an internal fact rather than a clause to put in front of a client. A period somebody entered that cannot be read is treated the same way on the page and kept apart from an unset one behind it, because they are two different phone calls. The case that was a wrong answer rather than a missing one: a notice period recorded as nought days is a real decision an operator can make, and with it on file the estimate was promising, in writing, notice the business had decided not to give. Both halves are now on the page, the disagreement is named, and it says the two have not been reconciled — never "at least 0 days", which would read as an agreed period that happens to be small. A customer whose agreed rates already include the fuel is told no period at all, since that would be a statement about a charge their estimate does not make; a reprinted estimate answers off what was stamped on it when it was sent, not off today's configuration. No figure on any quote moves, no save is refused, and nothing claims a notice was sent to anybody — sending one is still separate work, and the promise itself is unchanged, because withdrawing a commercial promise is not a decision this software takes.
NF.Quotes.revise and NF.Quotes.recordDiscount were complete and had no
caller in any screen — measured 0 and 0 across the three apps, against 1 each for the outcome and
chase writers on the same card — so the office refusal "Raise a revision with a current
validity" pointed at a control that did not exist. The quote detail's Workflow card now carries
a Versions panel (the chain in the reader's own words, and a revision raised as a draft that
sends nothing and goes out through Send) and a Discount panel (an offer quantified, reasoned
and signed, recorded and never taken off the charge lines). Every refusal either writer makes that
a person can reach is printed in the panel, in the writer's own sentence, and survives the repaint;
a version history that cannot be read and a quote with no discount on file are hatched, because
neither is a clean slate. A revision is offered only while the quote is open and never on an
accepted one: measured, the acceptance reader answers already before it consults the version
gate, so a revision there would supersede the accepted version while the booking went on using its
price — that is FR-QT-29's requote, and it waits on D-QT-29.FR-QT-32,
FR-QT-16).NF.Pipeline.validityOf
compared today against the stored validUntil string through
Date.parse — the one parser NF.custDate exists to keep off
that field, and the one #330's acceptance refusal had already stopped using. Measured on
12 Sep 2026: a validity recorded as 2026-13-45, which is not a date at all,
was rolled over into February 2027 and reported as live with 155 days left on the
screen that forecasts revenue; 1 October 2026 produced 19 days the same way.
Worse, 01/09/2026 — 1 September in WA — was read in US order as
9 January, so for the 235 days between those two readings the pipeline called the
quote run out and dropped it from the weighted book while the acceptance page went
on taking it: one stored string, two confident opposite answers. Both readers are
now NF.custDate, and a new guard states the rule as an equality — the
report calls a quote run out exactly when the refusal does — over eleven stored
values at four clock settings.validityOf now answers four ways (live / run out / undated /
unreadable), carries no days-remaining figure for either empty, and the row draws a
read-and-passed validity dimmed as the known negative it is while an unread one is
hatched, this app's mark for a state it cannot call. The weighting deliberately did
not move — an unread validity is weighted exactly as a blank is, because that is what
the acceptance path already does with it — so no figure changed; it is named instead,
on the row, in the "quoted, still open" caption and in the note. That note also stopped
being able to say "No open quote has run past its validity date" on its own over quotes
carrying a date nobody had read.NF.Outbox.record() as every other time, so the stamp carries the device and reads as Stamped in the cab. Once the truck has left, a stamp nobody took reads not captured, dimmed and dashed, and a value that is not a stamp at all is hatched. None of them is ever read as a time of zero. The office's demurrage claim row draws the same three answers from the same reader, NF.Charges.workEvidence, and says in words that "not captured" cannot tell a skipped tap from a stop worked before the cab offered one. The stamps sit beside FR-CH-09's four evidence items and are not counted among them, so no charge on file is re-graded.NF.Compliance.gateBlockers never reads which step a gate names, and the cab asks it at pickup departure only.A futile, standby or collection-fee charge can now be decided by a named
person, for a stated reason, on the con note. This is the gesture the
corpus already performs in prose — one supervisor's email reads "No Futile
Charge due to Driver Sick", which is a fault attribution and a waiver taken
by a named person on a named con note — and there was nowhere in FleetView to
put any of it. NF.Charges.attribute / waive /
withdrawWaiver / approve record who attributed the
fault, who approved or waived the charge, and why, and record nothing else: no
rate, no hours and no money, exactly as a demurrage decision does. The decision
rides the consignment record, so there is no new collection and no backend
deploy, and a split part carries its own decisions because a split part is an
ordinary con note. Attributing, waiving, withdrawing or approving without a
named person is refused, a waiver with no reason is refused, and a waiver is
withdrawn rather than deleted — every waiver the charge has ever carried stays
on the record with its person, its time, its reason and how it ended. A
detention decision is refused by name and points at the demurrage claim that
already records it, because two records of one wait are free to disagree with
each other and with the invoice.
Three absences that ordinary English runs together are kept apart on a charge decision. Nobody has attributed this is not attributed to nobody — the first is drawn as an unknown, the second is a decision a named person took about weather or a road closure, and whether the charge is chargeable reads as unknown rather than as no. No reason recorded is not no reason exists. And not approved is neither approval refused nor approval not needed: no approval threshold is configured anywhere, so whether a decision required signing off is reported as unknown on every record, and recording an approver is explicitly not a gate having been passed.
Recording a decision does not make a charge priceable, and the charges report still reports the three types as unraisable rather than as nought. The futile and standby rates ship unset until they have been agreed, so every such charge is still refused by name. Wiring the report to the new decisions would have moved futile from we cannot know what would have been raised to we looked and there were none, over a corpus that bills futile time at $279 an hour — true of the store and false of the world, because nothing captures a futile attendance and no rate prices one. Whether a waiver reason should be a chosen code or free prose is still an open business question: the prose is always stored, and a code is refused by name until a list has been agreed rather than accepted into one nobody ratified.
A time the office retyped on a con note kept the driver's GPS beside it, in green, as though it proved the new time. Every time-based charge Northfleet raises — detention above all — is measured between two stamps on the con note, and the con note's Job flow panel lets the office edit them. It did so silently: the value it replaced was gone, nobody was recorded as having made the change, and the position taken at the moment of the old stamp was carried across and drawn beside the new time in the same colour a driver's own capture gets. A customer asking "who says you were held ninety-five minutes" was answered with a pair of strings that could not say where they came from.
A stamp now says which of four things it is: stamped in the cab (read off the device id the driver's own outbox already writes onto the record, so the cab had to change nothing), recorded in the office, edited in the office — carrying the value it replaced, verbatim — or source not recorded, which is every stamp taken before this shipped and is drawn hatched with the words "this is not a statement that a driver captured it". A carried-over position is kept, because dropping it would destroy evidence of something, and named, because it places the truck at a different time from the one shown; it is hatched rather than drawn in the colour that reads as proof.
Beside each demurrage claim the con note now states what the charge actually rests on, as a count and never a percentage: the start and finish times, the site representative's name, that person's signature and the reason for the delay. A name somebody typed is not counted as a signature somebody took, a flat fee is not marked down for having no measured time to hold, and "we could not read the record" is kept apart from "we read it and there is none" — the second is a real answer and the first is not. On the three charge types nothing captures anything for at all — futile, standby and the collection/delivery fee — it says so by name rather than reporting nothing.
FR-CH-11 can be sized rather than guessed at. The
raised/waived/disputed reporting view was verdicted “leasable and
unblocked — the three states already exist on the record”, and
they do — on demurrage’s model, not on
NF.Charges, which holds a rate card and a calculator and no record
at all. The brief sizes both options and recommends hanging a charge decision
off the consignment the way c.demurrage already is: measured, a
fully-decided claim is 899 bytes, so both legs of a con note
cost 0.468% of one consignment# row, while a
separate unsharded collection 413s at 432 charge decisions and
would therefore need its own SQL table, the DDL runner and a backend deploy. A
split is already an ordinary consignment, so per split comes free, and
a keyed map makes FR-RT-16’s counted exactly once
structural instead of a rule somebody has to enforce. (#331)raisedAt is written once and read by
nothing — the claim projection drops it and the 26-column CSV has
no such column — so raised is the one of the three states
projected nowhere; on this model a claim exists by arithmetic rather than by a
button, and the count that means something is the claim population.
NF.Charges.allowanceFor('detention') reads 60
minutes off the published card while NF.Demurrage falls back to
30, so two modules disagree by a factor of two about the same
event. And the customer portal is sent consignments
whole — PORTAL_STATE_FIELDS narrows only
drivers and vehicles — so internal waiver reasons
and dispute notes already reach a customer’s browser, which a new
collection would not have fixed and which is filed as its own question rather
than as a rider. (#331)0 — from the terms while the chip three inches from it read
"0 min · no allowance applies" — two claims about one field, and the
box's was wrong twice over: the published terms hold no futile allowance, and a
0 in a box a dispatcher types into reads as an agreed nil. Both now
answer the same three ways the model does. Clearing an allowance likewise said
"it falls back to the terms" for every charge type, and the terms define
one for detention only — so clearing standby's claimed a published term
had taken over when what has actually taken over is the terms do not say.$0.00 reddens 3 checks, dropping the waiver block
reddens 7, restoring the old allowance placeholder reddens 3. One of its checks
— that the detention box quotes the hour in the published terms —
was retired before merge and is quoted in place: #315 landed in the
meantime, measured the published 60 minutes against the 30 a wait is really
billed against, and made that allowance two figures nobody has reconciled. A
box quoting an hour over an unreconciled pair is the defect, not the fixture.FR-CH-11’s detention report is
read from the demurrage claim already on the con note, and four checks in its
suite asserted that a list held something and then read it unguarded — so
on the run where that list really is empty the suite threw rather than
going red. Measured: a deliberate break severing the report from
NF.Demurrage.claimsFor() stopped the run inside that file and left
83 of 150 suite files unstarted, with the runner printing no verdict at
all to take a number off. All four are defensive reads now, and the same break
reddens 35 assertions over a run that finishes. The known-abort budget in
backend/tests-abort-shapes.js shrank from 69 sites to 67,
which is the direction that list is meant to move in — and two of the four
were invisible to its detector, which matches a length assertion followed by an
unguarded index and cannot see a dereferenced .find() result.FR-CH-11 asks for the charges report by customer, depot, charge type and reason,
and the trap in building it is that NF.settings.chargeRates ships empty by
design and three of the four live charge types have no decision writer at all. The naive
version prints “futile: 0 raised, 0 waived, 0 disputed” — every
number true, and the sentence a manager takes from it (we are not incurring futile
charges) exactly false, over a corpus that shows futile time billed at
$279/hr against a published $150. So the card distinguishes four empties:
a charge nothing can record is hatched and dashed; a charge type that can be
raised with none on it is dimmed and dashed, because we looked; a register that could
not be read is hatched and says so; and a charge raised with nothing waived or queried keeps
its measured 0, because dashing that would under-claim what we do know. Detention is
the one type with records, read through NF.Demurrage's own
claimsFor() so the report and the con note cannot disagree about a claim.recordedBy beside its existing rateSource: detention answers
demurrage — the claim already on the con note — and futile, standby
and the collection/delivery fee answer null, each naming in its own words what
is missing. Measured, and it is why the obvious shortcut is wrong:
NF.Charges.summarise().unpriceable filters unit === 'hr', so it
names futile and standby and cannot name the collection/delivery fee, whose fee is
per delivery and is held per depot. A report reading its hatch off that list alone would
drop the collection/delivery fee through to “nothing raised” and draw it
dimmed — the wrong empty, on the charge type most likely to have a rate on file. Both
reasons are now reported separately, because no rate and nothing records it
are different jobs for different people, and whether a per-delivery fee has a rate is
answered as unknown rather than as no.NF.Demurrage.summarise() counts a query
only against a charge we are actually making; this report counts one across every state,
because a charge we waived and the client is still querying is a real conversation —
the model's own writer says “the charge still stands: a query does not withdraw
it”. Both numbers are returned and the difference is named on the card rather than
left as two counts of one word. A waiver reason and a dispute note are printed verbatim
with the person who decided them and are never bucketed; only the closed four-value
attributed-cause vocabulary is grouped, because grouping free prose would invent a taxonomy
nobody agreed.getComputedStyle().backgroundImage — a class no loaded stylesheet defines
ships flat and passes CI, and the section already has one visual language for we do not
know against we know, and it is none. The guard asserts the two against each
other on one page, because getting them the wrong way round is money on an invoice.c.attachments && c.attachments.length, so an absence drew as
no section, no sentence and nothing at all — which is byte-for-byte what a
job needing no permit drew as, on the screen a driver hands an inspector at a
roadside intercept. The section is now always drawn and says which of three
things it means: the paperwork we hold, listed as before; no permit or other
document on file, hatched, saying in the same breath that this is
not a statement that the load needs none and that nothing on the job
records whether one is required; and we have not read the documents for this
job, also hatched and worded apart, for a tablet whose database read never
landed or would not merge. Which empty it is comes from
NF.readComplete(), the one reader for whether this browser may state
a known negative, so a merge that stopped part-way cannot be read as an answer.
There is deliberately no fourth state claiming a permit is not required:
that is NF.Permits' assessment, it lives on the quote, and nothing
in driver/ calls it — the cab cannot reach it and must not
imply it. Archived attachments are filtered
active !== false once, at the read, and the open handler indexes that
same list, so a job whose only document has been archived falls to the honest
empty rather than producing a superseded permit. Measured: restoring the
conditional reddens 11 of 79 checks in tests/manual/stop-rule.js and
3 in tests/run.js; removing the hatch from
driver/driver.css reddens 3 in the browser and none of the new
assertions in the runner, and a hatch that is present in the file but overridden
in the cascade reddens 3 in the browser and 0 in tests/run.js
— which is why the rendering is read as a computed
background-image and never as a class name (#332).The permit on a job can finally be reached from the job. FleetView has
worked out whether a load needs a permit, and recorded who decided, the permit
number, the issuing party and the dimensions it actually grants, since the
travelling-dimension work shipped. All of it was stored on the quote and
read by one screen. Nothing on the con note, the run sheet or the driver's tablet
could reach it, and nothing anywhere could answer "do we hold permit
MRWA-OSOM-9931?" or "which movements does it cover?" — measured
at zero across the dispatcher, the driver app, the client portal and the server.
There is one reader now, over the link that already existed. The con note's
Job documents & permits panel, the load-check reviewer's concern list,
the compliance report and the driver's docket all ask it, so they cannot come to
different conclusions. A permit reference can be looked up across both places one
is already recorded — filed against a truck, or cited on a movement —
and it says which movements cite it. No new table was added: whether
Northfleet holds standing period permits or applies per movement decides where a
permit record belongs, and nobody has answered that yet.
A load nobody had measured used to look exactly like a load measured and found within. Three screens decided the word "permit" from a single tick on the driver's kerbside load check — a box with no deck height, no legal limit and no third answer in it. If the box was ticked they warned; if it was not, they drew nothing at all, which reads as a clearance. The compliance report was worse: its "no permit gaps" line counted only the jobs that had been ticked, so "no permit gaps · 3 checked" over a fleet of three hundred read as three hundred cleared. All three now give three answers, not two. A permit is required, no permit is required, or nobody has recorded either way — drawn hatched, saying in the same breath that it is not a statement that none is needed. The driver's tick still condemns a load and no longer clears one: only a permit decision a named person recorded can do that. Where the office has recorded "not required" over a load the driver measured oversize, it condemns and prints both records, because that disagreement is the recorded incident in miniature — a clearance made against figures that are not what is on the trailer. The driver's docket now says, in red, when our own records say a permit is required for the job and none is attached to it; it deliberately never prints a clearance, because an office clearance read at a roadside intercept off a tablet whose copy of the database may be hours old is the wrong way to be wrong.
An attached permit is archived now, not deleted. Removing a permit, escort order or SWMS from a con note deleted the row outright — no record that it had ever been there — and it identified the row by its position in the list, so with two dispatchers on one con note the document removed was not necessarily the one clicked. That was on the paperwork that proves a load was legally on the road, and the change log cannot always reconstruct an attached document because it is too large to record. Documents now carry an identity of their own, the remove button and the record that answers it use the same one, and removing marks the document as no longer in force while keeping it and naming it on the job. Two refusals rather than a guess: a request that names a position is refused and says so, and where two documents on a job are genuinely indistinguishable the archive is refused rather than picking one — because the one left behind would be the one somebody produces at an inspection. Every refusal is printed in the panel, where it can be read, rather than in a message that slides away.
docs/decisions/332-permit-register.md, measured against
origin/master at a8e49344. NF.Permits
already writes a structured permit record — the number, the issuing party,
the permitted dimensions, and the cover check that refuses a 4.6 m permit
against a 5.0 m travelling height — and stores it on the
quote, read by one screen. Measured: NF.Permits occurs
0 times in driver/ and 0 times in
portal/, so the one safety check this issue exists for does not
reach the road. A permit register also already exists and nobody had
noticed: NF.assetDocs kind permit — sharded,
audited, archive-never-delete, a content-hashed id — carrying three
MRWA permits with expiry dates. What is genuinely absent is the join: an
instrument-checked 0 hits for any lookup by permit number anywhere in
js/, driver/, portal/ or
backend/src/, against a positive on byEnquiryRef to
prove the search space was real. Recommends one reader over the existing
quote → con note link, the authorities as configuration on
settings the way setDeck already writes deck geometry,
and a collection only if one business question comes back a particular way.
(#332)js/data.js ×2, js/integrations.js ×2,
the registry, the handler's own COLLECTIONS literal and
AUDIT_COLLECTIONS), plus a registration group, plus a backend
deploy that is workflow_dispatch only by design. And #332's
“no permit/authority register — confirmed absent” is
half false, per the entry above. The direction of both findings survives —
this is dispatchable and small, smaller than the triage thinks, because
the recommended first two increments touch none of the seven. Also recorded:
OQ-33 blocks the charge line and nothing in the register,
and quoteGate()'s charge branch is live today off hand-typed charge
lines rather than off the unheld fee schedule — so the measured
$340.00-with-no-height defect is already refused. And one finding
that outlives #332 entirely: there is no set-wise pin of
snapshot() against the collection registry — every
snapshot check in the tree is the per-collection “is MY collection
there” shape that Group 7's own comment names as why the audit lists
drifted in both directions for months — so a front-end-only collection is
dropped in silence by COLLECTIONS.forEach at
PUT /state, with no warning and no 4xx, and lives per-browser for
ever exactly as termsVersions does. For a permit register that is
worse than not having one. Worth its own issue whether or not #332 is ever
built. (#332)removeDoc deletes an attached permit with
splice(idx,1) — no tombstone, no active:false,
no confirmation, identity an array index — on the artefact that
proves a load was legally on the road, and under an 8 KB audit field cap
that records an inline document as (value).
c.loadCheck.oversize is a second oversize assessment, a
driver's kerbside tick with no deck height, no general-access limit and no
refusal in it, and it is the one the con note and the load-and-dimension report
actually read. And the cab renders the whole “Documents &
permits” section only when something is attached, so a job with no
permit on file is drawn identically to one that needs none — the
reassuring empty, on the screen a driver hands to an inspector. (#332)NF.Jobs.markSent behind both, the desk composer (which no longer offers a
quote email its own writer would reject), and autosend — the one mode with
nobody reading, which mails without going through the send writer at all and was
therefore the only path with nothing in front of it. A draft is still
produced, because a draft is a figure on our own screen with a person still to
press send; an information request still goes, because it is the message that asks
the question. A declared yes is not refused — it carries a caveat
naming, from the running app rather than from a sentence written down, that no
dangerous goods licensing requirement is on file and that dangerous goods are not
priced as their own service. Whether Northfleet carries them at all, and under
what licensing and charging, is an open decision (OQ-37) and nothing here answers
it: no class, no UN number, no packing group, no compliance requirement, no charge
and no decline rule.The driver can now see, and print, the manifest and the load plan for the trailer they are hauling.
Until now only the office had these two sheets. In the driver app, driver/ contained no trailer
contents reader, no load-plan reader and no print rule, so the person standing at the trailer could not
read either sheet or put one on paper. On the docket, the "Which trailer is this on" panel now opens both,
one sheet per trailer. A road train whose allocation cannot say which trailer carries a load gets a sheet
for every trailer in the combination, and the app says why rather than picking one. Every line on the sheet
comes from the same reader the office's manifest uses, so the cab and the office cannot disagree about what
is on a trailer. The sheet is read-only: the driver cannot edit the plan, and recording a difference from
the field is not built yet, which the sheet says in its own words.
It keeps three answers apart. If this tablet has not read the database and holds nothing for the trailer, the sheet is hatched. Its counts read "not read", it lists no rows, and it says in words that this is not a statement that the trailer is empty. A trailer we read and found empty gets a dimmed, dashed panel with real zeros. A load somebody recorded onto the trailer is the only row printed in the solid "recorded" style. A load that is only allocated is hatched as the plan it is, and where it rides on the deck is drawn as a dashed plan, never in the recorded style.
Print works with no signal, from the copy of the database already on the tablet. Every sheet states when that copy last agreed with the office. Both sheets print, with one trailer per page, and the hatching survives a mono laser because every state also carries a border and words. When every sheet would be the "could not read" answer, the Print button is not offered at all. The app explains in place that a printed sheet with nothing on it reads as an empty trailer. If somebody prints from the browser's own menu anyway, what comes out is the hatched "could not read" panel, never an empty manifest.
Measured by deliberately breaking each rule and counting the checks that fail. Having the cab count what is on a trailer itself, instead of asking the shared reader, fails 10 checks in the root suite and 3 in the browser. Drawing "could not look" as a zero fails 14 and 8. Drawing a planned position in the recorded style fails 3 and 2. Leaving the "could not look" hatch out of the cab's stylesheet fails 1 and 3. Offering Print when nothing could be read fails 3 and 1.
FR-AO-09, the evidence #338 names as what would license
graduating automatic approval past its one-click gate, and the one requirement in that
issue that did not exist at all: grep editRate returned 0 hits
across js/ and backend/src/, the only trace being
NF.Intake.LICENCE_NOTE's own admission that there is no edit-rate data.
NF.AutoQuality.report() now derives it, purely, from what the drafting and
sending writers already store, and #/config draws it beside the switch it
is evidence for — the per-field reading (both readings agreed / one reading / the two
disagreed / never stated / never recorded), how many drafts a person released, and how
many of those differed from what the automation wrote. It names the drafts that changed
by their enquiry reference and computes no percentage anywhere, because "94% accurate"
hides the field that is wrong every time.docs/decisions/absence-as-success.md censuses. So we could not look and
nothing has ever been drafted are drawn hatched; drafts exist and nobody
has released one and every release went out unchanged are drawn dimmed and
dashed with a dash rather than a 0; and a real figure gets ordinary
chrome. A draft the automation sent unread in autosend is counted by
its own name and never as "released unchanged" — it went out unchanged because nobody
looked, and counting it would make the meter congratulate the automation in exact
proportion to how little it was reviewed.FR-AO-09 asks for that cannot be derived is named on the
card rather than approximated. "Extraction accuracy" needs a ground truth, and the
drafting writer puts the extracted values straight onto the quote and keeps no copy — so
a reviewer correcting the delivery point overwrites the only record of what was read. The
tempting approximation, re-running today's parser over the stored wording, would score the
AI's fields against a reading that never produced the draft and would report an improved
regex as a reviewer's correction; #602 measured what an assumed reading costs, dating
6 of 6 rows and being right 0 of 6. The card says which column it cannot
fill and what it would take, the way the Requirement report names its two.docs/modules/driver-outbox.md named as still owed. Without it every
capture the server records would be in the change log and unreachable from the
viewer, which is the recorded but unfilterable gap that list already
closed for tmsJobs, sessionRecords and
carriers.intents would have been
told the log was complete and nothing matched, when the truth is that nothing is
persisted yet and the captures are reaching the database inside the con note
instead. A filter option that cannot say why it is empty is
absence-as-success with a dropdown in front of it. It now branches on
NF.Outbox.capabilityState() rather than on a row count, keeping that
reader's own three states: absent says a document was read and did not
advertise intentOutbox, and adds “this is not a statement
that no times were captured”; unknown says “we have not
looked”; only available falls through to the ordinary sentence,
at which point an empty really is an empty. It asks the capability rather than
re-reading the document, so there is one answer about what the API supports
rather than two that can disagree.GET /state says intentOutbox: true unconditionally,
and PUT /state stores one intent#<id> row per
intent on the same whole-document save the capture always rode (DP-876 — there
is exactly one queue, and this is not a second one). The row is keyed on the
client id, so a replayed intent overwrites itself and can never mint a second,
and a write carries only what the device has not yet seen acked — so the write
is partial by default and two devices cannot erase each other. Needs a backend
deploy: until deploy-backend.yml is run by hand, the capability is
not advertised and every cab goes on sending byte-for-byte what it always sent.GET /state does not carry the intent log at all (measured at 477
bytes an intent and six time fields per con note, it grows faster than anything
else in a document read at every sign-in). So filtering to it used to fall
through to "every add, change and delete is recorded here" the moment the
backend landed, which would have been false of exactly one collection. It now
answers three ways — we have not looked, this build's API does not have it, and
the server does store these and this is a decision — and the third sends
the reader to the con note, where the time change really is recorded.5c466df7), so a POD nobody was ever asked to photograph looked the
same as one where every view was taken.
NF.podViewCoverage, and the screen re-derives nothing. There are
three answers, never two. No record of which photographs were asked for
is hatched and says it is not a statement that none were needed. So is a
view list that does not line up with the photographs, a reader that could not
be asked, and any answer the screen does not recognise. A shortfall is
counted (“1 of 3 views held”), names every missing required view,
and shows the driver's reason where one was given. It is never a percentage.
A complete record says a photograph was filed against each required
view by the driver. It shows no tick and no green, because nobody here has
looked at what each photograph shows.
tests/run.js
and 5 of 44 in tests/manual/pod-drops.js. Dropping the
readout from the panel reddens 2 and 15. Removing the hatch class
reddens 6 and 4. Repainting the hatch flat from the stylesheet
reddens 1 and 4.900 px / 0.7 as a
fallback, so a raise in the shared table would have left that path capturing at
the old size. All three cameras in the cab — the POD, the form runner and
the licence/document capture — now read the one table through one reader,
and where the table cannot be read at all the capture is kept whole rather than
shrunk to a guessed floor. The per-submission photo budget is deliberately
untouched: re-encoding a form's photographs to fit one record is the right
behaviour until media is uploaded separately from the record it belongs to, and
the alternative is losing a compliance check. Measured, the two do not meet
— all 5 form templates name the evidence tier explicitly, so the POD tier
fed the budgeted path on none of them, and a new guard fails the build if a
template ever lands without naming its tier. Reverting the tier reddens
3 assertions; restoring the hardcoded copy reddens 3; dropping one
template's tier name reddens 2.docs/decisions/rm-band-triage.md recorded
that NF.Locations.resolve() — the site-alias resolver that
tells an unknown site name from an archived place, from an ambiguous one, from
one we hold and cannot send a truck to — had exactly one caller in the
whole app, the address book, while the email intake was the screen specified
to need it. That was true at the brief's declared base and was falsified two days
later; it was still being read as a statement about the code six days after
that, which is the failure mode this directory exists to stop. Re-measured
comment-stripped over js/, driver/ and
portal/: two callers, the address book's filter branch and
the intake's own site reader, the second wired end to end through to the enquiry
ticket's What we still need panel. The original wording is kept verbatim
and marked, in all three places the brief asserts it, because it is what a later
dispatch was sized against — and what survives is named, since
maySendCustomerMail() really is still called by nothing but its own
banner.NF.Locations.resolve( returned 0 while
its positive control fired, because the address book reaches the resolver through
an accessor — so a receiver-specific pattern is defeated by the spelling of
the call and answers no callers at all, one step past the truth and in the
reassuring direction. The count of two comes from casting wide instead: every
.resolve( call site in the app, classified by receiver, which is the
only shape the spelling cannot defeat. A positive control proves the instrument
runs; it does not prove the instrument can see the thing being counted.#/clientcontacts has a Person or mailbox column, read through NF.Contacts.kindOf and nothing else, with three answers: Named individual, Shared mailbox, and Not stated. The third is every contact written before the field existed and every contact the FMS import brings in, and it is drawn hatched — never as a person and never blank. Nothing is worked out from the name or the address: an Accounts payable row at accounts@ reads Not stated until somebody says. The add form has a matching choice that defaults to not said, which is stored as no kind at all rather than as a person; a refusal from the writer is printed in the form with the choice kept. Measured: the screen asked kindOf nothing before this (kindOf|.update( returned 0 in the screen against a positive control of 1). Not built: changing the kind of a contact already on file. The only contact editor, NF.Contacts.update, records the contact as confirmed today, so classifying an imported contact through it would mark it checked when nobody rang anyone; a writer that does not confirm is owed in js/data.js first, and the form says so (#344).It is read by state and never by truthiness, and there are eight answers rather than two. Nobody has asked is drawn hatched and says in its own words that it is not a statement that the paying customer is the end client. “There is nobody behind them” is a separate, positive answer and is drawn dimmed and dashed, the mark a known negative gets here. A name nobody on the register answers to is kept rather than refused — and said to be unplaceable, naming what that costs — while a name that answers to two clients is refused outright, with both named, and nothing is written.
REQ-BHP-NEWMAN, which holds a departure — reached
nobody on a load booked through a hire company. Measured on the sample fleet at
five clock settings and identical at all five: 2 of 20 drivers work for
the broker and have no work of their own for the end client, and for them the
departure gate returned no blockers at all where one applied. That is a
compliance pack rejected at the gate arriving, in the office, as a green tick.
Both readers now ask the one reader that knows who the freight is for, so the
con note screen and the gate cannot disagree. Nothing is flipped into blocking by
this: no con note in the shipped data carries an end client, so every answer is
unchanged until somebody records one, and recording one can only add a
rule. The reason printed beside a held departure says which side matched —
endClient:… rather than client:… — because that
sentence is frozen into the permanent record a second, deliberate tap writes, and
naming the payer’s own rules there would put something in a driver’s file that is
not true of the job.
#/clientcontacts has a Person or mailbox choice and a Set button. Before this, only a new contact could be classified. The one editor that could change an existing contact, NF.Contacts.update, records the contact as confirmed today, so classifying an imported contact through it would have marked it checked when nobody had rung anyone. The new control uses a separate writer, NF.Contacts.classify. It records who said which, and when, in fields of its own (classifiedBy, classifiedAt, classifiedDay), and it never touches lastConfirmedAt. So an audit can tell somebody said this is a mailbox apart from somebody checked the number. Measured through the real control on an imported contact with no confirmation date: after it is classified it still has none, and its currency chip still reads Never confirmed, hatched. Setting a contact back to Not stated is allowed and is recorded against who did it. The writer refuses an unknown contact, a kind that is neither of the two, a call that leaves the kind out, and a classification with nobody named. Each refusal is printed in that row, in the writer's own words, and the choice stays selected. The screen still never calls update() (#344)..jcard.is-done{opacity:.6} faded the whole card, and
opacity pulls the text and the surface it sits on toward the page
background together — so the customer name, the con note number, the
pickup address, the contact phone number, the Delivered badge, the relay
line and every leg-type chip lost contrast at once. Thirteen of the rendered
contrast budget's 23 lines came from that one declaration, and no static check
could ever have seen any of it, because the fade sits on an ancestor the
selectors never name. The fade is replaced, not deleted: a completed card
gives up its raised white fill and its shadow and sinks to the page colour, with
a border darker than a live card's holding the outline together. Done still reads
as done — the ✓, the green Delivered badge and a completed card having no
action buttons already carried the state; the fade only carried the emphasis.
Measured on the rendered cards: the customer name 4.49 → 14.44:1, the ✓ 4.49 →
16.34:1, the phone link 3.01 → 6.57:1, the address 2.80 → 6.06:1,
Delivered 2.64 → 5.84:1, and the four leg-type chips from 2.66–3.22:1
back to 5.98–8.58:1, exactly their live-card figures.portal/portal.css and
driver/driver.css each declared their own copy of the greys and the green
that css/core.css darkened for contrast in #363, and neither had ever taken
the new values — so the two apps that go to a customer and to a driver in a
cab in direct sun were the two still failing. Measured with
tools/contrast-check.js: 26 colour pairs below WCAG 2.2 AA, now 0
— 6 in the portal, 20 in the cab, and 0 of 256 scored pairs failing across all three
apps. The worst was the portal shipment stepper, which painted a white step
number on a light grey disc at 1.22:1 — the worst pair anywhere in
docs/audits/wcag-2.2-aa-audit.md, and in practice a future step’s number
a customer simply could not see.portal/index.html and driver/index.html load Leaflet’s
stylesheet and their own and no css/ file at all, so a rule reading
var(--danger-fg) in either would have resolved to nothing, rendered as an
inherited colour, and still parsed — a silent fall-back no static check could see.
Each file’s :root therefore restates the roles rather than referencing
them, and tests/suites/92-contrast.js asserts both halves: that each app
declares its own and that no var() without a fallback names a property that
file does not declare. No brand colour was re-specified — where a brand colour
was the failing side the label moved (white on brand orange 2.46:1 became
--ink at 6.66:1), and white-on-red fills use --danger-fill while
every red border, rule, glyph and tint keeps --red. One grey became two:
--muted-on-dark keeps the lighter value at the seven rules that sit on a dark
surface — the cab’s top bar, sync pill and speed panel — where the
darkened one would have been 2.58:1.tests/suites/92-contrast.js went from
26 lines to none; the object stays, because its two assertions are the mechanism — a
new failing pair anywhere reddens as unbudgeted, and a line put back that has stopped
failing reddens as stale. Each of the 26 is now asserted absent from the
stylesheets rather than merely passing, which survives a rule moving. Zero exemptions
were added and the grey traffic-light strip is untouched at 1.49:1: grey is the
absence state, and darkening it to 3:1 would make “nothing to see here”
the loudest row on the board. Still not a conformance claim — this is the static pass,
and it proves a declared value rather than a rendered one..nfh-tablewrap paints two shadow
strips as its "there is more this way" affordance, and because they are pinned to the
container's edges while the cells scroll through them, the strip — not the
white card — is the surface that any cell's text sits on mid-scroll. At
rgba(34,31,32,.12) its darkest stop composites to #e4e4e4, which
imposed an undeclared 5.71:1-on-white floor on every colour in that table, and three
of the table's own colours were under it: the we hold no value dash at
4.45:1, "Late" at 4.27:1 and "On time" at 3.92:1, all
against the 4.5 they need. The strip's peak is now .08 and it is widened from
14px to 21px, which holds the affordance's total shading exactly — a
farthest-side radial stop integrates to peak × width / 2, so
.08 × 21 = .12 × 14 — and lowers the floor to 5.26:1.
Re-measured in a browser against the real page: the dash 4.45 → 4.83:1, "Late"
4.27 → 5.61:1, "On time" 3.92 → 5.61:1, and the whole table clear
of AA. No static pass could see any of this — the strip is on an ancestor none
of those selectors names, and tools/contrast-check.js scores the same
stylesheet 0 of 256 failing..nfh-ok read --ok, which is a fill: it
is the green circle under a white tick in five other rules, and the portal's own token
block says in as many words that a tint label uses the -fg roles and a
solid fill uses the brand colour. As a label it was 4.98:1 on white and 3.92:1 under
the scroll strip. It had never appeared in any test run, because the sample data has to
hold a delivered, unsplit, on-time consignment before the element exists at all
— so this was a failure waiting on a roster rather than one anybody had seen. Both
timing labels now read the role tokens the portal already declares (--success-fg
and --warning-fg); "Late" had been carrying a hand-typed
#a2560f with no token behind it. Neither hue changed family and neither status
changed meaning — green is still on time, amber is still late. The we hold no
value dash was deliberately not darkened: its job is to be quiet, and the surface
fix carries it.tools/contrast-check.js reads the
stylesheets off disk, which proves a declared colour and never that the
colour wins the cascade where it lands — almost every text element in both
apps is transparent, so what it sits on comes from an ancestor its selector
never names. tests/manual/rendered-contrast.js loads the real pages,
walks up the ancestor chain until it finds a real surface, and measures
getComputedStyle(el).color against it. It carries no contrast
maths of its own — ratio, luminance, alpha compositing and colour
parsing all come from the existing instrument, and a suite assertion refuses a
second copy, because two implementations of one rule both produce a plausible
number. Measured: appending a 1.17:1 rule to driver/driver.css
leaves node tests/run.js at ALL PASS and the static instrument at
0 of 257, and reddens 4 checks here. (#349).jcard.is-done{opacity:.6}
fades a completed job card as a whole, and opacity pulls the text
and its surface toward the page background together — so the customer
name, the con note number, the pickup address, the contact phone number, the
Delivered badge and the relay line are all between 1.73:1 and 4.49:1 on
a card a driver still reads. The check resolves a faded subtree as two
composites rather than refusing it: before that, 19 of 58 text observations on
the cab's run sheet were unmeasurable, and an instrument that cannot see a third
of a screen is not measuring that screen. Reported, not fixed — every
failure is held as a named budget that reddens if it grows or goes stale.
(#349)—muted‑on‑dark
classification — the seven cab rules kept on the lighter grey because the
darkened one is 2.58:1 on a dark bar — was made by reading markup. Measured,
eight of nine sites hold at 5.16–6.43:1 and one does not:
.drv-lim-lbl reads 1.52:1 on the over-speed panel,
whose state class repaints the surface red. A ninth is never rendered in the
configuration the audit reasoned about, because a bound tablet overrides it. And
the audit records the wordmark's orange i as a portal row; it is in
both apps, at 2.46:1, because no rendered pass had ever been run against
the driver PWA. No colour in the cab was changed: the over-speed panel flashes,
so darkening its fill would make a safety alarm quieter, and that is a decision
for Simon rather than for a renderer. (#349)tests/suites/95-target-size.js holds, and neither was a decision:
both read "outside this branch's lease", which is a hand-over rather than
a reason. Measured with node tools/target-size-check.js: 3
styling keys provably under 24px across the three apps, down to 1, and
nothing outside the dispatcher fails at all. The one that remains,
.er-rig__x, is untouched on purpose — it is a refusal with a
dated decision attached, and it needs a design call rather than a stylesheet
edit..dchk is the job-form check field
(jacking setup, oversize route) and the POD's "Goods received in good
condition", which ships ticked, so the target that matters is the one a
driver uses to turn it off at a roadside with a glove on. Nothing
evidentiary changed: no signature capture, no timestamp, no chain of custody,
no retention — the box is 4px bigger. FR-OF-15/FR-DR-10.<label>. The whole row accepts the pointer, so the
declared box is a floor under that row rather than the whole target, and no
markup and no JavaScript changed. That is now asserted, along with a guard these
two files specifically need: neither driver/index.html nor
portal/index.html loads a single css/ stylesheet,
so a 24px rule written in the dispatcher's CSS would have satisfied the static
tool and changed nothing a driver or a customer ever taps. flex:none
goes on with the size, because a checkbox in a flex row is shrinkable by default
and a declared 24 is only a guarantee once it cannot shrink under a long label.elementFromPoint
returns the input at five points inside each and the wrapping
<label> one pixel outside — and a click on the box's
bottom-left corner and a click on the far corner of the row both operate the
control, which a size does not prove. The accept row cost 0px of layout
(323 × 138.44 before and after, because the sentence beside the box is
taller than the box); the cab's check row went 353 × 26 to 353
× 30, centred, so nothing moved sideways. Written up as §2c of
docs/audits/wcag-2.2-aa-audit.md, which also corrects that
document's "nothing on the portal measured under 24px" — it was
always a claim about the three screens its rendered pass visited, and
#accept was not one of them.NF.Rates.levyBasisFor) that answers customer first and
organisation second and names which of the two it came off. That is
FR-RT-21's second half as well as its first: the rule is data, so
answering it is a one-word configuration change rather than a code change.OQ-30 is answered for nobody: the organisation default
is absent from the settings object rather than holding a value, and there
is no commented-out default either — asserted over the raw source,
comments included, because a runtime check cannot see a literal behind a slash.TR-120), the levy
stays "a separate calculated line, never baked into a quoted rate"
(FR-RT-24), and the record of what the charge was priced at
survives, so the disclosure added in the last release still reads.
A rate that fell between price and delivery is a credit, because the
direction is read rather than assumed.FR-QD-14) — including the case where nothing
was on file and the document carried no clause at all, which a naive fallback
would get wrong. Nothing is printed where nothing is on file, where the quote
carries no levy line, or where the stamped rule is one this build no longer
knows: an absent sentence cannot mislead, and "the basis is not on file"
on an outgoing document is an internal fact wearing a clause.XP-990, the number the issue asks for: letting the
unset case silently default to delivery reddens 62 assertions and
to quotation reddens 57. Beside those — shipping an
organisation default 64; an unreadable value stored against one client
falling through to the organisation rule 51; folding the rule into the
rates' own state and losing the disclosure 19; rewriting the levy row
instead of adding a re-struck line 23; the writer coercing an
unrecognised rule instead of refusing it 12; dropping the frozen base
10; dropping the sentence from the quote 9; and drawing a held
levy in the settled colour instead of hatched 2 in the browser check and
0 in the regression suite, which is the whole argument for reading a
rendering in a browser.
Every count sums with its own pass count to exactly the baseline, so none of
them is an abort — and three of them were, at first. The row-rewrite
break originally reported 2 FAILED over 21,473 assertions (a derived
difference of 183, not a measured count) and the two default breaks reported
33 and 29, all three because dereferencing an absent row aborted
the group: this repo's own counting gotcha, happening to the guard that would
have caught it. The fixture grew non-throwing shells, and the numbers it then
measured were larger in the two cases that matter most.
409 assertions, day-independent by construction, swept across eight clock
settings derived at run time and three spawned timezones with a byte-identical
digest, plus 15 in the browser check.#/audit screen's collection
dropdown and the backend's collection registry are two copies of one fact, and
until now they were checked only one collection at a time — fourteen test
suites each asking “is my collection in the list”, a shape
that can see what is present and never what is missing. Measured against the
registry, they had drifted in both directions at once: ten
collections were audited on the server with no filter option at all, so every
change to them was recorded and unreachable — including
vehicles and trailers, the fleet master records, plus
clientTerms, locations, routeAttachments,
inbound, assetContracts, truckMaint,
coaching and recoState. And two options named no
collection anywhere: clients, which nothing in the app defines, and
routes, which is in-memory map geometry that is never stored and
never audited — two filters that could never return a row, and whose empty
read as “no changes match these filters”. All twelve are fixed. The
net count difference was six; the real difference was fourteen, which is why the
new check names collections and never counts.backend/tests-collections.js asserts the viewer's
list and the registry's audited collections are the same set by name, so
neither side can move without the other going red, and the red says which
collections rather than which numbers. A collection may still be listed ahead of
its server-side half — intents and termsVersions
are — but only by being named in a declared exception list that is itself
checked against the registry, so the day the backend catches up the exception
goes red and has to be deleted. That is not hypothetical: assetDocs
carried the same warning as a comment for months after the backend had
registered it, and nothing could notice.intents has said so properly since it was added;
termsVersions had sat under the reassuring wording the whole time.
Both now say, in place, that the collection is not registered with the change log
and none of its history is being kept — and that this is not a statement
that nothing has changed.Two more of the lists that decide whether a record persists are now compared against the one collection registry, instead of being kept in step by hand. FleetView keeps one registry of every collection the system stores, and several separate lists elsewhere have to agree with it — what the browser sends, what the browser installs when it reads the database back, what the change log can be filtered to, and what a customer's portal is allowed to receive. Three of those were already compared automatically; two were not. The first gap is the one that matters: a collection the browser sends and the database stores, but that the browser never installs when it reads back, works perfectly on the machine that typed it — that machine answers every question off its own copy — and is invisible on every other device, for ever, with nothing on any screen saying so. Measured: removing one collection's install step reddened nothing anywhere in the tree before this change, and now names the collection by name. The second gap is a list on the server saying which collections a customer's browser may read, which nothing compared to the registry at all — so an entry naming a collection the database never stores could sit there indefinitely. The one entry of that kind today is a deliberate one, recorded ahead of its server-side half; it is now excused by reading the application's own declaration of it rather than by a fourth hand-typed copy of the same sentence. Both checks name the offending collection rather than reporting a count, and each proves its own reader against fabricated input first, so a reader that silently stopped working cannot be mistaken for two lists that agree.
Two settings that existed, were read by the running system, and could not be changed by anybody are now deploy options — and the one that governs an alarm no longer stops saying that nothing is sent. A parameter that exists and cannot be set turned out to be a class rather than an incident, and these were the two real gaps a census of all 44 of them named.
How long a stuck email may sit before the system calls it a problem was declared, read on every poll, and reachable by nothing: the deploy had no box for it, and the deploy is the only way the system is ever updated. So the decision about it could not be taken even by somebody who had taken it. It can be set now. A value it cannot use is rejected by name before anything is built, because the code reads an unusable number as nobody has decided — which would report a setting somebody chose as a setting nobody chose. Leaving the box empty still means nobody has decided, and a deliberate zero means the first minute counts: those are different acts and the log says which one happened.
And setting it no longer quietly removes a warning. While nothing was decided, every poll printed the count, the age, and the words "nobody is being told". That sentence disappeared the moment a threshold was set — exactly when somebody is most likely to assume an alert now reaches a person. It does not: there is nowhere in the system to send it, which is the other half of the same decision and is still not built. All three settled answers now say so in place of that sentence, and a check measures it against the system rather than asserting it, so the day somebody adds a recipient the wording is forced to change with it.
How many days of database backups are kept was the other. Editing the built-in figure could never change a running system, so confirm and set the retention was not doable at all. It is a deploy option now, bounded to what the database itself allows, with two warnings on it: lowering it throws away recovery time that raising it again cannot get back, and the last change to it went out with nobody watching for an interruption. What the live setting is today still cannot be read from here, and that is said plainly rather than reported as the built-in number.
Nothing changes on the live system until somebody runs a deploy and types a value. What changes today is that they can — and the list of settings nobody can reach is now empty and held that way, so a new one arriving fails on the change that adds it rather than being found months later by a fix whose knob does not turn.
The mailbox poller's retry schedule was computed, stored, and obeyed by nothing. When a message could not be filed, the poller worked out how long to wait before trying again, wrote that time onto the record, and showed it on the intake queue screen — and then ignored it. Every message that had failed was re-tried on every poll, which meant the six tries a message is allowed were used up in six polls: one hour, not the two and a half hours the design called for. The two settings that control how long to wait could not change anything at all.
The consequence was the one this queue was built to prevent, arriving from the other direction. If the mailbox is shut, the poller deliberately charges nobody a try, so an outage cannot mark the whole backlog as needing a person. A database outage was not covered by that rule — correctly, because a failed save really is about that one message — so an hour of database trouble gave up on every message in flight at once and handed somebody fifty of them under “we tried six times”, when the truth was “the database was down”.
The poller now reads the schedule before it re-tries anything. A message still inside its waiting period is not re-attempted at all, is not charged a try, and — this is the part that matters — still holds the poller's bookmark behind it, so it is read again on a later pass and cannot be stepped over. A message that has been given up on is never held back, so giving up still releases the bookmark exactly as before. If the schedule itself cannot be read, nothing is held back and the run says so in those words, rather than reporting that nothing was waiting.
Measured: removing the check reddens 16 assertions in the poller's suite and 0 in the queue's own — the queue's checks read source text, and no source read can tell a wired reader from one nothing calls, which is why both suites exist.
A poll now reports what it held back, and the difference between an empty answer and one it could not get. The poll result carries the number of messages deferred inside their own retry window, how many are in flight, and whether the retry schedule could be read at all — reported as “we could not look” rather than as a count of zero. Nothing identifying rides on it: counts and reasons only, as before.
intakeQueue — the record of every message the mailbox read
could not process — has been persisted, merged by id and audited since
v0.146.0, and was read by exactly one file under js/screens/,
portal/ or driver/: the change log's own filter list,
which is not a display. So #386's own design question, "what does a message
that fails land as, and who sees it?", had no answer on any screen.
Settings → Mail intake is that answer: every failed message by id,
its attempts, how old the oldest dead letter is, and which of the three failure
classes it hit. Its module doc's own "two front-end wiring lines still open"
was stale — both landed in the same merge the queue did
(87dbefbc) — but nothing displayed the collection and nothing
anywhere tested the one line that had landed.unknown out of transient,
and a screen that drew them alike would print the first sentence about the second
situation. A failureClass this build does not recognise —
including null — gets a fourth rendering rather than
being coerced into one of the three.intakeQueue key in the document at all (which is the state a live
site is in today, #386 having had no backend deploy) is hatched with the
count as a dash; an array with lastSync() still 0 came out of
this device's cache and says not a measured zero; the database answering
with an empty queue is dimmed and dashed, a known negative. Even that one
refuses the two wider claims in its own words: an empty queue means nothing
has gone wrong, never the mailbox has been polled (a row is minted on
failure only) and never somebody is watching.unconfigured prints the
count and the age in every state including a measured zero, because an
unmade decision suppresses the alarm and never the fact — the threshold and
the recipient stay unset, which is Simon's decision and not a gap this screen
filled in.deadAt || firstFailedAt, so a dead-lettered row carrying no readable
deadAt borrowed its first-failure time and sorted to the top
as the one waiting longest — while the figure three inches above it, which
reads deadAt alone, correctly called that row's age unknown. Two
numbers on one card, one of them wrong. The fall-back is gone: each group is
ordered on its own stamp, a row whose stamp cannot be read sinks rather
than claiming to be the oldest, and it says so on the row.PUT /state strips the
key, so a Retry button — or a tick that cleared the needs-a-person flag
— would be a browser rewriting the record an incident review reads first.
The suite refuses <button, addEventListener,
Store.save and NF.intakeQueue = in the screen's own
source; the browser check counts every control inside the screen at 0 and
reads every PUT /state body for the key. And the row prints no
subject and no address — the fixtures carry both, in fields the screen must
ignore, because the state document is read by the client portal an anonymous
customer reaches from an emailed link.tests/run.js and 10 in
tests/manual/intake-queue.js; folding unknown into
transient, 5 and 4; swapping the hatch and the
dimmed-and-dashed treatment, 4 and 3; an unset threshold reporting
clear, 4 and 1. Two rows are the reason both suites exist.
Unlinking css/modules/iq.css reddens 0 in
tests/run.js and 7 in the browser check, which is why
every hatch claim there reads getComputedStyle().backgroundImage
for a gradient and never a class name. And the row-sort fall-back first reddened
0 in tests/run.js because the ordering is DOM-coupled —
a node-side group was added for it, since the browser suite runs in one CI shard.
That group's first version scored 1, which was a finding about the test:
the row prints "listed last among the dead rather than first" and, with
the fall-back in place, printed that sentence while sitting first. The
sentence is now asserted against the position rather than merely for its
presence.#/intakequeue (reached from Settings) has a Next attempt
column, read off nextAttemptAt. That is the time the mail poller's retry
gate actually obeys: a message still inside its backoff is not re-attempted and is
not charged for the wait. Before this change the schedule was enforced and shown
nowhere: nextAttemptAt appeared 0 times across js/,
driver/ and portal/. There are four answers, kept apart.
Due in 40 min means the time is in the future, and it is rounded up, so it
never reads "due in 0".
Due now is painted differently from due in and says how long ago the
message came due. That figure shows when the poller is not running.
Not scheduled covers a dead-lettered or landed message. It is drawn
dimmed and dashed, because a dead letter is finished rather than unknown:
it is waiting for a person, not for a retry.
Where the time is missing or unreadable, the column is hatched and never
says due now.
The readout is a pure function of a clock the caller passes in. The screen parses
the time the same way the backend does, and a test checks the two against each
other, because V8 reads the bare string "2099" as a date and the gate
really does hold such a row back.
Deliberately breaking each rule turned these tests red:
drawing not scheduled hatched: 10, plus 3 in the browser;
letting an unreadable time fall through to due now: 31, plus 4 in the browser;
the same fall-through from an unreadable clock: 10;
the readout reading its own clock: 36 (and 0 in the browser, whose own clock
is the caller's, which is why the tests use fixed times from 2019 to 2044);
flattening the hatch: 8 in the browser and 0 in the logic tests.
Front end only; live on merge (#386).store-dynamo.js
survived because twenty files still selected it — so those suites were pinning
live handler behaviour through a dead lens, and the file could not go without
reddening all twenty. Each now drives an in-memory double at the storage seam
and asserts what the handler asked the store for rather than what a dead
adapter rendered it into. @aws-sdk/client-dynamodb and
@aws-sdk/lib-dynamodb go with it (116 lines out of the lock file),
and an explicit STATE_STORE=dynamo now refuses by name and
says where the data actually is, rather than reading as a typo.UnprocessedKeys re-request and throttle retry, the 25-item
BatchWrite ceiling, the per-batch isolation, the empty-but-not-last page — none
of which has an equivalent under PostgreSQL, and every one of which has its
Postgres counterpart already covered. Four seam-level claims went in beside them
(a snapshot is one write set, GET /state is one table read, the
handler never re-drives the store itself). The one that got stronger is the
snapshot write: the old assertion proved the adapter would tolerate an
undefined field, and the new one proves the handler never sends one —
which is the defect that took live GPS down. Root suite 20,429 → 20,423;
backend 12,187 → 12,186, the single difference being one fewer file under
backend/src/.audit#), the rows received from FMS before import
(fmsInbound#) and the driver's ordered log of every time stamp
(intent#). The backup's own excluded list, the part meant to
tell a reader who was not here what the file does not contain, named only the
five telemetry kinds — so a file handed to ICT or to a restore designer read as
though the change log was in it. The change log had been left out unnamed since the
backup engine was written; the other two joined the skipped set later with nobody
noticing, because the test compared the list with the telemetry constant rather
than with the one the read actually uses, so both sides of the check were the same
five. All eight are now named, each with its reason, and the three that are not
telemetry say so. The test now compares the list with the constant the read really
passes, taken from the running code, and with what the capture actually asked the
database to skip — so the next kind of row added to the skipped set without a
name turns the suite red by name. Measured: dropping one of the three names reddens
4 checks in backend/tests-backup.js, adding a fourth prefix to the
read reddens 3, and the backup file from before this fix reddens 8.
Nothing about what is backed up has changed: whether a backup should
hold the FMS rows and the intents, rather than just name them, is an open
decision, and the file says so. This is backend code, so it does nothing until the
backend is deployed by hand.A backup that was written and never listed no longer reads as a backup that worked. A capture can end one way that is neither a success nor a failure: the file reaches storage and the entry that would let anybody find it does not. The backups panel on Settings drew that as a plain success, and the card went on saying "No backup has ever been taken" underneath it — the one line on that card stated as an established fact, over a file the browser had just watched being written. It now gets its own hatched notice naming the capture id, which with no catalog entry is the only handle anybody has on the file, saying that the file was written, that the entry that would find it was not, and that nothing on the card can tell you whether that file is still there or what it holds. The notice outlives the button's message and a later successful capture does not clear it, because producing an unfindable file is not undone by taking another backup. While that notice is up, the list below is described as a record of what was written down rather than of what exists. The storage location is still never printed.
The same card no longer claims the fleet's GPS history is covered by the database's own backups. It said so in one clause and then, two paragraphs lower, correctly said it cannot state how far back the database can be restored. Nothing in a browser reads that setting: it belongs to the database cluster, it is not in the site's configuration, and a value in a deployment template is not a reading of the live cluster in any case. The card now states which data is left out of a FleetView backup, says plainly that how far back it can be recovered is not something this page reads or guesses, and warns that no figure here must not be read as an adequate one.
settings.purgedAt,
#533, #934) was consulted through five verdicts, two of which read the browser's own
nf_state_v2_at timestamp: a cache that carried no time was kept
(undatable — "we cannot prove it is stale"), and a cache written
after the purge was kept (cache-newer — "it has agreed with
the database since"). Neither reading survives contact with a tab that is
left open, because that stamp is rewritten on every autosave, so it is
always newer than the purge and the exemption never lapses. Measured on a live
dispatcher's machine three days after the database was purged to nought
drivers: 2.97 MB cached, 20 sample drivers and 23 roster
lanes still on the board, and no amount of reloading shifted it —
apply() merges by id and never removes, so pulling a clean database
does not clear what is already in memory.cacheAt is no longer consulted at all, and
already-dropped is the only exemption left. That stamp records
when this browser last wrote, never when it last agreed, so it can
say nothing about whether a purge has been honoured — the write time is
not a read time trap already written up for savedAt in the
restore brief, arriving one layer down. The only stamp that answers the question
is nf_state_v2_purgeSeen, because this code is what writes it. A
cache is now discarded even when it is dirty, deliberately: a purge is an
explicit operator act declaring the database authoritative, and on the machine
this was measured against the cache was dirty precisely because its
writes were failing, so a not-if-dirty clause would have exempted the one
browser that needed it most. The driver's outbox is a separate store and is
never touched, so no capture, signature or POD is at risk. Measured on the whole
tree: restoring cache-newer reddens 3 assertions, restoring
undatable reddens 1, and both restated rather than deleted so the
record of what was decided survives the change.purge-records.js --write --stamp-only
writes the purge stamp — the timestamp that makes a browser holding a
stale copy discard it instead of merging it back over the database
— and removes no records at all. It sends only
settings, carries neither of the two destructive spellings
(replaceCollections, replaceBoard) so it cannot remove
a record even by accident, prints the counts it is leaving alone before
it writes so nobody reads the run as a purge, and reads the stamp back
— failing unless the value it wrote is the value it gets, because "the
request returned 200" is a statement about the request, and the whole point of
the mode is that somebody is going to rely on the stamp. Use it after any reset
that did not announce itself: an import that went wrong, a restore, a
hand-corrected row.vehicles and trailers now on the purge's go list, a
re-run purely to write one timestamp reported 274 records to remove
— 76 trucks, 191 trailers and 7 queued mail rows. The stamp is reachable
on its own now, so the safe act does not require the dangerous one.settings key — which the test dereferenced
bare, throwing a TypeError that aborted the rest of the
file and took the two groups below it with it. Measured on the final
suite: removing the mode now reddens 43 assertions where it previously
reddened 2 and crashed; the read-back check 2, the non-map
settings refusal 3, the dry-run gate 2. The fake API
also grew the one broken-backend behaviour that can reach a disagreeing
read-back: a write it acknowledges 200 and then does not apply.settings as the whole map and the server stores it wholesale, so a
tab left open from before the run pushes one back carrying no stamp and
erases it — and that tab, having never seen the stamp, takes every
other browser's discard down with it. One open tab defeats the mechanism
silently, with no error anywhere.BackupRetentionPeriod as Some interruptions on update,
while the backup window, the maintenance window and deletion protection are
No interruption — and retention is precisely the one still open
to change, since whether Aurora’s 7-day window is raised toward 35 is an
undecided question. None of the four replaces the cluster, which was the true
half of the old sentence. The interruption was not measured
when the change went out on backend deploy run #127: we did not observe one,
which is not the same as observing that there was none. (#535)A report over a window that holds no record now says so, on every screen that reads recorded movement. Between 25 and 31 August 2026 the recorder wrote nothing at all — the vehicle register was empty, so it had no vehicle to record — and those days can never be filled in, because the rows were never written. Until now nothing on any screen said that. A read of that period came back complete and correct, every day answered, and the answer was a week of a fleet that appears to have stood still: a truck that was not recording and a week nobody has read exactly the same. The Fleet Overview, the asset page, the speeding pages, Lost Revenue, the Events log, the assistant's panel and Fleet movement now all carry one sentence when, and only when, the period they are showing reaches those days — naming the dates, naming how many of them the window touches, saying that what the fleet did then is unknown rather than nil, and saying that recording resumed on 1 September 2026 so it reads as a dated, finished gap and not as something broken today. It is drawn in the same hatched treatment the app uses everywhere else for "we could not look", never as a zero and never as a percentage. A window that does not reach those days says nothing at all, which is why Fleet movement's own last-24-hours view is unchanged. The dates are held once, with the measurement they came from and the document it was written up in, rather than typed into a screen.
assets collection went to
zero in production around 24/25 August 2026 and was noticed eight days
later — later than any backup window could have helped, which is
why the thing that protects this register is detection rather than retention.
Both register screens (#/assets and #/register) now
carry one line saying what the last database read actually carried for it, drawn
hatched wherever we do not know. Measured on the real
reconcile() path at 9f4c50e5, three different facts
arrived at every screen as one rendering — a browser holding 1,190 rows
over a database answering none, a database answering none over a browser holding
none, and a document carrying no assets key at all —
NF.assets.length and syncState() being identical on
two of the three and synced on all three. (#536)#/register's “No assets imported yet — use Import
from Fleet Dynamics”, are positive operational instructions: they told
a dispatcher the register is empty and the next move is to fill it. Measured,
they said exactly that for a read that failed, for a session in which no read had
landed, and for a document that carried no asset register at all. They are now
behind a licence that only the one genuinely known negative — the database
answered zero rows and this browser holds none either — can satisfy.
Anything else prints the hatched note and stops. On #/register that
matters twice over, because the withheld instruction is an instruction to
import over a database nobody has looked at. (#536)assets array has been refused rather than applied
since the loss was repaired — that guard is right and is unchanged —
but a browser holding the only surviving copy said nothing about it and kept
reporting synced, because the sync really had succeeded. There are
now six answers where there was one number: we have not
looked, we could not look, no database is configured,
the database expressed no opinion, the database says none and we
hold 1,190, and the register is empty. Only the last of those, and
the local-only build, license a count. The divergent one names the rows this
browser is the last copy of and says to leave the tab open, because
snapshot() is writing them back every five seconds. (#536)Why the vehicle register was lost in August is written down as undetermined, with what was ruled out and what would settle it. It had not been recorded anywhere, which reads as forgotten rather than as investigated. The note states what is now known — the loss falls inside 24 August 2026 read in UTC, which is 24 August 08:00 to 25 August 08:00 Perth time, and is a tighter window than the issue carries — what has been eliminated by measurement, the three explanations that are still open, and the four checks that would separate them, one of which needs no cloud access at all and one of which stops being possible in November. No cause is claimed. It also reconciles the two different date ranges the issue and the engineering note each give for the same seven-day gap: they are one gap described in two time zones.
710 − 559 arithmetic of
which 137 is a trailer track flag rather than a missing type and 2
rows have no register row at all, and 23 is right on the right predicate and now
stale in the good direction. Re-measured through the shipped classifier: the
three spellings that landed on 6 September type 17 of the 23, so the
hand-typing job is six rows — NF635, NF1330, NF1331, NF1048, NF1392
and NF1393, three of which are a person's name typed into a
vehicle-description field. The full 23 with unit id and depot are a fixture in
the suite, so the worklist is in the tree. NF.ASSET_NOISE matches
0 of the 23: #537's own stated cause for them, that they carry
status-note descriptions, is false.MAN pattern would recover two rows and mis-type a man-lift as
a truck, so it is declined — with the number, and with an assertion that
reddens if somebody adds it in silence. Adding man| to the
rigid alternation changes exactly three of the 83 real
register strings this repository holds, two of them in the six-row
residual. In exchange it claims any description whose standalone word is
man: MAN LIFT, MAN CAGE, MAN BASKET
and bare prose all come back rigid, and a man-lift is plant at
costPerKm 0.00 against a tracked rigid at 0.85. The risk
#537 names is also not the one that bites:
/\bman\b/i.test('MANITOU') is false — there is no word
boundary after MAN — and plant is tested before
rigid anyway, so the guard the issue asks for would not have
guarded anything. The refusal-versus-guess call stays Simon's; what changed is
that the declined option is now costed in the suite rather than rejected
in a comment.track:false type still
appears on the live map through the rotating tier and reaches
no movement history, ever, which is how 137 assets vanished from history
without one of them losing a type. The seed ticks track on
prime and rigid only, and the taxonomy was reset to
that seed by the same event that blanked the register, so no trailer being
recorded was never a decision anybody took. The five untracked types are
one type's worth of assets — 191 trailer rows and
0 dropdeck/flattop/float100/dolly,
corroborated by backend/load-fleet.js deriving 191 trailer-pool rows
on 6 September. And the answer that decides it: the feed carries trailer
positions — 137 of the 191 were recording on 24 August, and the cab's
trailer picker orders them by their live fix out of
GET /fleet/live with "~30 of ~190 don't report at all"
— so ticking it takes coverage from 568 of 573 to roughly
705 of 764: 137 more assets with a history, at a ratio that reads lower.
The cost is real (573 → 764 against a FLEET_SLICE of 120, so
every tracked asset's refresh stretches by about a third), so the flag is
untouched: whether trailers are tracked is Anthony French's, the slice budget is
Simon's, and it is a data edit on the Types editor rather than a deploy.backend/load-fleet.js's
76 as prime movers having grown "50 → 76 — somebody has been
typing rows by hand". It is the prime pool, and
NF.assetTypePool maps both prime and rigid
into it: 50 + 26 = 76, which is that brief's own histogram exactly, unmoved. The
pool mapping is now asserted so the two numbers cannot be read as one again.3T C/CAB, two that read
Toyota Corolla and one that read 8T FLAT BED — every
one of them a spelling of something it already recognised (crew cab,
Camry, FLATBED). All seventeen were recording before the
August re-import. It still refuses MAN as a bare truck marque and
still refuses the register's misspelled COMPRESSSOR: those trade a
refusal for a guess and are decisions for the business, not spellings. Measured:
the seventeen were 0 of 23 before and are 17 of 23 now, and the
four descriptions that are somebody's name are still — correctly —
left alone.track live ticked on Trailer. An asset whose
type is not ticked reaches the live map and no movement history,
ever, which is how 137 trailers stopped being recorded
in August without one of them losing a type: the taxonomy was reset to the
shipped default by the same event that blanked the register, and that default
had never been a decision anybody took for this fleet. Simon Anderson decided on
18 September 2026 that it should be flipped regardless of the GPS fitting
programme. Measured on the register shape last read from the live document, the
tracked set goes from 573 to 764. (#537)#/assets. Nothing in the
code can do that, and this release does not claim to have. (#537)Trailer, so they are recorded too; when they are retyped to
Dolly they will drop straight back out, one at a time and silently,
unless Dolly is ticked in the same gesture. It has deliberately not
been ticked here. (#537, #791)SPD_MAX_READS, GEO_MAX_READS
and ROLL_MAX_READS cap day partitions, and the comment beside
them said 128 kept "the fan-out to a few socket waves rather than thousands of
queued round trips" — a true sentence about DynamoDB, and the wrong unit since
PostgreSQL became the sole store. One partition is one query that the adapter
keyset-pages at 500 rows, so it costs floor(rows / 500) + 1 round
trips: ×2 at this fleet, because a daily-summary partition holds one
row per asset that recorded and the recorded figures are 710 and 568 assets. So
the widest window any screen asks for is 91 partitions / ~182 round trips
against a cap of 128 / 256 — neither is thousands, which is why the old
sentence's conclusion survived its unit. The measurement now lives once, beside
ROLL_MAX_READS, with the other two caps and both module documents
pointing at it. Nothing about what the routes answer has changed. The cap
was deliberately not re-sized: trimming it on the strength of the
multiplier would drop a day of a compliance record to fix a cost that the read's
wall-clock deadline already holds.#track/<cn>?t=<token> link declares no account, so its page read
GET /state?scope=portal with nothing else and was handed the widest answer the route
gives — on the generated sample, all 50 con notes, 16 quotes, 65 job threads and 12 customers, in
the tab of whoever held a forwardable link to one of them. The page now presents the link it was
opened on (?scope=link&cn&t), and the route resolves the con note's own tracking
token through the same point read and constant-time compare POST /track/shown and the
scoped telemetry routes use, then answers that con note, its split family, and the rows its page
reads — the truck on it, its customer and that customer's terms, the yards and places its route
names, and the settings — from point reads, before the table scan. No quote, job thread, invoice,
rate agreement or driver rides. A wrong token, an unknown id, a missing parameter and another
shipment's token are one 403, byte for byte; a store that could not be read is a 503, never a
refusal. Measured: in the browser check the link page holds 1 con note of the 87 its fixture
document carries and PUTs no collection at all; and for each of 53 con notes — the 50 the sample
generator makes plus the three parts of one split made through NF.Splits — the shipped
portal page draws the same thing, byte for byte, on the link read as on the portal read, with an
in-transit shipment drawing its map rather than the "🕗 Not yet dispatched" a con-note-only answer
printed.
The acceptance link is unchanged and stays on the portal read, because the portal is the
writer of record for quotes, a singleton written wholesale.deploy-backend.yml, and until
then the route answers the new scope name with 403 scope_unknown. Any 403 on the
link read — that, a revoked token, an id retyped in the wrong case (the route matches exactly;
the page matches case-folded) — puts the page back on the portal read it has always made, for the
rest of its life, so the link still opens and the change is never worse than before it. A 503 or
no network is not a refusal and retries the link. Needs a backend deploy before any link is
actually narrowed.?scope=portal drops
the collections and &account= drops the rows inside three of them
— and neither reaches what is already at rest, because
apply() merges by id and never removes, which is
right: a short read must not blank a row this device already knew about. So the
narrow answer merged into the wide one and changed nothing on the disk, and the
next autosave wrote every account's rows back out.
The stamp is what made it invisible. The cache is stamped with
the declaration it was written under, and that stamp names the account — it
stops a customer switching account on a shared device keeping the previous
account's con notes, but only across boots, where the next boot reads a
foreign stamp and drops the copy. Within a session it is defeated by the save
that rewrites the cache under the new declaration before any boot happens. The
stamp was therefore a true claim about the declaration and a false one about the
rows underneath it. They now agree by construction rather than by timing.
Three answers per row, never two, and they are the route's own: a
row naming this account is kept, a row naming another is dropped, and a row
carrying no account value at all — absent, null or empty
— is kept, because it is not another account's and not
this one's either, and dropping it would lose a customer's own work on the
strength of a field nobody filled in. An account id of 0 is a real
account, tested explicitly rather than by falsiness. (#569)snapshot() still carries every row, so nothing here
is ever a request to the database to forget one. That is the same reason
quotes is held out of the row scoping altogether — it is a
singleton the portal is a writer of record for, and a narrowed copy that reached
a PUT body would delete every other account's rows. Every collection
that is narrowed is sharded and merges by id, which the guard asserts
across all three files that decide it.
And the job thread is held out too, which took a browser suite to
find. The table began as a copy of the route's, asserted equal
to it, on the reasoning that a collection narrowed on one side and not the other
is a bug. That reasoning is wrong, and the asymmetry it misses is the whole
safety argument: on the wire a narrowed answer is merged, and a merge by
id never removes, so the route may narrow anything; at rest the narrowing
removes, and this is the only place in the store where rows a device
already holds are deleted rather than simply not added. An equality assertion
answers the harder question with the easier one's answer. Measured on the real
page: signed in as one account, the copy at rest kept all 19 quotes and dropped
the threads from three to one, so a quote sat in the document with
NF.Jobs.byQuote() answering null for it — a
derivation narrowed while its input was held wide, which is this repo's own rule
about a derivation being a pure function of its state, broken from the inside.
It does not self-heal, and had it done so it would have re-minted those threads
under new ids. The at-rest table is now a named strict subset of the
route's, with a reason recorded against every exclusion. The unsaved-work baseline is also still
taken from the wide document: a baseline taken from the narrow bytes would differ
from every snapshot for ever and turn a quiet portal into a write every five
seconds. And an app that declared no scope — the dispatcher, the driver PWA
and the server-side realms — is never narrowed by an account, whatever is
set on it, because for those the cache is the design and a driver in the
Pilbara works off it for a whole shift. (#569)tests/suites/126-portal-cache-account.js drives
the shipped writer and reads back what actually lands, never a hand-built object;
it asserts no customer value anywhere, only counts, booleans and shapes, because
a failing assertion prints what it got into a log; and it establishes first that
the document really holds several accounts and that the one under test is a
strict subset, so “the others went” cannot pass vacuously. The account
is whichever the generated document holds most rows for, resolved at run time, so
nothing here is a fixture that passes on a Tuesday.
The first finding: a rule written twice could not be shown to bind.
The refusal to narrow an unscoped app was written out in both narrowing
functions, and each copy masked the other — removing either alone reddened
nothing, with both assertions about it sitting green. It is one
reader now, and breaking it reddens 2. The second: the closing tab was
covered only by a source read. The unload write bypasses the ordinary
cache writer, and unwrapping it reddened exactly one assertion
— the source read — because every group drove the autosave and none
drove the unload, which is the write most likely to be a session's last and
therefore the copy still sitting there at the next visit. The same break now
reddens 3. (#569)NF.Store.cacheVouch() reports the verdict
and what was done about it as two separate facts, because "expired" and "therefore
gone" are not the same statement.overpass-api.de — a free, volunteer-run mapping service that
Northfleet has no agreement of any kind with. It went out from the phone itself, not from
Northfleet's server, on whatever mobile plan the driver happened to be on: measured at one
position every 13 seconds while that screen was open, about 3,324 times in a twelve-hour
shift, and up to 1,800 an hour before an earlier fix put a ceiling on it. It needed no
setup at all — no address, no password, no switch — so it happened on a driver's own personal
phone exactly as it did on a tablet bolted into a truck. The Drive screen now asks
Northfleet's own server instead, which is the same source the office board already uses
to check a speeding alert, so the cab and the office finally agree about what the limit is.
Northfleet's server looks the road up with Main Roads WA and remembers the answer for
each 500-metre stretch of road for a week, so most of those enquiries now reach nobody outside
Northfleet at all. The disclosure screen a driver can read from the sign-on page has been
rewritten to say exactly that, including that the old service is no longer contacted. Being
honest about what this does not do: the position still leaves the phone, to Northfleet;
and for a stretch of road nobody has driven this week, Northfleet's server does pass a real
position on to Main Roads — a Western Australian government service, from a server in Sydney,
with no phone or driver identified.FD_FIX_TZ_OFFSET_MIN is 0, so Fleet Dynamics' bare local
wall clock is read as UTC and every fix lands one zone-offset in the
future. fixTime's rule 3 refuses a future fix — correctly —
so a fix survives the parser if and only if its true age exceeds the
offset. The timestamp filter and the staleness filter are therefore the same
filter: the only rows that can carry a date are the ones that stopped reporting
long ago, and the sort's top band — measurably moving — is
necessarily empty, which is why the ordering is working perfectly and has
nothing to rank.
deploy-backend.yml's
own smoke test — the instrument backend/template.yaml built for
exactly this and which nobody had read: futureTime 20 of 22,
19 of 26 and 19 of 26 successful reads, with noTime,
badTime and ambiguousDate 0 on all three, so a
future parse is the only thing refusing a fix today. The snapshot's dated share
(210 of 692) matches the per-sweep acceptance rate (7 of 26) to a tenth of a
point, which is what stops it being one bad sweep. Three buckets, clustered
causally rather than uniform: 64–73% undated — the trucks working right
now, refused because their fix is fresh — 27–36% dated and all older
than the offset, their apparent age understated by it (a row reading
12h is a ~20-hour-old fix); and a ~17-minute sliver of true age around the
offset that would render as an ordinary live row with a confident speed.
noTime, badTime and
futureTime apart; fixTime returns the same bare
null for all three, so the distinction dies at the row.
FdFixTzOffsetMin=480 and read futureTime off that
deploy's own smoke test: the two hypotheses give opposite readings on the
counter that already exists, so it is confirm-by-effect on a sticky, reversible
parameter rather than a guess. Also recorded: diag rides every
/fleet/live poll and is read by zero property accesses across
js/, driver/ and portal/, so the one number
that diagnoses this has been crossing the wire four times a minute, unseen.NF.liveDepots), built the way retired pricing zones already work,
so there is no second opinion about which yards we still run. (#603)LOC‑FMS‑4412
→ LOC‑FMS‑9013, which is what an import produces before
the customer and site records land) are each drawn hatched now, with the reason
in words. The key case was reaching the driver: the run sheet in the truck read
that key out as though it were the name of a town, and a database key is never
shown to a driver or a customer. A run that genuinely records no route — a
fatigue break, a yard shunt — still draws nothing at all, because that is
not an unknown. And a route a dispatcher typed in themselves is still printed
exactly as they typed it: their words are the best thing we hold. The empty-return
finder now declines to sell the space on any leg it could not look up, rather
than treating it as a truck that comes home loaded.document.querySelector('.jsupport__route'), which answers
whichever support row is drawn first — and whether the demo roster puts a real
one ahead of the fixture depends on the day. On a day it does, the check read a
real yard that resolves, so the hatch assertion failed for a reason unrelated to
what it guards, and the "refuses the key" assertion before it passed
vacuously, confirming a real place name carries no database key. It passed
270 of 270 on 20 Sep 2026 and failed 3 of 3 on 23 Sep at the same commit. The
row is now found by the title the fixture was given, and a miss says so by name.To:. It built
mailto: + encodeURIComponent(c.email || '') — and no
consignment has ever carried an email key, measured 0 of 8 literal
rows, 0 of 50 loaded and 0 on the shipped build, against a customer master
record of exactly abn, id, name and
ref. So the || '' turned an absent field into an empty
recipient on every single consignment, and the body it pre-composes ends in a
bearer link to that customer's shipment, openable with no sign-in: a defaulted
recipient there does not fail, it discloses one customer's shipment to whoever
is typed next. The control is now off, carrying no href at all,
with the reason printed in the card — hatched where nobody has nominated
an address and dimmed and dashed where somebody decided this client
receives none, which are two different phone calls. Copy link and Copy update
message are unaffected: choosing your own recipient in your own mail client is a
different act from this app addressing one. There is deliberately no
fallback — every recipient the app can resolve today is whoever last wrote
in, and an enquiry address on the thread is now named as the thing we are
refusing to use rather than quietly used. Reintroducing the composition reddens
2 assertions in tests/run.js and 6 in
tests/manual/con-note-splits.js; collapsing hatched into
dimmed-and-dashed reddens 6 and 2; making the resolver fall back to the enquiry
address reddens 6 and 0.NF.ClientTerms.notifyTo and NF.Tracking.recipientFor —
one reader for “who would this go to”, with four answers rather than two.
A nomination on the client record resolves through the one contact store, so the
archived-but-still-named behaviour and the person-or-shared-mailbox answer arrive
unchanged; nobody has nominated one, nominated nobody, we could
not look and a resolved address are told apart, and the hatch-or-dim decision
is made once in the reader rather than separately on every screen. This is a
reader and there is no writer, no editor and no new field on any master
record: where a customer-facing address is meant to live is still open and
is Simon's, and an extra key on a customer record is destroyed by the next ABN
correction. The app stops drawing an unanswerable question as an answer; it does
not answer it.TR-224). Stamped by
NF.Odoo.createConsignment, the one booking writer, so all four
booking paths across the three apps are covered by one call site. It rides the
consignments collection, which is already in
snapshot(), merged by id in apply(),
whitelisted in backend/src/collections.js and listed in
AUDIT_COLLECTIONS — so none of the four persistence places needed
an edit and there is no backend deploy.D-608-1 and is open, so every stamp taken today freezes nobody
had nominated one and carries no address at all — which is the honest record
of a booking taken while nobody had said.NF.ClientTerms.notifyTo's and the booking record is never consulted
for it; the booking record never reaches for the nomination, the enquirer's
address or whoever last wrote in. The snapshot is write-once — the moment
a commitment was made is not re-takeable — and an imported con note freezes
"booked in another system" rather than being dated to the morning it was
imported. tests/suites/135-booking-notify-snapshot.js, 135
assertions, day-independent by construction and re-run under three clocks inside
the realm — including the one persistence place a source guard cannot see:
the key is proved to survive the real NF.Store.save() and to
survive a document from a client that never stamped, because
snapshot() emits con notes through a stripper and a whitelist there
would drop the field with nothing red.An issue was closed by a sentence saying it was not closed, and the test suite went red until the incident was recorded. The linker that closes an issue when a change says it fixes one reads the words and not their meaning, so a merge message explaining that a change did not resolve something closed it regardless. The issue was reopened within two minutes and nothing about it changed. The ledger of these incidents now carries it, with the part that is new: every earlier instance was in a pull-request description, which is scanned before it is posted, and this one was in the message typed by hand at merge time, which is the second way an issue can be closed and the one nobody checks because it is written last.
Closes #617, #628 closes #617 and does
nothing whatever to #628 — GitHub binds a keyword to the one reference
that follows it — and the fixed-but-open issue that leaves is
indistinguishable from real work. Measured over 346 merged pull requests and
264 commits: a reference first after a keyword closed 12 of 12; a
reference after a comma closed 0 of 37. It runs the other way too, and
worse: the parser is lexical, so “rather than closed: #66, …”
closed #66 two seconds after a docs-only merge and it is still closed. The
guard has read this repository's commit messages since #638 landed; it now also
reads the pull request body, which is where the defect originates because a
squash merge composes its message out of it. Last step of ci.yml's
test job, if: github.event_name == 'pull_request', so
a push to master — where there is no body to read — skips rather
than reporting a clean scan of nothing.env: variable and never through ${{ }} inside
run:, so a description carrying $(…) or
backticks is a value and not a command — driven with a hostile body, and
nothing executed. Folding the empty answer into the clean one reddens 7
assertions; making a missing body file a pass reddens 3; removing the step from
the workflow while leaving every word of its comment in place reddens 11.0; it exits 1. The cue now
reads a window with emphasis and code markers struck out. Measured before it
was widened, over every merged pull request body in the repository and every
commit message on master — 526 bodies, 1,438 messages, 343
keyword→reference occurrences, 144 of them with a negation word within 200
characters that the old cue declined. Exactly two change verdict, both
this defect, and zero are false positives: a letter, a digit or sentence
punctuation between the cue and the keyword still ends it, which is what keeps a
real commit reading instead of the <keyword> <ref> clean.
Commit-message defects are 9 before and 9 after, so the grandfathering ledger is
untouched.NF.personRoles[id].scope) is the third of #66's three asks. The
panel at #/people printed one conditional sentence about it —
"A depot scope is recorded against this person … It narrows what the
screens offer" — and never said which depots. Measured: the only
consumer anywhere in the app is escalation ownership on the Compliance screen,
and that consults a scope solely for the roles it routes a rung to
(supervisor, complianceAdmin). A scope recorded against
an auditor, an assessor, a connoteAdmin or
a sysAdmin reaches nothing whatever. The panel now answers three
ways — consumed, inert with the cause named, or
unknown drawn hatched — and names the yards. Reverting it to the one
asserted sentence reddens 20 assertions in tests/run.js and
5 checks in tests/manual/people.js.js/screens/15-compliance.js loads before the People screen, so the
routing table and the matcher are read at render time rather than copied —
the same idiom #/groups uses to answer the same question about the
sibling field on a group, and for the same reason: a sentence about a consumer
goes stale silently. Moving the routing table moves the answer, which is
asserted. A consumer that could not be asked is unknown and
is drawn hatched, never resolved to "it reaches nothing" — the
reassuring reading and the wrong one. Collapsing that state into the negative
reddens 2 in tests/run.js and 3 in
tests/manual/people.js; deleting only the hatch rule from
css/modules/ppl.css, markup and class intact, reddens 1 check
and 0 assertions, which is why it is read as a computed
background-image and not as a class name.NF.Compliance.setRoles deliberately keeps a
stored scope when only the roles change, so a scope written by hand could never
be widened back from this screen. The options are the depot and business-unit
values people on the register actually carry, never NF.depots
— the matcher compares a scope against the person's own depot
field, and offering the depot register would be a second vocabulary, which is how
#647's group and skills-matrix filters came to select nobody. Anything already
stored is unioned into the offer, so a yard nobody carries any more is still
shown and still ticked; dropping that union reddens 2 in
tests/run.js and 3 in tests/manual/people.js,
the browser half driving the real Save and catching the scope being silently
lost.backend/src/staffgate.js resolves an actor's
roles and no scope at all, so nothing recorded here has ever narrowed what
a request may do. That is not an oversight to be closed on a screen —
docs/decisions/339-identity.md settles the sequencing, and
server-side scope enforcement is gated on the customer links and driver
credentials that replace the anonymous shared token, because that token is what
bypasses the one write gate there is.docs/modules/vehicle-documents.md — the page every change to
this area is told to read first, and the page carrying the strictest honesty
rules in the app — went on describing the defect in the present tense,
under a Traps heading, and told its readers not to trust a result that
had by then become trustworthy. A document recording a shipped guard as
absent is the same defect as a screen claiming a check that never ran, and
it is the one a documentation page can actually commit. The page is corrected
rather than quietly rewritten: what the defect was, and when, is kept.
NF.IDLE_KMH
are untouched: the group is read off the same band(v) the sort used
plus fixStatus()'s own undated flag.docs/decisions/absence-as-success.md counts twelve repaired
instances of. The no-rows case used to write nothing at all into the card and
now answers four ways — the feed has not replied, the feed was refused (named),
no database document has merged here (NF.readComplete(), #933), and
a measured none..github/workflows/deploy.yml runs automatically on every push to master and stamps a per-deploy build number
MAJOR.MINOR.X into NF.version, then copies that value into driver/sw.js's
APP_VERSION — which is the service-worker cache-name suffix — so the cab's precached shell rotates on
every deploy and a driver/ change reaches installed tablets on their next online load after the merge.
No release cut is required. The old claim was a plausible half-truth rather than a careless one, which is why it
reproduced: tools/release.js really is the sole writer of the committed stamp (#359, unchanged here), so
a reader who checks the tree and stops there correctly concludes nothing moves between releases — and then draws the
wrong conclusion about what is deployed. It reached three artefacts in one afternoon before anybody read the
workflow. Corrected in docs/modules/gotchas.md, docs/modules/ci-cd.md,
docs/modules/versioning.md and the CLAUDE.md index — written up as a correction that records
what bit, rather than a quietly swapped sentence, and stated beside the fact that must not be inverted along with it:
deploy-backend.yml is workflow_dispatch only by deliberate design, so a backend/
change genuinely is not live on merge. Front end on merge, backend on request.driver/sw.js’s own header comment, which
pointed the reader at a versioning section that by then said the opposite, and the return-contract template every
developer agent is briefed from, so every dispatch return was carrying a wrong deploy-state line. Also in
docs/modules/vehicle-documents.md, docs/modules/two-schedulers-one-board.md and six
docs/decisions/ briefs. Each is corrected in place with the superseded wording quoted and dated
rather than quietly swapped out, because a silent rewrite destroys the record of what was believed and when — and
because the claim is a plausible half-truth that reproduces: tools/release.js really is the sole writer of
the committed version stamp (#359, untouched), which is a different question from what is deployed.
.github/workflows/deploy.yml runs on every push to master and re-stamps driver/sw.js’s
APP_VERSION — the service-worker cache-name suffix — from a per-deploy build number, so the
cab’s precached shell rotates on every deploy and a driver/ change reaches installed tablets on
their next online load after the merge. Finding the claim in several files is not corroboration; they are copies of
one error, and each correction now says so. The one real caveat is the network rather than the release — a tablet
out of signal keeps the old shell until it is next online — and the fact that must not be inverted along with it is
stated beside every correction: deploy-backend.yml is workflow_dispatch only by deliberate
design, so a backend/ change genuinely is not live on merge. No code changed: the edit to
driver/sw.js is entirely inside its header comment, and APP_VERSION and both cache-name
derivations are byte-for-byte unchanged.
And the three places that LISTED the surviving copies were the same defect one level up, so they no longer carry a
list. CLAUDE.md’s spine entry, docs/modules/gotchas.md and
docs/modules/versioning.md each named carriers that this change had just fixed — a written-down list of
stale claims goes stale itself, and it does so on the one page somebody checks. They now carry a one-line
grep that regenerates the list instead: every file matching the claim’s shape, minus every file
carrying the CORRECTION (#685) marker, so a file drops off by being fixed rather than by somebody
remembering to edit a sentence. It returns 9 files today of which 8 are real, the false positive is named,
and what the grep cannot see is written down beside it — whole-file granularity, *.js and
*.md only, shape rather than meaning, and no CI gate — because it is a worklist generator and a clean
run means only that nothing matched, never that the repo is right. The residual is flagged by consequence rather than by
count: the one that matters is js/screens/15-compliance.js, the only user-visible copy, whose wording
is pinned by an assertion in tests/suites/93-requirement-report.js, so the screen and its test must move in
one change.
The worklist was then cleared down to that pair. The grep returned 9 files when the sweep started and returns
4, of which 2 are real. Taken since: .claude/agents/product-manager.md, the reader-side
twin of the developer brief — it instructed the coordinating agent to carry the wrong deploy line into its
report, so the error propagated into every status update a human read, and it also offered a
“needs APP_VERSION bumped” state that has not existed since #359;
tests/suites/78-marker-precache.js, where the false claim sat in a header comment as the stated
rationale for a live assertion, so anyone weighing whether that guard was worth keeping was reasoning from it (the
comment only — the suite is unmoved at 19,495); AWS_DEPLOYMENT.md, which told an operator the deployed
version changes “with each release” on the very page that tells them to read that version off the live site;
and docs/modules/devices.md and infra/DEPLOY-NEW-ACCOUNT.md, which carried the adjacent
bump-by-hand shape — a different stale claim with a different date, false since #359 rather than since the
per-deploy build number, corrected as such rather than folded into the other, because conflating two stale claims is how a
correction sweep introduces its own error. What is left is the 15-compliance.js pair, one small clause in
docs/modules/compliance-register.md, and the named false positive.depots was missing
from the Change Log's collection filter, so the record existed and was reachable
from nothing. This is the read that has to happen before anybody corrects
the Bunbury/Tom Price row, because only the change history separates a Bunbury
row somebody mistyped from a Tom Price row somebody made by editing
Bunbury's — those are different repairs, and they look identical from
the row itself. It also dates the edit, which is what says whether the assets and
con notes filed against that yard belong to one or the other.docs/decisions/69-recording-cost.md, with
backend/tests-recording-cost.js (46 assertions) driving the real
recorder sweep and asserting every figure the brief publishes, so it cannot go
stale in silence. No dollar figure is stated or estimated: that needs one AWS
Cost Explorer read and one CloudWatch Logs Insights query, both written out with
their parameters and their named decider. Measured: a history bucket row is
rewritten whole on every sample, so its cost is quadratic —
97·n + 60·n(n+1)/2 bytes to store 97 + 60·n, which is a
×95 write amplification over a moving asset-day. (#69)backend/src/handler.js's own comment both claim — the comment
compares a peak row size with an average one. Idle sampling saves ×5.00
on writes and ×23.25 on bytes, not "a further ~3×". And the issue's
Action — look at the DynamoDB write-capacity metric — has had no metric
to look at since DynamoDB was decommissioned in #415; the Aurora equivalents are
named instead. Also newly counted: the live-position snapshot row, rewritten
whole on every sweep at 157,691 B over 573 known
vehicles whether or not a sample is recorded, which no estimate in the repository
had ever included. (#69)git branch -r answers a question about what happens to be on that
disk — one agent was pointed at a commit by the issue’s own comment,
was told “not a valid object name; no remote counterpart”, and
truthfully reported the work as absent. It was on a branch nobody had fetched.
node tools/preflight.js --prior-art 693 now runs
git fetch origin --prune (and --unshallow where the
clone is shallow) before it looks, then names every branch carrying that
issue number — saying of each whether it is already merged into master or
not — plus every unmerged commit that cites the issue by sha, which is the
half a branch-name search cannot reach. The same axis runs at brief time, so a
dispatch starts by naming the prior art instead of discovering it three hours
in. Three states rather than two: an answer from a clone that did not fetch is
labelled NOT MEASURED and never printed as “there is nothing”.
Pinned against throwaway remotes by the identical command answering differently
before and after a fetch; reverting the fetch reddens 14 checks.git log, 260 ms across 363 branches, and it is
printed only when a fetch really happened.The pre-dispatch prior-art check now asks master's own history as well as the branch list, so an issue whose fix already shipped stops reading as an open one. The check that every brief runs before work starts scanned the branch namespace only — it looked for branches and commits that nobody had merged, which it did by excluding master by construction — so a defect that had already been fixed and released came back as "Prior art: nothing found, and that is a measurement", a confident clean bill of health over work that was live. That is the more expensive of the two ways this goes wrong: an unmerged branch costs a session reading somebody else's work, while an issue that was fixed and left open sends somebody to rebuild something customers already have. Measured on 13 September 2026 against the 87 issues then open: 79 are named by at least one commit on master, 66 of them in a commit's own headline, and 24 were being reported as untouched. The check now names those commits, and says where each reference sits — in the headline, in the message body, or only as the pull-request number automatically appended when a branch is merged, which is a different fact and is called out as one, because two numbers that looked like clean finds of this very defect while it was being built turned out to be pull requests rather than issues. It states plainly that a commit naming an issue is evidence that work for it landed and is not a claim the issue is finished: a long-running piece of work is trimmed many times, so the reader is given the commits to read rather than a verdict. And it refuses to answer where it cannot look — a partial copy of the history, an unreadable one, or a missing reference point each get their own wording instead of being reported as nothing found.
node tools/preflight.js --about backend/src/alerts.js
scans every tracked text file for lines naming the mechanism, harvests the issue
numbers the repository's own prose cites within twelve lines of one, ranks them
by how many distinct mention sites carry each, and hands the top ones to the
existing --prior-art axis. It reaches no new verdict of its own:
every answer it prints is reportPriorArt's, so there is one
implementation of has this landed and this mode can only change which
issue that implementation is asked about.
--prior-art would have answered all
three correctly and nobody ran it, because in every one the coordinator held a
mechanism and no number: a grep of tests/run.js for
preflight returning two hits, one of which was the fix's own
comment block landed two hours earlier; tests/suites/92-contrast.js's
own “NOW EMPTY. 26 remained from 5 to 12 Sep 2026”, read as
26 outstanding pairs; and CLAUDE.md on
backend/src/alerts.js, read as nothing calls it. Given the
mechanism alone and no issue number, the new mode surfaces the right issue in
all three — #83 and #349 ranked first,
#1182 sixth of ten, which is what the cap was sized against
rather than a figure written after it — and master's own history names
every one of them, including 551bc9fb “alerts.js had no caller
anywhere — POST /geofence/alert is it, plus a guard so it cannot
recur”.
It WAS “… nothing calls it” — FALSE TWICE
OVER, the first matched a CORRECTION (#1182) block. A grep
that matches the remedy is indistinguishable from one that matches the gap, so
the new mode does not try to read the prose better: it treats the prose as an
index and goes and asks git. There is deliberately no second prose
classifier — tools/claim-census.js (#972) already labels a
CLAUDE.md index claim live, historical or retracted.
CLAUDE.md's index is one bullet per line with no
blank line between them: measured on backend/src/alerts.js, 70
references without that rule and 12 with it, #83 ranked first
either way. Three answers at each step rather than two — a mechanism
named by no tracked file and a mechanism with no issue cited near any
mention are their own printed states, because neither is nobody has
worked on this, and neither can exit 0.
tests/preflight-check.js, 262 checks to 290, with
every positive paired against a negative on the same fixture — a
harvest that returned every number in the repository would satisfy any positive
control ever written, so each control is run twice over one fixture arranged
both ways. Verify-to-fail, every break confirmed applied by grep -c
on the substituted text before the count was taken: not wiring the mode into the
CLI at all reddens 7, letting a mechanism nothing names exit 0 and print
reassuringly 4, dropping the harvest cap 2, removing the markdown
list boundary 1, widening the window to the whole file 1, reading
the working directory instead of git ls-files 1, and
removing the reference from either dispatch document 1 each.
Running the mode end to end also found a defect no fixture had: the summary
line added the two axes, and one issue can be on a branch and on
master, so it printed “the prose put 12 issue(s) in front of you and 21
of them already have work”. They are named separately now, restoring
the sum reddens 2, and the guard's own first fixture could not
register that defect — with one issue per axis the summed and the
separate forms both read 2 over a denominator of 2, so both issues are now on a
branch and on master and the overlap is itself asserted.
0 in tests/run.js,
which stays at ALL PASS — 31827 assertions · 143 of 143
under the two taken against it — the file is at tests/ root
and the runner never loads it, which is the property #1138 recorded
and #1182 corrected. The whole guard therefore lives in
npm run checks and npm test cannot see it.bottom:calc(var(--tabs) + env(safe-area-inset-bottom)),
so the installed-on-a-tablet case — the driver PWA's primary device
— is the one where that arithmetic actually does something, and
tests/manual/safe-area.js is the only suite in the folder that
wires insets at all. It carried no assertion about the banner. The suite goes from
109 checks to 132, the 23 new ones taken at 1024×768 with no insets
and at 393×852 with iPhone insets: the app's own last painted box stops at
the band, the band is bought
from the app rather than by squashing the banner (which #346 draws hatched and
forbids compressing), and the reserved gap is read off the banner's own
measured box — asserted at two widths that give it 76.06 and
144.13px, so no constant can satisfy both. Geometry throughout, plus the
banner's computed background-image for the hatch, because
driver/index.html loads no css/ file and a rule that
never applied would still leave the markup looking right.Math.abs(gap − bannerHeight) <= 0.5
and measures 76.58 vs 76.06 and 145.58 vs 144.13 — red,
because .nf-env carries margin-top:1px on purpose. That
one row of page background is an exact upper bound on the sticky lift: a page's
laid-out height is fractional while scrollHeight is an integer, so
the maximum scroll offset can fall up to half a pixel short of the document's
last row, and the sticky banner lifts back to its offset while the version line
above it does not. It is what guarantees the run sheet's
overlap === 0 at any content height, and asserting
equality to half a pixel would have demanded back the sub-pixel flake that
blocked a CI shard for a fortnight. It is now a two-sided bound naming that
rule. The second asserted the Drive screen overlays at every viewport;
measured in the cab at 1280×800 it does not, being clear by
1.00px. The Drive screen splits: the mounted-cab layout reserves
the band, because its three columns all reach the bottom edge and what the band
covered there was the posted-limit readout and the actions column, while the
phone layout overlays deliberately, the banner being 144.1px of a 393px
screen and reserving it having taken the map from 113.8 to 41.8px. Each half is
now derived from the stylesheet's own media-query threshold rather than written
down per viewport, so swapping the two reddens instead of passing.environment unset, sweeping 81 scroll positions and testing the
banner's own drawn area: 6,305 of 8,245 sampled points landed on the page
underneath it, 805 of them on a live contents-menu button —
invisible, and still tappable, because the banner lets taps through. And the
banner itself was fully on screen at only 6 of those 81 positions: it
slid up the page and off the top, so for most of the page there was no
environment warning at all on a screen somebody reads to find out how the system
works. Help now scrolls inside the app like every other screen, so the banner
stays put and covers nothing: 0 points underneath it and the banner fully
visible at every scroll position, measured at three viewport sizes and
two environment settings, with the end of the page still reachable and landing
clear. Nothing else about the banner changes — same words, same hatch when
nothing is declared, still not dismissible, and still absent in production,
where none of this geometry applies.Assigning a job on the Dispatch Board now refuses a driver or truck that is not available that day, the same way the Weekly Schedule already did. The ▸ Assign control and dragging a card across the board both put work on a driver's schedule line, and until now neither asked the conflict check the schedule's own drag asks. They do now, and they refuse exactly what the schedule refuses: a driver on leave or a truck in the workshop on that day, or one unit on the job twice. Nothing else is refused and nothing else is warned about. A driver who already has another run that day is an ordinary multi-drop day, and warning on it would fire on 228 of 240 everyday assignments on a busy sample day. Decided by Simon Anderson on 23 Sep 2026.
A refusal is printed on the board, in place: in the card, under the control, for the ▸ Assign pick (which is put back), or at the top of the column a card was dropped on. It says what did not happen and why, and states that nothing was written. If the conflict check itself could not run, the move still goes ahead and the board says NOT CHECKED, in the hatched style, rather than passing it off as clear.
A job put on the schedule from the Dispatch Board now lands on its own day, not on today. Both board writers filed every job on today's column, so a job booked for tomorrow and assigned today appeared on today's schedule line while the driver's run sheet had it on tomorrow. Measured on the sample data over eight consecutive days: 135 of 288 crewed con notes are on a day that is not today (10 to 36 of 36 per day). A job saved before jobs carried a day still lands on today, as before. Because the conflict check now asks about the right day, it finds leave that asking about today could not: 52 of 1,280 proposals asked through the board are refused, every one of them a driver on leave or a truck in the workshop on the day the work is on. The earlier "0 of 3,360" figure was measured on today's column and does not survive the fix.
NF.assetOdometer returns currency beside the figure and
its source — 'known' (we hold the moment it was taken),
'unknown' (we hold the reading and no date for it) or
null (there is no reading at all, which is a different fact again).
No staleness threshold is applied and none is invented: an odometer is
cumulative, so an old reading is still a sound lower bound and its date is the
whole treatment. Note what this reads today — Fleet Dynamics is
sending a date shape the reader correctly refuses, so every GPS-unit reading in
the fleet currently answers unknown. That is the honest answer to draw,
and it becomes known with no further code change once the feed's date
order is configured.Timeout, the caller gets API Gateway’s default gateway
response — a one-field JSON body, {"message":"Internal Server
Error"} — not an empty one. The real point of every comment saying
otherwise is unchanged and still true (that answer is undiagnosable in this
app’s terms, which is why the bounded reads stop themselves before the
platform does), but the stronger claim underneath it — “no JSON
body”, “nothing in it to diagnose”, “a
bodiless platform error” — sends whoever debugs it next looking
for a network fault instead of a slow read, because a front end that logs the
body sees a JSON object. One fact, three wordings, 24 sites, contradicted
by a 25th in the same tree. 23 corrected to cite
backend/src/gpswox.js’s own careful statement; one is held by
another branch and named in the guard’s exemption ledger.
⚠ The timeout case itself is not confirmed against a live
invocation — that would mean invoking production — and rests on
AWS’s documented default gateway-response behaviour plus this
codebase’s own later statement, corroborated in shape by the measured
403 {"message":"Forbidden"} already recorded in
backend/tests-fms.js.tools/timeout-body-check.js regenerates the worklist on every
node tests/run.js (asserted in
tests/suites/75-repo-guards.js), so the claim cannot quietly come
back the way it spread — by being copied. It says in its own header what
it cannot see: it matches words rather than meaning, so a fresh phrasing
escapes it entirely; it cannot tell a correction quoting the old wording from a
relapse except by the #709 marker; it reads .js and
.md only, with changelog.html outside it deliberately
as the historical record it is; and it is a consistency check over this
repository’s own prose, never a measurement of AWS.backend/load-fleet.js,
derives vehicles and trailers from the 1,210-row
Northfleet register instead of the twenty invented demo trucks that have stood in
for them since the prototype started — 76 prime movers and rigids, and
191 trailers, measured on the live database. The register is the
input: it is absent from the document the script sends, so it cannot be
written to even by accident. Report-only unless you pass --write, it
reads back what landed field for field, and running it twice writes nothing the
second time. Registration is left empty on purpose, because the register's
plate column holds Northfleet fleet numbers (NF1000) and on
part of the fleet a status scratchpad (SPARE, U/S) —
putting one of those in a field labelled Registration would be a claim nobody at
Northfleet has made. A row the register cannot describe is refused by name
rather than given a made-up type, and the report names every refusal with the id
against it. Nothing has been run against production; that is Simon's to do.vehicles, trailers and truckMaint sat on
the purge's keep list under the words “asset register satellite”
— and they are not one. A satellite is a record keyed to a register
row and read back through it: a registration document, a service entry, a
contract, all three of which still stay. These three are the sample fleet:
NF-104…NF-215, a two-row trailer pool, and three
maintenance windows keyed to demo trucks. Measured on the live database: 20
vehicles, 2 trailers, 3 maintenance entries, and not one of those ids resolves
to a row on the real register. So the purge kept exactly the rows it exists to
remove, and the mistake was invisible because it was wearing the right label. The
go list is 33 collections now and the keep list 15; the asset register and its
three real satellites are unchanged.25353) and carries the number painted on the door separately
(NF1000), so once the fleet list is built from the register, every
screen that formats a stored id instead of resolving it prints a
number nobody can look up. That is a defect this app has shipped before, in
v0.147.17, on the trailer picker. Fixed on the truck swap picker, the free-truck
list in the schedule conflicts panel, the vehicle dropdown on a new consignment,
the events filter, the Asset column of the fleet-documents table in Settings, the
truck under a driver's name on the messages list — and, the one that matters
most, in the cab: the top bar, the Truck tab, the daily-checks header, the
tablet's truck picker and the truck label the driver reads. A driver at a roadside
intercept holding out a tablet reading 25353 cannot match it to
anything on the vehicle. What is stored is unchanged — the fix is a
reader, not a different write — so every allocation, conflict check and
history key keeps the identity it always had.js/data.js reading “a WA benchmark, not Northfleet’s
fuel account”, which reaches whoever next opens that file and never
reaches the asset page that prints the money. Measured on the shipped seed, the
wrong answer was two clicks away through controls that already ship on the
asset register, and it read in the reassuring direction. The diesel price now
carries the same five fields a type rate does — a recorded source, who set
it and when, and an empty-until-checked confirmation — and a fuel
cost is reported as confirmed only when both figures behind it
have been, naming the one that has not, because “not confirmed”
without saying which half is a disclaimer nobody can act on.benchmark, which is a true statement about it, and nothing
anywhere marks it as confirmed — there is no migration, because a
pre-tick is a migration with a Save button.active !== false,
NF1294 names 2 rows and SPARE names 4.
Nothing was mis-keyed — the unit ids are distinct and allocation stores the
id, so the register round-trips correctly — but a scheduler opening the
trailer picker saw four buttons reading SPARE with no way to tell which
was which, and the pools rail, the utilisation panel, the schedule conflicts
panel and the driver's run sheet all repeated it. A shared label now prints the
unit id beside it — SPARE (34148) — which is the thing
a scheduler actually picks by and is the register's own stable identity. Where
two rows have no unit id to tell them apart, the label says how many assets
share it rather than pretending to name one.
SPARE (1) / SPARE (2) would be a distinction nobody
made, and whether SPARE should be a plate at all is a data question
for Northfleet that this deliberately does not answer — the screen is
honest about the register as it stands. A label that resolves to exactly one live
row is returned completely unchanged, so the ~758 rows that are already
unambiguous gain no decoration at all. Archived rows are counted the way this app
counts everything: active !== false at the read, so a sold trailer
does not make a live name ambiguous, while a job filed against it still resolves
to the number it was sold under.
NF-1294 and one stored
NF1294 are reported as sharing a name — they print
identically — while the fifth trailer plated SPARE, working
is a different string that already rendered distinctly and stays out of it.
Measured: removing the disambiguation reddens 7 assertions, testing
!a.active instead of active !== false reddens
14, counting archived rows reddens 6, measuring the collision on
the raw string instead of the app's comparator reddens 4, and dropping
the two-rows-with-no-unit-id case reddens 2. A source guard over the
idiom that caused this — formatting a pool member's name instead of
reading the pool's resolved label — found a sixth screen nobody had
listed, the pools rail's own drag chips.
SPARE, and the utilisation
rail was showing four different assets, with four different utilisation figures,
under one name on the panel a buy-or-dispose conversation starts from.
label off the rows the pool already hands them, so the conflicts
panel, the utilisation rail, the free lists, the pools rail, the trailer picker
and the cab all get their answer from the one place that decides how a shared
label prints. A label resolving to exactly one row is still returned completely
unchanged.
SPARE (34148) while its own tooltip, three inches away, said
SPARE. Two implementations of one decision, free to disagree.
signedBy is typed by the driver at proof-of-delivery
capture — the cab labels that box "Received by (name)" — so it is a
third party's name: not our employee, not our customer, and usually the store
person or foreman who happened to be on the gate. It was printed twice on
#track/<con note>?t=<token>, a link that is anonymous by design and
therefore forwardable: once in the delivered step ("· signed <name>") and once
in bold on the proof-of-delivery box ("Received by <name>"), immediately under that
person's own signature. The exposure was never guessability — the token has been
minted from crypto.getRandomValues since v0.148.0 — it was a link mailed
to a purchasing officer and forwarded twice inside a mine site. Both lines are gone.— this
replaced read as nobody signed for it, which on a shipment the page has just
called delivered is a different and false claim.NF.PortalTrack.recipient(), beside the
driver-name decision it sits next to, and it reads whether a name was recorded
and never what it says: two records carrying two different names render
byte-identically, so there is no name in scope to reach.display:none span still hands it to anybody who
views source, and this portal's own sign-in card is the measured precedent for
exactly that. So every absence is asserted against the rendered
outerHTML too, and the treatment is read off
getComputedStyle rather than a class name, after probing a real hatch in
the page first: not hatched is background-image: none, which is also
what a stylesheet that failed to load returns. Measured on
tests/manual/portal-selfservice.js (207 checks → 236): putting the
proof-of-delivery line back reddens 16 of 236, putting the delivered step
back reddens 8, drawing the withheld name hatched reddens 2, and
smuggling the name back inside a hidden span reddens 5 — of which
none is a rendered-text check, so the DOM half on its own would have passed
that last one.drivers and vehicles only, so a consignment row
arrives whole, and the per-account narrowing is deliberately not sent on a
tracking-link visit because a saved account id could leave out the very con note the
link names. Closing that means a shared-layer change, its paired server-side field
list and a backend deploy, and it would take the field from every bearer-link holder
including the delivery record that legitimately wants it — so it is recorded in
docs/audits/portal-state-exposure.md as a decision to take, not taken
here. And the recipient's handwritten signature image is still published on that
same box, above where the name was; it is a proof-of-delivery evidentiary
artefact, it is on none of the six fields any sweep has enumerated, and it is
escalated rather than changed.NF.ASSET_PATTERNS carried six
types against the taxonomy’s ten, and dolly was one of the
four it could not reach — while the word dolly sat as an
alternative inside the trailer pattern. So
NF.classifyAsset('DOLLY') answered trailer, and the 47
register rows typed trailer over a description containing
“dolly” were not mistyped by anybody: they were typed by the
app’s own classifier, correctly by its own rules, through the
register’s own Suggest types button — and the 48th would
have gone the same way. The register could not be typed correctly by the one
tool the register offers for typing it.
Nothing already on the register moves. Suggestions only ever fill a type
that is still blank, so this changes what is proposed for a new row;
retyping the existing 47 is a separate reviewed decision about which units
really are dollies, and no code may take it.
Three answers, not two. A description naming a dolly and another
trailer word — FLOAT DOLLY, or the
TRAILER - Supatilt/Tandem Axle Dolly that is
really a supatilt — is now answered we cannot tell and left blank
for a human, rather than being guessed at in either direction. A wrong type is
worse than no type. Measured over the 82 real register descriptions held in the
repository, exactly one answer moves.A trailer and a dolly can be the same truck, and the double-booking check
used to miss it. The board's conflict check asked only the pool a unit was
being offered from. So one physical unit already out as a trailer today raised
a clash when somebody tried to hook it up as a trailer again — and
nothing at all when the same unit was offered as a dolly, or as a prime
mover. Measured on the shipped check: one conflict when proposed as a trailer,
zero as a dolly, zero as a prime mover. That is exactly the
situation the real asset register produces, because 47 units are typed
trailer on the register and also answer the Dollies pool off a word
in their description — so the unit a scheduler picks out of the dolly list
is routinely one a colleague can hook to another run as a trailer, and neither
list admitted it. The check now knows that a prime mover, a trailer and a dolly
are one register and one id, so a unit already out in any of those roles is
named when it is offered in another, saying which run has it and in which role.
Two other places were blind the same way and now agree with it: the
timing-overlap check, which reported nothing on two timing-critical runs four
hours on top of each other over one unit, and the weekly conflict panel, which
grouped each pool separately and so held one entry in each list, clashing with
nothing in either, over a day it then called clear.
A drop is still permitted where the board cannot be sure, and refused where it is certain. A unit already out on another run in a different role is reported as a warning naming that run, exactly as an ordinary double-booking already was — at day granularity nothing can say whether the two runs really collide, and a control that refuses what it cannot justify teaches everybody to click through it. The one thing now refused outright is one unit named in two fields of a single job — as the prime mover and again as a trailer, say — which is physically impossible, and which is the same test the board already applied within one field. Measured across the whole generated board: no job on it does that, so nothing legitimate is newly refused. Drivers are deliberately left out of the rule: a driver is a person on a different register, and the two id spaces share nothing.
“No unit is out in two roles” and “we could not look” stay different answers. If the day's allocations cannot be read, the new check reports that it could not be read rather than answering an empty list, which would read as a day somebody had checked. It is also deliberately not the same question as the overlap notice on the pools rail: that one says a register row is in two pools, which is true of those 47 whether or not anybody has scheduled them, and this one says a unit is out on two runs today. Being reassured by one says nothing about the other.
10532 10814 13792 … — the
register's internal ids — and now reads NF1035 NF363 NF975 ….
The same fix reaches the dashboard's vehicle list, the live map's popups, the
utilisation truck chip and lane report, and every asset link in the app that
does not carry its own label.
NF1294 and four share
SPARE, so which record a name resolves to is a real question and
not a tie-break; the old search is kept and the tests run both and compare them,
rather than checking the new one against itself. And correcting a fleet number
through Assets Management now refreshes the lookup immediately — that is
the one change that alters an answer without adding or removing a record, so it
is the one a cheaper approach would have got wrong.node
tests/run.js passed under Australia/Perth and under
UTC and failed under America/New_York — two
assertions, one of them the Levy notice (#329) group's own claim that its
figures do not depend on the clock. The cause was in the suite's own hand rather
than in the model: four fixtures built “today + n” as
localDay(new Date(asDate(today).getTime() + n * 86400000)), and a
day is not 86,400,000 milliseconds in a zone that observes daylight saving.
Across the 1 November fall-back a fixture asking for ninety days’ notice
landed at 23:00 the previous evening and re-derived as eighty-nine. Every
day offset in that file now steps calendar days through the
Date constructor’s own overflow rule instead.NF.Rates.levyDaysBetween takes both ends through
NF.asDate to local midnight and rounds the difference,
precisely so a 23-hour or 25-hour day cannot turn 90 into 89.96 and then 89
— measured, it answers 90 for a real 6 Sep → 5 Dec
gap under all three zones, and the customer-facing path (a typed
YYYY-MM-DD and NF.localDay(new Date()), never
millisecond arithmetic) is the same in every one. No application file
changed. The notice period a client is quoted was correct on every clock
setting before this and is correct on every clock setting after it.UTC, Australia/Perth and
America/New_York, hands each child the same anchor day and compares
the answers — days, verdict, shortfall and every sentence, byte for
byte — rather than three green ticks. The sweep cannot pass vacuously: the
children report their own resolved zone and January/July offsets, the three must
differ, and at least one must be behind Greenwich. Beside it, a control finds the
running zone’s own transition at run time and shows the millisecond form
losing a day across it; a zone with no transition says so in those words rather
than passing quietly. Measured: the sweep costs +10.2 s on a
161 s run (+6.3%), against +322 s for a naïve three-zone
sweep of the whole suite.SPARE, and the two guards built to stop exactly that were
blind to it. The service-due screen named an asset with its own inline rule
— the plate, or the id if there is no plate — which is a
third independent answer to a question one shared reader already owns.
On the live register that name is not unique: SPARE names four
trailers and NF1294 names two, so a workshop booking could be made
against a row nobody could identify. The due list, the two un-forecastable
lists and their engine-hours twins now read SPARE (32593) the way
the pools rail, the trailer picker, the conflicts panel and the utilisation rail
already do, and a name that belongs to exactly one live asset is still printed
completely unchanged.
r.plate || r.id inside a line of markup — is not in
either population, and a fourth spelling would not have been a call either. So
the new guard is derived from the field rather than from any idiom: the
register's plate may not reach the screen except through the one reader that
knows when a name names four rows. Whether a value reaches the screen at all is
worked out by reading the source rather than from a list anybody maintains, so a
screen written tomorrow is covered without anybody remembering this exists.
Measured: a bare plate with no call around it, added to a screen the
guard has never been told about, is caught and named by file and line.
Australia/Perth and UTC, which have no
transitions and are the reason nobody had seen this; and under
America/New_York, Europe/London and
Pacific/Auckland, 2 hours a year for the idle-asset list's
“yesterday”, 26 for the lane report's fourteen-day table and
181 for the pipeline's ninety-day conversion window. All three now step
calendar days through one shared derivation.UTC, Australia/Perth and
America/New_York and compares the answers: 672 day keys from
96 wall-clock anchors across all twelve months must be byte-identical in all
three, and the pipeline window must open and close on the same two dates in all
three. Beside it runs the control that stops that being a tautology
— the millisecond form, asked the same question at the same anchors, must
disagree across those clocks, and it does. A second control finds the
running zone's own transition at run time and shows the millisecond form naming
two different yesterdays within one local day, depending on what time somebody
opened the screen.js/screens/, which the regression runner never loads, so a source
guard is the only reach it has into them: it walks the whole of js/
off disk, strips comments first, and flags a line that hands a
localDay-family call an instant built by multiplying the day
constant — while deliberately not flagging a duration measured by
division, which is the correct form. Measured on the tree it guards it
finds 9 such lines and is right about 7; the three it does not
cover here are named in a ledger that goes red the day one of them is
fixed, and the two real cases it cannot see at all — where the instant
is built on one line and turned into a day key on the next — are written
down rather than left implied, because a guard that silently covers seven of
nine is worse than one that says so.Date.now() + 7 days turned into a
date with toISOString().slice(0,10), which is a UTC date.
WA is UTC+8, so between local midnight and 08:00 the UTC day is still
yesterday and the quote went out valid for six days rather than seven.
Swept over all 8,760 local hours of 2026 under Australia/Perth:
2,920 hours a year wrong — the eight hours of every day in which a
WA transport operator does its 5:30am prestarts and raises its first quotes. It
now steps calendar days in local time, like everything else in the app.Australia/Perth and UTC, which have no transitions and
are why nobody had seen any of this; and under
America/New_York, Europe/London and
Pacific/Auckland, 12 hours a year for the seven-day speeding
leaderboard’s opening day, 16 for the shared telemetry window at
its eight-day default (and 181 at the ninety days the fleet overview
really asks for), 58 for the training screen’s thirty daily
completion buckets, 60 and 120 for the fleet overview’s
30- and 60-day service cut-offs, and 181 for the quote funnel’s
reporting period. All nine sites in the app now go through one shared calendar
step.DAY constant, and
one travelling through an intermediate variable) and seven it must not, of which
the last two are its own limits, asserted as measurements rather than
described in a comment — a trail carried more than twelve lines, and one
that leaves through a function parameter. The two windows the test runner can
actually call are additionally driven under UTC,
Australia/Perth and America/New_York and their
answers compared, with the millisecond form of the same question beside
them as a control that must disagree — because three identical
passes prove only that the test cannot tell the difference.NF1000) — not WA registrations —
and on part of the fleet it is a status scratchpad (SPARE,
U/S). That is why the fleet list is built with registration left
deliberately empty. One line in the driver app did not follow it: whenever the
tablet was bound to a truck that the board has a lane for but which is not on the
operational fleet list — the ordinary case once the fleet comes off the
register — the app made up a record for it and filled the registration in
from the plate. So the My truck card, the card a driver holds up at a
roadside intercept, showed NF1000 (or U/S) beside the
word Rego, and the device row and the truck picker read
NF1000 · NF1000 — the same number twice, once
under a claim nobody at Northfleet had made. The same truck also answered
differently depending on whether it happened to be on the fleet list. It now
reads a dash, which is the honest state, and the number on the door is still
shown where it belongs.master, and failed
identically on every branch cut from it, so four pull requests sat one shard
short of green behind a defect none of them had introduced.
document.documentElement.scrollHeight is an integer and the
body's height is not: at 1024×768 the run sheet is
2265.484px tall, scrollHeight rounds down to 2265,
and window.scrollY clamps to a maximum that never reaches the true
bottom — over-scrolling by 50px moves it not one pixel. The banner is
position:sticky, pinned to the viewport, while the version line
below it is in flow, so the residue is precisely the remainder the page cannot
scroll away. Measured across five viewports, the overlap equals that
remainder in every one: +0.484 → 0.484 at two sizes whose height
rounds down, and 0 at the three that round up or land exact. It was
Math.round's tie-break on a fractional page height, which no app
change can influence.position:fixed, reserving nothing — the
#702 defect exactly) reddens 2 of 159, and the version-stamp assertion
names box overlap 35.578px against a 0.422px remainder with the ink
68.5px the wrong side of the banner. It still catches the 36px
regression it was built for, with the original number.overlap === 0 was unsatisfiable
by any app change. It was not: the remainder is
|height − round(height)| and therefore strictly under one CSS
pixel by construction, so margin-top:1px on the banner is an
exact upper bound and 0 is the right contract. Both changes reached
master — the bound through one PR and the loosened assertion
through another's merge — leaving the check permitting up to a whole pixel of
real overlap that the layout now guarantees cannot happen. Redundant rather
than a hole, since the sixteen-height sweep beside it asserts exactly 0 and is
the binding check, but a reader comparing the two would reasonably wonder which
one meant it. The remainder is still reported, because naming it is what
makes the geometry legible; it is simply no longer the threshold.
Verified to fail: removing the 1px bound reddens 2 of 161, and the
tightened line reddens at 0.484px where the bounded form passed it
(0.484 ≤ 0.484 + 0.02).docs/modules/testing.md drew the wrong
lesson, which is the half that would have misled somebody. It concluded
that a geometry contract can be unsatisfiable and should then be replaced by
one that bounds the residue. The corrected ordering is measure whether the
contract is reachable, and when it is not, look for a bound on the LAYOUT
before you touch the ASSERTION — one pixel of page background bought back
a strict contract that the analysis had given up on. The original analysis is
kept rather than deleted, because its diagnosis is correct and is what the
bound was sized against; only its remedy was superseded.portal/portal.js's booking form derived its day with
new Date().toISOString().slice(0,10). toISOString() is a UTC date and WA is UTC+8
with no daylight saving, so the UTC day rolls over at 08:00 AWST: measured over all 8,760 local hours of 2026
under Australia/Perth, 2,920 of them — 33.3%, which is 00:00–08:00 every single day —
answered the previous day. This was not latent on the deployed configuration; it was wrong every morning.
The same variable fed three things, and the third is what made it more than a cosmetic default: the field's
value, its min — the floor of what a customer may pick — and the "Requested"
readout that confirms the choice back to them. Because min is a floor, a customer booking at 6am
on a Tuesday was offered Monday, could submit Monday, and the summary agreed with them; nothing
downstream refuses a past collection date. And because all three read one variable they were consistently
wrong, so there was no contradiction on screen to prompt a second look. It now asks NF.localDay,
which has described this exact failure in its own header comment since the driver's prestart hit it.
Whether a customer should be able to request a same-day collection at all is a separate business
question and is deliberately unchanged — the floor is now the correct day, whatever day that is agreed to be.js/ — the client portal and the driver PWA.
The sweep that forbids a day key derived by millisecond arithmetic walked js/ and nothing else,
so portal/portal.js, driver/driver.js and driver/sw.js were guarded for
neither that idiom nor the UTC slice. portal/portal.js is named in this test file, in the
list belonging to a different guard testing a different pattern — a reader who saw the filename there and
concluded the day-key sweep reached it was reading two unrelated file lists as one. It did not, on two
independent counts: the corpus stopped at js/, and the sink required the day constant on the same
line, while the booking form's was a bare new Date() with no arithmetic at all. So the one class
of this defect that has ever produced a wrong answer for this operator was invisible to the guard that names
the file. Both idioms are now swept over those three files, each path asserted to exist so a corpus that
quietly shrank to nothing cannot pass by scanning no bytes, and the matcher is proved against a fabricated
corpus of six shapes rather than against the repository happening to be clean — on a fixed tree "found
nothing" is the expected answer either way. The rendered form is asserted separately in the browser, on a
context pinned to Perth with the page's own clock fixed inside the eight-hour window, because a source scan
proves the derivation and says nothing about what the form was drawn with — and on a CI clock in UTC the
broken expression and the correct one return the same string, so an assertion taken there would have passed
on the defect.docs/decisions/334-procurement-scope-conflict.md
rests on had moved, not gone, and holes 1 and 2 were closed with it.
Three things it left behind. CLAUDE.md's index entry still carried the
caveat "we could not look up the assertion this recommendation turns
on", on the one page a reader starts from, about a question a merged commit
had already answered; it now records the answer, the closure, and that
TR-090's proven is at last warranted.
T-6 was still open in the brief — "CLAUDE.md has
no index line for this document" — when that line has been at
CLAUDE.md:141 for some time. And the reason the grep failed was
never written down, which is the part with a lesson in it:
dayRate, PM-562, "out of scope by decision"
and "buy side" return 0 commits touching tests/ or
js/ across the entire history of this repository —
including the very commit that wrote the brief and measured the assertion as
present. dayRate was never test code: it is the field name
the brief injected and reverted, and the other three are a requirement id
and prose that have only ever lived in docs/. That grep would
return zero at any commit ever made, so a zero from an instrument that cannot
register a positive is not a measurement — the reading was never
evidence of drift, and treating it as such is what made moved look like
possibly removed. Removed is now excluded by pickaxe rather
than by argument: git log -S on the guard's own label and on its
regex returns only the commit that wrote it (#260) and the suite split that
relocated it, and no commit removes it. Both guards are found by their
shape — grep -rn 'money or onboarding' tests/ —
which is why they are cited by mechanism. Documentation only; no test and no
production code, and D-334-B/OQ-35 remain Simon's.TR-090 and
PM-562 both say the hired-in carrier record carries no rate, no
insurance expiry and no compliance field, and both say that decision is
asserted against the field spec rather than only written down — which is
what docs/decisions/334-procurement-scope-conflict.md rests its
whole recommendation on. The one assertion doing the work tested
/rate|price|charge|invoice|insur/i under a label promising "money
or onboarding": the words covered three clauses and the pattern covered
two. Measured before anything was changed —
complianceStatus, onboardingStatus,
inductedAt and ticketsHeld reddened 0 each, and
0 with all four appended to the carrier field spec at once, with the
assertion count unmoved from baseline. Not a weak guard: the absence of one.
Now one clause, one assertion, over both surfaces a field can reach the
record by — what the writer advertises, and what the record ends up
holding, which is the half nothing looked at (a rate composed by
build() and left off the field spec reached the database with both
guards green, also measured at 0). Verified to fail, off each
runner's own summary line: the rate clause reddens 3, the insurance
clause 3, and the compliance clause 2 — 2 for one field name and 2
for all four together — where every form of it previously reddened 0; the
build() hole reddens 2. In the browser suite each of the
three clauses reddens 1, against 0 for the compliance clause
before. No product code changed and no requirement record was amended:
TR-090's claim about itself is simply true now.gst until the verify-to-fail run for
the third clause caught it matching onboardi(ngSt)atus — the field
was refused, under the rate clause's sentence rather than the compliance
one. Removed rather than exempted: a three-clause guard whose red names the
wrong clause of the requirement is worse than one term shorter, because the
sentence is the entire reason for splitting it in three. cover is
absent for the same kind of reason and never was in it — covering
is a legitimate field on this record.NF.mintRecordId, which
draws from a CSPRNG and then avoids every id this device already holds, so a
collision is impossible within one client rather than merely unlikely —
and one client is where a loop, a batch and an import live. Measured at
20f7d44c with the clock pinned inside one test realm, so every
mint shares one millisecond by construction: 200 runs of the Reports
screen's Raise missing invoices button carried a duplicate invoice id in
5 of them, 400 inbound transport requests produced 2 duplicate ids, 400
trailer observations 1, 400 re-homes 3, and 2,000 trials of twelve incident
reports produced a duplicate in 3. All of those collections merge by id, so the
loser was silently absorbed rather than refused — one customer's invoice
inside another's, one driver's prestart answers over another's. Every figure is
now 0.NF.Locations.add minted 'LOC-' + Date.now() with no
randomness, and then correctly refused the duplicate it had just created
— measured, 400 calls in one millisecond wrote one location and refused
the other 399 with “a location with id LOC-… already
exists”. The duplicate guard was right and the mint above it was
wrong. The address-book screen had the same mint and no guard, so its
collisions were silent instead.NF.Rates and
NF.Charges mint from max(digits of every id) + 1, and
their tmsSafe fallbacks minted an id carrying the base-36 clock
— which is digits. Measured through the real writer: one such id in the
register moves nextId() from RA-1031 to
RA-999106 and never comes back, the scan covering archived rows.
Over 2026, the digits inside a base-36 Date.now() reach
999,972 and 5.0% of milliseconds extract above the 1000 seed floor. The
new mint is letters only, so it extracts to nothing and is skipped by the
counter instead of climbing it — the ratchet the FMS import number space
was designed around, found inside our own pricing register.99, five files — is the
exhausted-number-space case. The other 7 numbers and 18 files took a
number that was genuinely free in the tree that took it, two coordinator
sessions each reading the only directory it could see. So
tools/suite-number-check.js --free and --next now
read refs/remotes/origin/ as well as the directory, and answer one
of three ways per number: free (nothing claims it here and no unmerged
branch adds a file under it), claimed on an unmerged branch (named, with
the file that took it), or unknown — the branch namespace could
not be read. A failed branch read never comes back as the on-disk answer with a
warning attached: free is empty and the disk numbers are returned
under a different key called unverified, because the on-disk answer
presented as the checked one is exactly what produced the eighteen. Widening the
NN shape would have repaired 5 of the 23 and is a separate,
still-open decision about the running order.git log --remotes=origin --not origin/master over
tests/suites/, no network — labelled as last fetched, N
ago, with never fetched in this clone kept as its own separate fact.
An implicit fetch would make the answer depend on the network at the moment of
asking rather than on something the caller chose, and a stale cache presented as
current manufactures the same confident wrong answer one step later. To make it
current, fetch first. --no-branches gives the directory-only
answer and reports it as unknown rather than as a number, since
that is what not looking is.99 entry called
99-stamp-writers.js the fourth file under that prefix
— it is the fifth, 99-levy-basis.js having been authored
later and merged 26 minutes ahead of it — and called 99
the largest collision alongside 58, where 99 holds five files against
58’s four and so is the largest alone. Both were true in the tree that
wrote them. Both are kept as a marked retraction rather than silently restated,
because the next reader cannot otherwise tell a corrected claim from one nobody
ever made, and no replacement ordinal is written in: an ordinal in prose
is a claim about every other branch, which is the one thing a branch cannot see.
The report now prints the file count beside each shared number, derived from the
list, and a guard refuses a fresh “is the Nth file” claim in
any ledger entry that does not carry a retraction.reconcileJobs()
skips a cell whose con note already has a job, so the cells left to mint for are
the ones with no con note. Measured on a board saved three days earlier and read
back, at six clock settings: 120 of 120 hashed runs carry no con note at
all. That kills the obvious both-answers-safe repair — lead with the con
note and demote the id — because on exactly those cards there is no con
note to lead with, and the refused candidate is recorded because it is the first
thing the next reader will propose. The costing inverts the expectation too: the
store-once answer is free on infrastructure (jobs is already
sharded and audited, so no new collection, no SQL and no backend deploy,
exactly as the enquiry reference needed none) and expensive on the axis nobody
looks at — there is no human gesture to allocate at, because the mint is a
derivation that produces 3 to 30 numbers per pass per client with nobody
touching anything.00 to
99 in tests/suites/ was claimed with no gap, so
suite-number-check.js --next answered null, seven
files were piled onto 99 because the grandfather ledger was the
only path the tool offered, and a dispatch working on an unrelated fix had to
bump a hand-typed count in a guard just to land. The shape now takes two
or three digits. The part that needed a decision rather than two lines
is the sort: '100-x.js' < '99-x.js' as a string, so widening the
shape while sorting by filename would have run every three-digit suite
before 00- and reordered the whole run — in a runner
that shares one realm across suites and where several assert against state an
earlier suite established. tests/run.js sorts on the
parsed number instead, with the filename as the tie-break, so
files sharing a number keep the alphabetical order they have always run in.
(#842)000-…099- keeps ls and the run
order identical for the next 900, but a rename rots the prose citations
of the old filename in js/, docs/,
tests/manual/ and changelog.d/, and nothing checks for
a stale one — docs/modules/testing.md gives that as the reason
renumbering was refused before. So 114 of the 122 files did not move. The
99 pile did, to 100–106, and only because 99 was
the terminal bucket: its eight files already ran last, in
alphabetical order of their suffixes, and the new numbers preserve both facts
exactly. It cost 41 citations across 21 files, which is the
figure to size any future renumbering against. (#842)vm.runInThisContext from outside the runner,
because the runner’s own counters are part of what the change touches and
cannot also be the instrument. The total was 27,625 assertions, ALL
PASS on both sides of the sort change. A guard now holds the property
the decision rests on — that for any list whose numbers are one width the
comparator and a filename sort agree exactly — and demonstrates the hazard
rather than describing it, by requiring them to disagree as soon as a
three-digit file joins. The runner is proven to use the comparator by spawning
it and comparing the order it prints, not by reading its source, which could not
tell a required module from an ignored one. (#842)rg99.files.length === 7) was written 5, bumped to 6, then to 7,
then a fourth dispatch had to bump it again — its own comment said making
it not need bumping belonged with this issue. It is replaced by a comparison of
every ledger entry against the directory itself, which cannot go stale on
somebody else’s branch. The 99 entry is deleted because the
collision is; what it recorded — two ordinals that merge order falsified
with nothing going red — is kept as a marked note where it stood, since a
correction that deletes what it corrects leaves the next reader unable to tell a
fixed claim from one nobody ever made. The other ten numbers are all
mid-run, so moving one would reorder it against every suite
after it; they are restated as deliberate rather than as arithmetic. (#842)tools/break.js applies and reverts the deliberate break that proves a
regression test can fail, and it refuses any path that does not resolve inside
the tree it was started in — exit 3, nothing written, both trees named. On
12 September two agents, each in its own worktree, both wrote break scripts
named b1.py…b7.py into the shared scratchpad; the second
overwrote the first between writing and running, and the loop applied six
BREAK-n markers to the other agent's tree while that agent was
mid-measurement. There is no --tree override — it is refused by name
— and a cwd outside any worktree fails closed. The lease compares on a separator
boundary, because fv-8460 begins with fv-846 and both
are issue-shaped, and it resolves symlinks on the target, because writing through
one writes to its target. revert restores the exact bytes saved at
apply time and touches no other file: the tool spawns no git checkout,
restore, stash, clean or reset
at all, asserted over its own source, because the measured counter-incident is an
agent in its own worktree running git checkout -- . over its
own uncommitted work with no object written anywhere to recover it from. Its saved
originals live in the per-worktree git dir, so the runner's own scratch cannot
collide the way b1.py did.git checkout -- . are not what people expect. Re-measured on
git 2.43.0 in a throwaway repository with two linked worktrees: the documented
table holds unchanged, and the cross-worktree stash pop reproduces exactly — one
agent's git stash push <pathspec> exits 1, its bare
git stash pop exits 0 and lands the other agent's content, after which
refs/stash and its reflog file are gone. Newly measured:
git checkout -- . writes no object anywhere for the edit it
discards, leaves a plain untracked file alone, and truncates an
intent-to-add file (git add -N) to zero bytes, exit 0,
silently — so keeping scratch untracked in your own worktree really is safe from
it, and staging it with -N is not.refs/stash, and the dispatch
convention said it isolated everything that mattered. The remedy section in
docs/agent-dispatch.md enumerated three things a linked worktree
isolates — its own HEAD, index and files — and its
remedy table's own worktree row read
prevented / prevented / prevented, so a coordinator reading the table
instead of the prose concluded a tree each was the end of the problem. Two
collisions on 7 Sep happened between agents that each had one: an agent's
git stash push <pathspec> failed on the pathspec, its
follow-up bare git stash pop succeeded, and it took
another agent's stash out of another worktree; and one agent overwrote
another's file in the shared scratchpad, after which 12 lines of somebody else's
work were pasted into a test suite. Both were caught by an agent noticing
something odd in its own tree, not by any check.rev-parse --git-path in a linked worktree: the working tree, the
index, HEAD and the HEAD reflog are per-worktree, while
refs/stash, refs/heads/*, the branch and
stash reflogs, config and the object store are one apiece
for the whole repository — the shared store being exactly why
committing works as the remedy. That corrects #846's own list, which
calls "the reflog" shared flat and so gets the forensically useful half
backwards. Recovery from a wrong pop is thinner than the incident suggested:
popping the last entry deletes refs/stash and its reflog, so
the work survives only as a gc-eligible dangling object findable by
the sha pop printed. If a stash is unavoidable it is named
(-m "<issue>-<slug>") and re-applied
by name after git stash list, which shows the branch each
entry was made on; scratch files go untracked in the agent's own worktree.tests/preflight-check.js now asks git which paths are
per-worktree, rather than grepping a brief for the word "stash". #846's own
AC5 refuses the obvious check — there is nothing to grep at dispatch time,
and a guard that reads an instruction instead of a mechanism is the line-shape
defect this repo has shipped four times. So the guard builds a throwaway
repository with a real linked worktree and discriminates on
--git-dir versus --git-common-dir: if a future git
makes refs/stash per-worktree, or stops isolating the index, it
reddens and the documented boundary gets corrected instead of going quietly
false. Beside it, the own worktree row of the remedy table is
asserted structurally — that it has more than three cells and is
not prevented in every one — because the table is what gets read.
Nothing in it runs git stash, that ref being shared with every live
agent. 135 checks to 156. Verified to fail, each break confirmed with
git diff --stat before the count was believed: reverting
docs/agent-dispatch.md wholesale reddens 4, the remedy
table row alone 2, developer.md wholesale 2, and
telling the guard that refs/stash is isolated — i.e.
contradicting git — reddens 1. Removing only the new section
reddens 1, and it reddened 0 before the instrument check was
added, because the table's own cells name both mechanisms; that zero is why the
check exists. npm test is unchanged at ALL PASS
(22,970 assertions) and reddens 0 — by construction, no file in
this change is read by anything tests/run.js loads.tests/manual/sso.js waited a flat 1,200 ms after clicking
the Microsoft button and then asserted — a bet that a redirect to the
stubbed sign-on endpoint, a redirect back, a form POST for the token and a
repaint all fit inside it. Measured here under six CPU burners on four cores,
against origin/master with nothing changed: 6 trials of 6
failed, a different assertion each time, and 4 of them crashed the run
outright after ~125 of the 219 checks — the next read landed while the
callback page was still parsing, so the shared layer did not exist yet and the
error named a line of the suite rather than anything about sign-on. It surfaced
during a sweep for an unrelated stylesheet change and was first read as a
regression in that stylesheet; disproving that cost a re-run of all 74 browser
checks on both trees.
TOAST_MS, and waiting for the toast to
clear instead would pass at any value whatever. The same idiom is
1,320 calls across 75 files in that folder, 70 of them the exact
click-a-sign-in-button-then-wait shape, and they are left for their own change
rather than swept unmeasured.Dates the app wrote itself were being read back as the wrong month.
Twenty-six places in the shared layer stamped a date and time as
07/09/2026, 03:40:00 — day first, as anybody in WA would write it.
Handed back to the app's own date reader that is 9 July, because the
reader takes a slash date month-first; and once the day of the month is past the
12th it is not a date at all. Measured across a whole month: 30 of the 31
days of August read wrong, eleven of them as the wrong month and nineteen as
nothing at all, with exactly one day right by coincidence — the 8th of August,
where the day and the month happen to be the same number. Every one of those
places now writes the one sortable shape the customer portal was moved onto in
v0.154.0.
The one that was not just a date on a screen. A quote's enquiry date is copied onto the job thread when the thread opens, and the thread's date is what the revenue pipeline's monthly buckets and the enquiry list's date filters read. An enquiry raised on the 5th of the month opened a thread dated four months earlier, one raised on the 12th opened a thread dated three months in the future, and one raised on the 13th or later could not be read at all — so the thread was quietly given today instead. That is fixed at the reader as well as the writer, which is why enquiries, quotes and threads raised before this release read correctly too.
Nothing already recorded was rewritten and no data was changed. The reader takes the old shape as well as the new one, so a credential filed last year, a toolbox-talk roll, a signed load check or a demurrage claim keeps exactly the characters it was written with — which matters most for the records kept for seven years. Whether to tidy the old rows up is a decision left for a human.
A stamp we cannot read still says so. Where a date arrives from somewhere else — a permit attached from the cab, a stamp read off a customer's email, a prestart filed on a tablet — a date we can read is tidied into the one shape and a date we cannot is stored character for character, then named as unreadable wherever it is used. It is never quietly turned into today's date and never dropped: "we cannot read this" and "there is nothing here" are two different facts and each keeps its own sentence.
Three separate copies of the day-first rule were also folded into one, so two screens can no longer disagree about when the same enquiry arrived — and a date that does not exist, such as 31 February, is now refused rather than rolled forward into the 3rd of March.
tests/manual/harness.js's
signIn() — used by 30 call sites across 13 suites and all six CI
browser shards — waited for the driver app's tab bar and then slept 1.2 seconds.
The tab bar is static markup that is in the page whether anybody signed in or
not, so it never proved a sign-on had happened: an empty driver register, a
changed password or a boot that fell over all returned quietly and left the
first assertion in the calling suite to fail in words about something else. It
now waits on the app's own state and names it — the login gate carrying
is-hidden and the driver switcher holding a chosen name, both
halves, then the marker that tab's own render writes. Measured on the same
suites under six and then twenty-four busy-loop CPU burners on four cores: the
tab draws in 27–346 ms and does not slow with load, because the whole path
is synchronous inside the click's own task, so the 1.2 seconds was buying no
settling — 36 of 36 trials passed either way, and the six suites ran 174 seconds
faster. No timeout was raised; each wait is bounded, swallows its own timeout so
a condition that never arrives is reported by the failing check's own words
rather than a stack trace, and every predicate is null-guarded because it is
polled while the page is still loading.fixed anywhere —
it is enough to be positioned inside a layer that is. All 23 such rules in the
app's stylesheets were checked in the browser, by walking each rendered box up
to the element that actually positions it: exactly one is anchored to the window
that way (the build stamp on the sign-on screen, which PR #859 fixes), six more
sit inside a pinned layer but are positioned against a box of their own and are
therefore fine, and one could not be rendered at all and is recorded as
unchecked rather than counted as clear.#login-form and then slept a
fixed amount, in ten different amounts across the folder — 700, 800, 900,
1000, 1200, 1400, 1500, 1600 and 1800 ms, 30 of them at 1,000 and 17 at 900.
Ten guesses at one boot chain was the finding: nobody knew the number, which is
why there were ten of them. They now go through one helper,
H.signInDispatcher() in tests/manual/harness.js, which
waits on three things the app itself does — #login carrying
is-hidden, an operator on NF.Identity.actor.name (or
NF.Auth.signedIn, for a build that has single sign-on), and
go() having put a screen in #view. No timeout is
raised anywhere; each wait is bounded at 15 s, swallows its own timeout
so the calling suite's own assertion still reports the failure in its own words,
and writes one named line to stderr saying what did not arrive.NF.Auth.signedIn and #login.is-hidden, both
halves. That pair is right for sso.js, which signs on through
Microsoft. It is wrong for these 63, which sign in through the demonstration
operator picker — and wrong in the reassuring direction, because
js/screens/63-identity.js's picker path is
setOperator() + enter() and never touches
NF.Auth at all, while NF.Auth.signedIn is
valid(session) and the picker mints no session. Measured on a real
page rather than read off the source: across 236 samples over 4,000 ms
of a picker sign-in, NF.Auth.signedIn is false in every one, as is
NF.Auth.configured (config.js ships
sso: null). Requiring it would have hung all 63 sites for the full
15 s each, swallowed the timeout, and then printed a stderr line that was a
lie. It is now accepted as one way in and never required. Two markers that
look usable are also refused, for the same reason: index.html ships
46 .nav__item elements and an actor already in
mode:'selection', so a predicate resting on either is true before
anybody clicks.sso.js, whose sign-on is a redirect to the authorize endpoint, a
redirect back, a form POST and a repaint, and which is not one of the 63
(its sign-in wait went in #848, and its button is #login-sso-btn).
The picker handler runs setOperator(), enter(),
boot() and go() synchronously inside the click's own
event dispatch — and await page.click() does not resolve
until that dispatch is done, so the sign-in has already happened when it returns.
Measured with a single page.evaluate immediately after the click, no
polling and no waiting, 6 trials at each of 0, 48 and 192 busy-loop CPU burners on
4 cores: 18 of 18 already had the gate hidden, an operator set and five
element children in #view. Not one trial at any load was still
mid-sign-in, and removing the wait entirely reddens 0 (12 of 12 green at
192 burners on the two suites that assert on the gate directly). So this is the
same finding #856 reached for the driver harness, and the honest claim is
67.1 s of guessed delay per full run (mean guess 1,065 ms) that
stops being spent — measured 56 s → 49 s over six suites,
one per CI shard — plus a legible failure, rather than a flake count. The
condition is kept rather than dropped altogether because it cannot become a bet
again if this path ever grows an await, and because it is what makes
a real failure readable: with the password deliberately wrong it reddens
1 of 19 in composition-layout.js and writes one named line,
where the fixed wait gave a thirty-line Playwright actionability trace.lost-revenue-location.js,
map-offline.js, odoo-import.js,
skills-matrix.js, rates.js), where editing them buys a
merge conflict somebody resolves blind. Three are the files
js/app.js names as pinning TOAST_MS at 2,600 by waiting
3,200 ms (compliance.js, locations.js,
rates.js) — a different wait from the sign-in one in each of
those files, and in them both were left alone. And four are
board-conflict.js, which was converted and then reverted on a
measurement: with the leftover-prompt fix above in place either way, base
code is green 8 runs of 8 at full cores and the converted version is green
4 of 8, failing "A MOVE THE GUARD DID READ DOES NOT CLAIM IT COULD NOT
LOOK" with the toast silent. In that one file the fixed 1,500 ms really
was doing something downstream — not for the sign-in, but for the
second of its two cross-lane HTML5 drags, which is timing-coupled to the
first. It is the single place the finding above does not hold, and it is held as
a named exclusion rather than smoothed over. Eleven further sites sit behind
the portal's #plogin-form, which has neither
NF.Auth nor a #login gate, so the condition this change
establishes does not describe them; they are named for their own change rather
than swept unmeasured. node tests/run.js reddens 0 for any of
this and always will — it loads no browser suite.NF.Training.skillsMatrix()'s catch answered
people: 0 — byte-identical to a register that is genuinely empty —
and stamped generatedAt: Date.now(), so a derivation that
threw carried a fresh timestamp and rendered "Nobody to show …
as at 09:14": a failure wearing a successful read's clothes, on the screen
that decides who may legally drive. It answers read on both paths
now, with no timestamp at all on the failure — there is no instant at
which that answer was true, so there is none to stamp — and carries the thrown
error's own message so the defect is diagnosable from the card. Tested as
read === false and never !read, for the same reason
active !== false is: every matrix minted before the flag existed
carries no such key, and a truthiness test would read all of them as failures.
The Skills Matrix screen's local smReadState() reads that shared
answer instead of inferring from a count, and draws a thrown derivation as its
own hatched card rather than as "Nobody to show" under an instruction to
widen a filter that was never the cause.backups key no longer reads as "the database says
there are none". apply() ran
NF.backups = NF.backups || [] unconditionally, so a response
carrying no backups key at all left an empty array — and
#/settings, the screen somebody opens to find out whether this
company can recover its database, could not tell we have never read the
catalogue from the catalogue is empty. It is guarded exactly like the
intakeQueue merge fourteen lines above it now, so both
server-authored collections follow one rule: an absent key means no opinion,
never a statement that the collection is empty. The panel renders the
difference — never read hatched, read and empty
dimmed-and-dashed, with a server-confirmed count of zero still outranking
both as the one measured empty on the card — asserted off
getComputedStyle().backgroundImage rather than off a class name,
because a class no loaded stylesheet defines ships flat and passes CI.
NF.backups.sort(…) moved inside the guard with the merge: left
outside it, it dereferences the value this change makes undefined,
which is a TypeError out of apply() rather than a wrong
answer. Every reader was enumerated before the shape moved — comment-stripped
across js/, driver/, portal/ and
backend/src, three lines name NF.backups and
exactly one is a consumer, whose Array.isArray branch had
until now been unreachable.tests/suites/61-apply-backups.js and
tests/suites/74-apply-collections.js pinning the collapsed
behaviour while their own comments said it was wrong. Those assertions now pin
the three states, keeping the retired claim quoted inside the correction so the
next reader can tell a fixed claim from one nobody ever made, and
docs/modules/backend.md's note about the asymmetry is corrected in
place — including a #530-era sentence saying the client-side merge was "NOT
yet in js/integrations.js", which #593 had made false.waitForTimeout(1000) back after a sign-in click was
invisible to npm test, to npm run checks and to CI.
An honest zero is a statement that a change cannot be undone visibly,
which is why the guard is its own piece of work rather than a nicety.
login: #dlogin- is the driver app's sign-in form
and #plogin- is the customer portal's, and both contain it. So the
guard matches #login with the #, which on the tree
before the change finds 75 sites across 61 files — and the naive
version reproduces the issue's 76 to the site, which is how the contamination
was confirmed rather than argued. Widening it back reddens 6.if (gated) { … } has a closing brace on
the line above the wait, so a line-based reader cannot see it at all, while a
naive split on semicolons over-reaches and blames a sign-in five statements
back for an unrelated delay. The file is masked for strings, templates,
comments and regular-expression literals — without that last one,
a pattern containing a quote swallows the rest of the file and 25 of 78
suites become unreadable while the guard goes on passing — and the
statement is then read at the delay's own bracket depth. Removing the
brace-stepping reddens 2, and reddens nothing in the sweep, which
is exactly why the guard is also driven over twenty fabricated shapes: the real
sites carrying that shape sit in files the ledger already names, so their count
merely falls.compliance.js, locations.js, rates.js
and sso.js the 3,200 ms wait is the assertion —
each reads a refusal still on screen long after a toast would have cleared, so
waiting on the toast to disappear instead would pass at any lifetime whatever
and unpin the constant. Removing one of them does not fail; it silently stops
asserting, which is worse, so each is now asserted present with the
toast read behind it (removing one reddens 2), each exemption states
why rather than being counted (a guard asserting "exactly four
exemptions" passes when somebody swaps one for another), and the margin over
the toast lifetime is derived from js/app.js rather than
written down — so raising that constant past the pin reddens 1
here instead of leaving four suites green and asserting nothing.board-conflict.js's
#703 section drags a run onto another line with the allocation guard broken, then
drags it back with the guard working. The stub API answers every PUT with a
conflict for that whole section, each drag dirties the board, the app autosaves
every 5,000 ms and NF.Store.remotePut() debounces 1,500 ms
on top — so a fresh .clash prompt lands between 1.5 and
6.5 s after a drop, which straddles both drags. .clash is
position:fixed bottom-right with no pointer-events:none,
and Playwright scrolls this wide grid so both ends of the pair sit under it
(measured at x 1262–1320 inside the prompt's own 986–1426), so
the drop is intercepted: the run does not move, nothing toasts, and
page.dragAndDrop still reports success. #872's remedy for the
leftover prompt dismissed it once, and a dismissal cannot outrun an ack that has
not arrived yet — measured at one pinned core, 3 runs of 8 were
red with the prompt dismissed and asserted gone before the drag, and in 2
of those 3 it was absent when the drag started and present when it finished. The
board is now drained on named bounded conditions — the queued write pushed
out and acked, then dismissed through .clash__ok, then nothing held,
nothing pending and nothing on screen — before each drag rather than
only the first, and the stub goes quiet for the section as well. Green 20 runs
of 20 (8 at one pinned core, 6 at two, 6 at four), 178 checks each. Which of
the two drags the prompt hit was a coin toss, which is why it read as flakiness
rather than as one mechanism.handleScheduleDrop splices a run out of its cell and pushes it back
into the target even when the two are the same cell, and still toasts
"Schedule updated" — so for as long as the first drag was broken, the
second dropped the run onto the cell it was already in and
"A MOVE THE GUARD DID READ DOES NOT CLAIM IT COULD NOT LOOK" was green over
a board that had not changed. That assertion is untouched; a new fixture check
beside it asserts the drag back really is cross-lane, measured off where the run
is rather than trusting the drag above, so the precondition reddens on its
own terms. Verified to fail: gutting the drain reddens 5 of 178 in 5 runs
of 8 at one pinned core, and the same state with the new fixture removed reddens
4 of 177 — that one assertion is the whole of what stands between a
vacuous pass and a named red, and nothing else in the tree can see it.board-conflict.js take #863's condition
after all, and the exclusion that held them is retired. They were reverted in
289857e2 as #863's ninth named exclusion, on a measurement —
green 8 of 8 with the fixed 1,500 ms and 4 of 8 without. That measurement
stands; its diagnosis was the half that was wrong. The padding was never
covering the second drag: it was a phase shift on the 5,000 ms autosave, and
what the delay decided was whether a conflict prompt landed on top of a drag.
With the coupling fixed at its source the converted file is green 20 of 20
across three load conditions, including 8 of 8 at one pinned core, where
the converted version without the fix was 5 of 8. So H.dispatcherIn
now covers 67 call sites across 54 files with no exceptions left in #863's basis — re-derived here rather than taken off an issue — and the four
add 6.0 s to #863's measured 67.1 s of padding (67 sites, 1,091 ms
mean). #871's SW_DEBT ledger entry for this file and
harness.js's own copy of the exclusion both still read
"deliberately not converted" and are now stale; neither file was in this
change's lease.master, each run in its own process: 22,970 assertions under
UTC, 22,970 under Australia/Perth and
22,977 under America/New_York. The whole verify-to-fail
discipline is arithmetic on that total — break a guard, count the reds,
check they sum back to the baseline — so a break measured under one zone
and re-checked under another looked like seven assertions appearing out of
nowhere, which reads as an abort that never happened; and a real abort of seven
could be masked. It survived because the day-independence recipe re-runs under
UTC and Australia/Perth, and those two agree.
The total is now 22,982 in every zone — identical, not pinned.
Pinning the runner's TZ was refused: it hides the defect and throws
away the third-zone check that is the only thing that found it.UTC and Australia/Perth the six claims that actually
measure the defect — the 96-hour sweep of both day-key forms, and the four
“the calendar step names ONE yesterday at every hour of the day”
checks — did not run at all, while the two those zones did run ran
nowhere else. The guards on the derivation were absent from precisely the two
zones anybody runs, and both suites were green in both. Both groups now derive
their window from the clock instead of branching on it, and make the same nine
and seven claims on every clock, differing only in the data they report.TZ=Australia/Adelaide and
TZ=Pacific/Auckland each reported 22,975 passed, 2 FAILED.
America/New_York's next crossing becomes a spring-forward on 2 Nov
2026, and the suite asserts its own America/New_York child exited 0
with ALL PASS — so from that date the same two reds appear
under UTC and Australia/Perth too. The direction is
now reported and never predicted, in four named cases.TZ=America/Santiago
reported 22,975 passed, 2 FAILED, a different pair again, its sweep
finding 0 of 96 hours disagreeing across a real crossing. Only the certain
direction is asserted now — nothing to cross means the two forms agree,
which is the half neither Australian zone used to state at all.TZ=UTC, the zone that could not see the
original defect at all, and 4 under TZ=Australia/Adelaide;
writing the disagreement as the tempting iff reddens 1 under
TZ=America/Santiago and 0 under UTC. Nine zones
now agree at 22,982, including a half-hour offset, a 30-minute transition
(Australia/Lord_Howe) and a two-hour one
(Antarctica/Troll).npm test and to CI. It was a gap rather than a
judgement: every path to a guard was a file two other changes were mid-edit in.
154 assertions now stand behind it, driven through the real writers under
a clock pinned inside the test realm so every mint shares one millisecond by
construction. Four properties: an id minted inside one millisecond is a distinct
stored id, on each of the ten writers a test can reach;
NF.Locations.add writes rather than refuses one — measured
here rather than quoted, by putting the old mint back and watching it write 1 of
400 and refuse 399 as duplicate_id; a fallback id cannot move
NF.Rates.nextId() or its charge-register sibling, where one
clock-derived id used to push the numbering into the hundreds of thousands
permanently, archived rows included; and the authoring counter behind a quiz
question's three ids is load-bearing — removed, 1,498 of 1,500 mints
collide. The four js/screens/ sites no test can drive are named as
such rather than reported as swept: three are reached by reading the shipped
source and one by extracting the shipped text and running it, which is the only
way to measure a counter.pointer-events rule at all and swallowed every pointer event that
landed on it. Measured at 1440×900: the prompt at
986..1426 × 600..841, a real drag dropped at the target
cell's own centre (1320, 721), p.clash__p the topmost
thing there, the target line 0 runs before and 0 runs after, and the
toast box empty — a silent loss, indistinguishable from a move that
worked. The drop now lands: the box passes pointer events through and
only its three controls keep theirs, so the cell receives the drag it was aimed
at. The box was not moved, shrunk or made to auto-hide — all three would
have traded this defect for a worse one. The same exposure was live one
screen over, on the Dispatch Board at #/jobs: two of its four
columns overlap the prompt (33,471px² and 67,182px²), where the
prompt held 780 of 780 hit-tested points before and holds 63 of
780 now — its own buttons.min(70vh,…). In the ordinary state the whole box passes
through bar the controls; nothing is ever silently clipped and no control is
ever unreachable. Guarded in tests/manual/board-conflict.js, which
hit-tests live controls with elementsFromPoint rather than
comparing rectangles — a rectangle says where a box is, and only a hit
test says what receives the click.tests/manual/board-conflict.js’s four dispatcher
sign-in waits with a condition and reported both stale claims itself,
deliberately not touching them because they were outside its lease: the
exclusion in tests/manual/harness.js, and the first entry of the
sign-in debt ledger in tests/suites/75-repo-guards.js, whose reason
was written in the future tense about work that had already merged —
“#872 owns the coupling, and its criterion 2 is what makes these
convertible again”, with #872 closed and that criterion met. Both are
now marked false in place and name the commit that falsified them
(3ea9ff4d) rather than being quietly restated, because several
measurements here were sized against exactly those figures. Re-derived rather
than trusted: the file holds five H.dispatcherIn calls, not
the four in the original claim — one was added by b5b49e9c
after the issue was written — and the ledger had ten entries where
the issue said nine.found <= sites — and that is the right choice, because a
found === sites equality passes the moment somebody swaps one
exemption for another. What nothing recorded is the consequence:
board-conflict.js owing 0 against a ceiling of 4 was
green in exactly the same way as owing 4, so a fully repaid entry was
indistinguishable from one nobody had looked at since it was written, the
ledger could not detect its own staleness, and every future conversion would
leave a fossil behind it reading as live debt — with no upper bound on the
mechanism. The fix is not the equality, which also reddens a partial
conversion and so taxes the good deed the ledger exists to encourage; it is the
three answers, never two rule this app applies to every screen, arriving
in a test guard. found > sites is debt that grew,
1 <= found <= sites is live debt, and found == 0
is repaid — red once, named, and remedied by deleting the entry.
That is a floor on the entry rather than on the count, so the ceiling is
untouched and a file owing 3 of 4 still passes; sites: 0 is refused
too, because zeroing an entry keeps the fossil while wearing the check’s
own clothes. The repaid entry was deleted rather than marked, which puts
the file under the containment assertion instead — stricter than any
ceiling, since a wait coming back there now reddens as an unledgered stray and
names the helper to use.pickWorkingDriver() — which selects a driver who actually has
work on the open day, the most repeated test defect in this repository —
followed its selection with a flat 900 ms wait, and that wait was padding
at all eight call sites: dispatchEvent is synchronous, so the
driver app’s change handler has already set the driver,
repainted the bar and re-rendered before the call that dispatched it comes back.
It is a bounded condition now — the picker still carries the driver that
was asked for, and the app has drawn something for them — kept rather than
simply deleted so it cannot become a bet again the day that path grows an
await. Measured on one pinned core, eight trials each way
over the four suites that call it: green 32 of 32 suite runs with the
900 ms and 32 of 32 with the condition, 508 checks per trial
identical in both arms, and 7.2 s of a ~189 s pass no longer spent on
nothing. A green run at full cores would not have been evidence — #872
measured 0 red of 6 at four cores and 3 red of 8 at one pinned core on this same
folder. The blind spot mattered more than the 900 ms: the sweep that
polices these waits requires a sign-in behind them and this one sat behind
a page.evaluate, so nothing in the tree would have noticed it coming
back. The guard is therefore about the file every suite shares, where one delay
is paid by all of them, with an allow-list that ships empty and requires a
sentence per entry.git diff --stat”
cannot see a same-line substitution, which is the commonest way a guard is
broken. Every verify-to-fail figure in this repository is arithmetic on a
deliberate break, and the step that confirms the break landed exists because
a break that never applied looks exactly like a guard that does not
exist — nothing changed, the run says ALL PASS, and the conclusion
written down is nothing defends this. But --stat counts
changed lines: measured on git 2.43.0, flipping an operator
(!== → ===) and swapping a value on a different
line both report 1 file changed, 1 insertion(+), 1 deletion(-),
byte-identical, so --stat says something happened and does not say
what. #878 hit this on two of its four breaks and confirmed each
with grep -c on the changed text instead. This is not the
--stat-is-empty case the step was written for; it is the opposite,
and a brief that stops there has satisfied the letter of the rule and learned
nothing.7f067020: grep -rni 'diff --stat' --include='*.md' docs/
.claude/ returned two files —
docs/modules/rates.md, the only committed copy since #355, and one
⚠ clause added to docs/modules/testing.md by #886 ten minutes
after #887 was filed against a base where the answer was one — and
0 in docs/agent-dispatch.md,
.claude/agents/developer.md, product-manager.md and
README.md, so the process that actually runs git commit
was told this by dispatch brief and folklore and by nothing on disk. It is now
one section of docs/modules/testing.md carrying the table, cited
from the dispatch doc, the developer brief and the return-reviewing brief rather
than duplicated into any of them. Two rows were checked rather than
transcribed and turned out to be exact mirror images:
git checkout <ref> -- <path> stages the restore,
so a plain git diff --stat is empty, while
git restore --source=<ref> <path> does not, so
--cached --stat is the empty one — each recipe blind to the
other. Hence git diff HEAD --stat for presence and
grep -c for identity. rates.md's sentence and
figures are deliberately left as they stand: its breaks were line removals,
where --stat genuinely is sufficient, and rewriting it would
misrepresent what was run.git diff --stat inspects an instruction rather than a
mechanism, which is the line-shape defect this repo has now shipped four times
(#752, #788, #808, #815). The one genuinely mechanical property available was
git's own staging semantics, which tests/preflight-check.js could
ask git directly the way it already asks which paths a worktree isolates
(#846) — and it was eliminated as a subject instead of guarded:
leading with git diff HEAD --stat, which reports a staged and an
unstaged restore alike, means a future git behaving differently would change no
conclusion drawn from the table. Making the recipe insensitive to the fragile
fact is worth more than a check defending it. npm test therefore
reddens 0 for this change, which is the honest answer and is stated
rather than left as a silence.prime, dropdeck), which is deliberate and unchanged:
it is a pure function of the name, so two dispatchers adding “Drop
deck” arrive at one id without asking each other. Where there is no slug
there is no content to derive from, and the fallback was
't'+Date.now() — one id per millisecond, no randomness at
all. It now mints through the one record-id minter against every id already on
file, so a collision on a client is impossible rather than improbable.
Asset types merge by id, so a collision was silently absorbed rather than
refused: one type's pool, tracking flag, day rate and rate provenance would land
inside another's, and a single tracking flag on one type has already been
measured moving fleet-wide movement history by 137 assets. Measured on this
tree through the real writer with the clock held still: the old mint wrote
1 of 400 and refused the other 399 as “That type already
exists” — so for a name with no slug that refusal only ever fired to
absorb the mint's own collision. The new one writes 400 of 400, each with its
own id.Date.now().toString(36) — this site left the
base-36 conversion off. Widening that scan to Date.now() used
anywhere in an identifier was measured rather than assumed, and is not
recommended. The app holds 325 Date.now() call sites
with comments stripped; a scan for all of them would flag 325 and find nothing
left to fix. The widest scan anybody could actually write — the value
reaching a string, by concatenation, template or
.toString() — flags 8, of which five are not
identifiers at all (a cache-busting query parameter, two “as at”
captions, an ETA sum, a stored timestamp) and three are identifiers already
examined and deliberately left: the device suffix, which carries randomness and
its own avoidance loop, the quiz-authoring handle, whose counter is what
separates it and is now pinned, and a capped forty-row in-memory arrival list
that is never persisted and never merged by id. Zero of the eight is an
unfixed mint, so the widened scan would ship needing eight exemptions to find
nothing. What is worth building instead is stated positively and is not a text
search: ten writers now have their mint proved to go through the one
minter, against 50 collections the backend persists — so the
question to ask is which collections have no such proof, not which lines
mention a clock. 36 assertions now stand behind this one, driven by
extracting the shipped click handler off disk and running it against a pinned
clock, because the regression suite loads the shared layer only and a screen
file wants a browser.active:false
and a stamp of who retired it and when, it drops off the Types table and off both
“Set type” pickers, and a retired row offers Restore. Its name,
rates and measurements go on resolving for every register row that cites it, which
is the whole reason archiving beats deleting. Retiring a type that register rows
still carry is refused with the count in the sentence, and a name that would collide
with a retired id is refused in its own wording rather than under
“that type already exists”, which over a row no picker shows is a
sentence nobody can act on.' ' is truthy, so it cleared the guard and the
trimmed value — the empty string — was stored, leaving a nameless row
in the one collection every screen groups and labels the fleet by. The rename box
beside it had always guarded this and the add control had not. The refusal is the
writer's own sentence, printed in the panel rather than as a toast that
slides away while somebody is reading it, and nothing is filled in on your
behalf: a type nobody named is not a type called “Untitled”.
Cancelling the prompt is still silent, because changing your mind is not an error.0. Measured while sizing the archive: of 65
places the app reads the asset-type taxonomy, across 18 files, exactly
one applied the active !== false guard — but 31 of the
rest look a type up by id to name, price or validate a record that cites
it, where the guard would reproduce the deletion defect it replaces. The seven
that offer a type as a choice are what needed it; three are on this screen and
are fixed, and the other four are named in the code for the change that takes
them.(archived) so
editing an asset, a geofence audience or a course audience cannot silently blank
it. That behaviour shipped with every claim about it made against the markup the
app composes—and a class no loaded stylesheet defines ships flat and
passes every markup check ever written about it. 85 checks added to four
existing browser suites now read the rendering: the
(archived) marker is measured as computed colour against its
painted background (16.34:1 in the asset form's select, 16.34:1
beside a 24×24px tick in both audience editors), and Settings'
“N retired — not counted above” pill is measured dimmed and
not hatched—6.45:1 for its wording against 15.35:1 for
the figure beside it, against a hatch probe minted in the same page so the
negative is a measurement rather than a tautology. Hatched means we do not
know; a retired type is something we read and know about.(archived) marker reddens 3 checks; the reader
no longer round-tripping the held value reddens 6, and so does the form
renderer no longer handing it in; the geofence audience 10; the course
audience 13; removing the Settings pill 13. Removing the pill's
stylesheet link reddens 6 of the computed-style checks and 0 of the
markup ones, which is the proof that these read the rendering.preflight.js --prior-art 702 printed "PRIOR ART
EXISTS. Read it before writing anything" over a branch, 13 unmerged
commits and a "NOT merged into origin/master" label, every word of it
true, while master already carried the same rules by four other commits as a
strict superset. The dispatch was spent establishing that three of its four
merge conflicts were rival implementations of one rule rather than different
work. The failure was one-directional and reassuring — it over-reports, so
nothing breaks and a dispatch is simply spent, which is exactly the cost #693
built the axis to save. Each branch and each listed commit is now
classified, and where the work has landed the headline says "PRIOR ART
EXISTS AND HAS ALREADY LANDED" and tells the reader to go to master rather
than the branch.[ALREADY ON ORIGIN/MASTER] (no commit on it master
lacks), [SUPERSEDED], [PARTLY LANDED] (N of
M patches landed — what a branch merged and then written on again looks
like), and [COULD NOT CLASSIFY], which is never
printed as absence: it names
list_pull_requests state:all head:<branch> as the
authoritative answer no git command can reach, and a shallow clone lands there
by name too. The fold goes that way on purpose — the pre-#897 output's one
strength was that it failed towards go and read it, and a version failing
towards nothing to see would be worse than the defect. A superseded
finding still exits 1: something was found and it changes what the
dispatcher does, so 0 stays reserved for a measured none. The citing
commits carry the same marks (landed / in a sq /
absent / ?) and are counted, because 8 of 14
already on master and 14 unmerged commits cite this are different
facts about one issue.git cherry alone would have measured nothing — and that is the
measurement this change rests on. A squash merge is this repository's
convention and it turns N commits into one whose patch is their
sum, so per-commit equivalence cannot hold for N > 1.
Measured 8 Sep 2026 against origin/master cc2b36c8:
squash-merged origin/claude/776-ambiguous-asset-label (PR #798,
landed as c6897675, still on origin) has all 3 of its
commits reported absent by git cherry — the same two-plus-marks
answer a never-merged branch gets, which is #827's null instrument on a non-zero
number. So a second exact test was added: the branch's whole net change as
one patch-id, looked for among the commits master gained since the branch
diverged, narrowed to commits touching the files the branch touched
(100 candidates → 11, 2.3 s → 181 ms).
Over all 369 remote branches git cherry classifies 29
as landed and the net patch 8 more — including a 140-commit branch
(PR #675, 94108159). Both are sound in one direction only:
they may upgrade a branch to already on master and may never downgrade one
to nothing to see. Merge commits are not examined (
git cherry skips them, measured 4 ahead and 3 reported) and the
answer says so where a branch carries one.git merge-base --is-ancestor
and git rev-list --count origin/master..<branch> answer
whether a branch's history is on master; a squash merge puts its
content there under a commit that is nobody's ancestor. Measured here:
rev-list --count read +1 for all 35 worktrees, three of
whose PRs had merged that day. tests/preflight-check.js asserts
is-ancestor appears nowhere in the tool's code and that no
rev-list --count takes a range — the one that remains measures the
clone (#688) and takes none. A line-presence probe over the touched files
was built, measured and thrown away rather than shipped: 30.5% of #702's
own added lines were on master, against 94.4% for a squash-merged branch
and 83.9% for one with two genuinely unlanded commits, so no threshold
separates them — docs/agent-dispatch.md's "do not add a
heuristic here; read the branch", measured a fourth way. What is still
not answerable is #702's own shape: content that reached master as several
commits, or as a rewrite of the same rule. No patch on either side matches, so
it reads as could not classify, which is not absence and never is.tests/preflight-check.js now builds six real branches in
throwaway remotes — never landed; landed under a completely different commit
message (#702's actual shape, and the case a subject grep cannot reach at
all); squash-merged with two commits; squash-merged then written on again; and
two whose commits name no issue at all — then drives the real tool over a clone
of each. The squash fixture asserts the null-instrument claim against
git cherry directly rather than through the tool, since reading the
tool for that would be circular. The last two exist because the assertions
could not see the thing they were written for: folding could not
classify in with the landed states — the one regression that would be worse
than the defect — reddened 0 at first, because every fixture branch had
commits citing its own issue and the commit half of the headline test carried
it while the branch half was never exercised. A branch named for an issue whose
commits do not mention it is the only shape where the headline rests on the
branch verdict alone.
Verified to fail, all measured in /home/user/fv-897 on
cc2b36c8 with TZ unset, resolved UTC, against a baseline of
201 checks: reverting the branch line to the pre-#897 "NOT merged into
origin/master" label reddens 13; replacing the classification with
rev-list --count — the naive fix — reddens 13; removing the
net-patch instrument and keeping git cherry alone reddens 6;
folding could not classify and could not look in with the landed
states reddens 3; and re-introducing --is-ancestor reddens
1, which is the source guard and the only thing in the tree that could
see it. node tests/run.js stays at 23,439 under all five, by
construction — it requires none of these files, and saying so is better than
leaving it a silence.NF.suggestAssetTypes built its known set off the
whole taxonomy, and NF.ASSET_PATTERNS emits exactly the
seeded ids that can be retired — so the one gesture on this screen that
writes across the entire register in a single confirm was also the one that
could write an id nothing may be assigned to any more. It now reads
NF.assetTypesLive(), which is the one call of that reader in
js/data.js and is asserted at one: every other read in that file
resolves a type a record already cites, and archiving exists precisely so
those keep resolving. Retiring a type is refused while it holds live rows, so
this looked impossible — the bulk proposer is why it was not.NF.assetsOnRetiredType() answers which live register rows carry a
retired type, by name and count, never a percentage, grouped by the type
they are on, with the list capped and saying so. There is deliberately no
fix these button: suggestAssetTypes' own rule is that it
"never touches an asset a human has already grouped", a row carrying a
retired type is grouped, and there is no correct value to rewrite it to
— the honest next moves are re-type it by hand or restore the type, and
both are decisions. Three states, not two: a taxonomy nobody has read answers
not known and is drawn hatched, a register we did read with none on a
retired type gets its own sentence, and neither prints a bare 0.
Archived assets citing a retired type are counted separately rather than
folded in, because an archived row is a fact and not a worklist item.importAssets() and
NF.mergeOdooAssets push literals carrying type:''
straight onto NF.assets, and the FMS import (#727) names an asset
nowhere at all — it lands customers, places, contacts and con
notes. The register also genuinely holds untyped rows (#537 measured 813, then
426, then 23, then six), and refusing a blank outright would block the one
gesture those six need, which is opening NF1048 — a person's
name typed into a vehicle-description field — to correct the description.
So a create stays accepted and the clear is refused by name, because that
is a real loss arriving as a successful save: the type select falls to
— not set —, the form posts that back, and an asset somebody
opened to fix a plate comes out off every pool, unpriced and out of movement
history. The refusal is in one place — the writer, whose own
sentence the form prints — and not repeated in the form, and only the
writer can see it: a type id absent from a synced taxonomy renders no
option at all, live or retired, because apply() replaces
NF.assetTypes wholesale while merging assets by id.NF.Registry.list('driver') filters archived
rows, so a vehicle naming a driver who has since left the fleet had no matching
<option>: the select fell to — none — and
the save wrote that back. It now keeps the row's own stored value, marked
(archived), through the same f.options(val) seam the asset
type picker uses. There is deliberately no writer refusal for clearing
this field — — none — is an ordinary answer for a
vehicle with no usual driver, so choosing it is a decision, where a blank type
means untyped, unpriced and out of every pool.podImage — the signature the person on the gate wrote on the
driver's screen — one line above the printed name #783 had just withheld
from the same box. A signature is the more identifying of the two, not the less:
it belongs to a third party who was never asked, it is reproducible, and it is
the one thing on that page nobody else could have re-typed. Where the artefact
has been offloaded it is an absolute /media/ URL that
GET /media/{key+} serves to anything the shared site token lets
through, so publishing it published a fetchable URL and not only a picture.
It is now withheld from that render, and it is not rendered at all
rather than hidden — a CSS-hidden image still ships the artefact to
anybody who views source, which does not undo a disclosure. The image is
untouched on the full delivery record at #docs, which already
refuses a link holder by name, so the customer loses nothing they could reach
before. Nothing about the stored record changes: this is a display scope, and
the POD is still the POD.tests/run.js, 6 in
tests/manual/portal-selfservice.js and 2 in
tests/manual/portal-history.js; removing the withheld row
altogether reddens 4, 10 and 1; hatching it instead of dashing it 3, 3 and 0;
and folding the third answer into the second 4, 2 and 0. Taking the image off
#docs as well — the over-withholding direction —
reddens 1 in tests/run.js, which is the assertion that keeps every
“the image is absent” check above from passing over a codebase that
no longer holds a signature at all.tests/manual/board-conflict.js failed 7 of 222 on CI from
9 September, identically on unrelated branches, which blocked every merge behind
a judgement about whose red it was. The seven are the ones that assert a drop
onto the board under the conflict prompt lands rather than being lost in
silence, so loosening or skipping them was refused. The cause is in the check,
not the app: the drop end of the drag has been hit-tested since #880, and
the grab end never was — it used the run's geometric centre. The
schedule grid is a wide table inside a horizontally scrolling wrapper, so a run
at the right-hand edge is clipped by it, and on 9 Sep the pair the check
arranged was a run spanning 1371–1468 inside a wrapper that clips at
x=1420: its centre was 1420, exactly the clip boundary, so
elementFromPoint returned the wrapper and mousedown
landed on nothing draggable. Instrumented, the whole gesture fired
dragstart 0, dragover 0, drop 0, dragend 0 —
nothing moved, nothing was said, three times over. The grab point is now the
centre of the run's visible box and is proved by a hit test before the
drag starts, with candidate pairs tried against a real scroll until one reaches
the run; the seven assertions are untouched.grab at 1420,529. Load does not
reproduce it: a 6× CPU throttle, a 6 second stall between the
arrangement and the drag, and four concurrent copies of the suite on a four-core
box are all green. The measurement, the two readings it corrects and the reason
the #/jobs drag at the foot of the same section stayed green are
recorded in the suite so the next person does not re-derive them.status:'delivered', with no signature and no recipient name on the
record: a claim on evidence we do not hold, on the document a customer opens
from a tracking link. It now tests NF.podCaptured — the two
artefacts POD capture writes, and deliberately not the status — which is
the same reader backend/src/edi-coates.js requires before it will
derive a delivery milestone for Coates, so our outbound EDI and the customer's
own page finally agree about the same record. Three answers, never two:
delivered and signed for, delivered with no signature captured (a known
negative, so dimmed and dashed and never hatched — we read the record and
there is none), and not yet delivered, which draws no box at all. A
consignment carrying a proof of delivery and a recorded delivery problem
claims nothing and is drawn hatched, because nothing here can tell which of the
two is current. And the asymmetry both ways round: a status may never
make a delivery and may always unmake one, so a POD the cab
captured before the office rolled the status now shows the customer the
signature it really holds — where before the page showed them nothing.NF.Store.refresh() and
reconcile() both end in
.catch(() => emitSync('pending')) — correct for a dropped
connection, and it stays — but that catch also absorbed a
TypeError raised anywhere inside apply(), so a
permanent failure came out wearing a transient one's words:
the database has not answered yet, when it had answered.
apply() merges its collections in order, so every collection
after the failing one silently never landed and the app carried on with a
partial document while the pill said it was waiting. Synced, not
answered yet and answered and could not be applied are three facts
and three different phone calls; the third had no representation at all. It is
now its own state, apply-failed, converted at the one
emitSync choke point that already narrows a refused build's
synced — so all four call sites are covered by one line, and
blocked, conflict and refused-demo pass
through untouched because this may only ever narrow what a listener sees.
syncState() answers it ahead of everything, including
local: every other answer in that list describes the write,
this one describes the document the page is holding, and a build with no API can
hold a short one just the same./state: (1) after a successful reconcile
reconciled is true, so a later pull whose merge threw walked
straight past the never write over a database we haven't read gate — the
tab kept its stale copy and the next autosave PUT quotes
wholesale over a database holding one more, deleting the office's newest
quote about a second and a half after a sync the pill called pending; the
closing tab's flush() did it too. (2) On the boot path the
reconcile gate did hold — but save() wrote the partial snapshot into
localStorage and set the dirty flag, and the next boot
re-applies a dirty cache over the database and pushes the result: with the
merge throwing in its first branch, the next boot over a healthy database PUT
quotes: [], which deletes the collection, because
PUT /state writes a col# singleton wholesale for every
key that is present. The collections at risk are the four wholesale-replace ones
— quotes, jobs, schedule,
trailers, i.e. the board and the quote register. So while a failure
is outstanding pushNow() sends nothing (and deliberately does
not clear pending — the write is held, not refused for
ever), flush() returns before both its direct write and its
keepalive PUT, and save() writes neither the cache nor the dirty
flag. Nothing is lost by that: the copy at rest stays the last document
that merged in full, and a driver's capture never depended on it — it is in
NF.Outbox under its own keys, replayed at every boot and after every
pull, which is exactly why refusing here is safe in a cab. Only a merge that
completes clears the record, so it heals rather than latching: the existing 15 s
reconcile retry re-reads, and a clean read releases every write it was holding.NF.Store.syncApplyFailure() answers
{at, phase, collection, notLanded, error, message}, with
message the store's own sentence so a screen prints it rather than
inventing a second wording. The merge sees the document through a read-tracking
Proxy, so both facts come off the document itself rather than a
hand-kept copy of the merge order that would go stale the first time somebody
adds a collection. Past the merge the phase flips to derive and
collection answers null: naming the last-read
collection for a throw in NF.reconcileJobs() would be a confident
wrong answer rather than no answer, and a realm with no Proxy
degrades to the same honest we cannot name it. The backups card on
#/settings gains its own branch for it, hatched — an unknown,
not the dimmed-and-dashed of a measured absence — and it says an empty list
there is neither a catalog with nothing in it nor one nobody has read, but one
nobody could finish reading. A server-confirmed count still outranks it,
because that is a measurement. The shared POD reader
NF.Odoo.writeOutcome() keeps the capture queued and
hatched but stops telling a driver it "goes when the connection is back",
which is the one sentence that would send them out of the yard on it.NF.podCaptured — the one reader for was an artefact
captured, and the reader the single-shipment box one function away had just
been moved onto. So a load whose driver captured a signature image and
typed no name was not counted at all, and two renders on one page disagreed
about one rule: measured on the fixture, the old count answers 2 of 3
where the shared reader answers 3 of 3. Worse, at zero the count was
dropped silently while the card went on saying “Open the full delivery
record for the per-load signatures and photos” — so a shipment where
not one load held a signature pointed the customer at per-load signatures. An
absence rendered as an invitation, on the exact figure they wanted.
Four answers now, and the roll-up is never truer than its loads. Every
load signed for keeps the tick; three of four reads “Signed for on 3 of 4
loads” and names the one that holds nothing; none reads “0 of 4 loads
have a captured signature”, stated rather than left silent, and drawn
dimmed and dashed because we read every load and there is none — a known
negative, never hatched. A load carrying a proof of delivery and a
recorded delivery problem withdraws the claim for the whole shipment and is the
one hatched answer, because it is the only one that is genuinely an unknown. A
majority is never rounded up into a claim — names and counts, and no
percentage in any answer — and the invitation is withheld wherever there is
nothing to open, or reworded to the signatures and photos we do hold. The link to
the delivery record itself stays: that record exists and carries each load's own
delivery time and photos, and it was the promise of signatures that was false,
not the record. It is one decision per load called from the roll-up rather than a
fourth copy of the rule, so a load the cab stamped before the office rolled its
status is still counted — a status may never make a signature and
may always unmake one.#docs counted signedBy — a name somebody typed
— rather than NF.podCaptured, which is what the artefacts a
driver's capture writes are really read with: the delivery-record list printed
“3 loads · 2 signed”, and the assembled split delivery record
printed “Signatures on file 2 of 3”. The shipment card next door had
already been moved onto the shared reader, so one consignment read
“Signed for on all 3 loads” on the tracking page and “2 of
3” on the documents page — same account, same con note, and
whichever a customer read first made the other look like a mistake. The list also
told a customer “No signature captured” for a single con note
whose driver captured a signature image and typed no name, which is the
same defect on the other path. #docs is the surface a proof of
delivery is downloaded from, so it was the wrong one of the two to be loose about
what a signature is.
Both readers now call the one decision, and all three surfaces are asserted to
print the same sentence for the same shipment — the agreement is the
fix, and the count is only how it is measured. Every load holding an artefact
keeps the tick; a shortfall reads “Signed for on 3 of 4 loads” and
names the loads that hold nothing; none reads “0 of 3 loads have a captured
signature”, stated rather than dropped, and drawn dimmed and dashed because
we read every load and there is none. A record carrying a proof of delivery
and a recorded delivery problem is the one hatched answer, because it is
the only one that is genuinely an unknown. A status may never make a
signature and may always unmake one, so a load the cab stamped before the
office rolled its status is still counted. Names and counts, never a rate. No
signature artefact moved: the delivery record still renders exactly the
signature images it always did and still names who signed under its own
“Signed by” field — what changed is the claim beside them, and
the list row no longer prints a recipient's name, because a name was never the
test.apply-failed state, new in this release — met the pill's
fall-through for unrecognised states and came out as waiting. Not
claiming Synced was right as far as it went; it still claimed a slow
connection. It now reads "Data incomplete — not saving. Refresh the
run."jobs and
schedule, which are the driver's run. So the tablet could be
holding a short board while telling the driver it was catching up. Waiting
invites patience; incomplete invites a pull, and only one of those was
true. A dispatcher can refresh a browser; a driver drives off without the last
two jobs.driver/index.html loads exactly two stylesheets
— Leaflet's and driver.css — and no css/
file at all, so a treatment borrowed from the dispatcher has no rule in the
cab and would ship as a flat pill through a green suite. The pill reuses the
blocked treatment, the only one that is solid, wraps, and is not
upper-cased — this label is a sentence, not a status word. The browser
check drives the shipped store into a real failed merge (so the job the office
sent is genuinely absent from the tablet) and compares
getComputedStyle against the other three states and against a
bare, unclassed pill.tests/manual/in-cab.js — removing the new branch
reddens 7 of 203, painting a dropped connection as a failed merge
4, painting a failed merge as Synced 6, and borrowing a
dispatcher class the cab never loads — the flat-pill failure, which none of
the other three breaks reaches — 2. node tests/run.js
stays at 24,095 under every one of them, because it loads the shared layer
and never a driver render.js/screens/04-board.js, was fixed hours earlier without the
exemption being removed. Measured rather than assumed: putting the retired
clause back into that one file left the whole tree at ALL PASS —
25,235, identical to a clean run. The list is now empty, its emptiness
is asserted as a number, and the same injection reddens the guard. (#933)NF.Store.lastSync() > 0 — which records that a sync
happened, not that the document merged in full. Since #919 those are different
things: the merge walks its ~50 collections in order, a throw partway leaves every later one
unlanded, and the sync stamp keeps whatever the last whole read set. So after a stopped merge
the schedule board could report “nothing on the board to check”, the empty-legs card
“nothing is rostered in view”, the fatigue register “we looked, and there are
none”, the document outlook “no asset document is on file at all” and the
customer’s own terms page “wording no longer held” — over rows that never
arrived. schedule and jobs are two of the four collections replaced
wholesale rather than merged by id, which is why the two board cards are where it showed as a
confident empty rather than as a card one merge behind.NF.readComplete() sits beside NF.Store and
answers four states — the read arrived and could not be merged, there is no database to
wait for, nothing has landed yet, or the document merged in full — with a merge that
stopped ranked ahead of “no database”, because a build with no API at all can
be holding a document that never finished merging and “this is all there is” is the
most reassuring sentence to hide that behind. A future fourth condition is one edit rather than
nine. Nothing is drawn differently: every card keeps its existing hatch for the unknown and its
existing dimmed-and-dashed for the measured absence, and what is added is which unknown
— in the store’s own sentence rather than a second wording, because “the
database has not answered on this page load” is flatly false when it answered and could
not be merged. Every healthy and every known-negative sentence is unchanged to the byte.
Two sites are named as still holding the old clause and are not in this change:
js/screens/55-reports.js’s compliance snapshot and
js/screens/04-board.js’s assign-to-driver card, both of which need the guard.NF.Store.lastSync() > 0, which
records that a sync happened and not that the document merged in full — so
after a stopped merge all five rows printed their known negative, in the dimmed treatment that
means we know, over consignments, incidents and
assetDocs that never arrived. This is the ninth and last of the nine screens named
in #933; it was held by another branch while the other eight were done. It is also the one
worth naming rather than sweeping in: one of the five rows prints “all current”
about vehicle paperwork, which is the claim docs/modules/vehicle-documents.md
forbids outright, and this is the second time that claim has been reached by this route
— the comment above repDocIssues() names this very defect twenty lines above
the code that committed it. Nothing is drawn differently: the hatch for the unknown and the
dimmed dash for the measured absence both stay exactly where they were, and every healthy and
every known-negative sentence is unchanged to the byte. What is added is which unknown
— in the store’s own sentence, because “the database has not answered on this
page load” is flatly false when it answered and could not be merged, and it sends a
general manager to wait for a sync that has already happened and failed.repStateSeen() is now a single call to NF.readComplete(), so
the compliance snapshot cannot drift from the eight screens fixed alongside it, and a future
fourth condition is one edit rather than ten. Which states license the claim is judged at the
site rather than assumed: this card takes ok, which treats a build with no database
as read, because all three collections behind it are ones this app authors and pushes —
unlike the intake queue, which the server authors and which correctly keeps the stricter
licence. Measured: reverting to the old clause reddens 13 assertions, swapping the hatched
and dimmed treatments 8, taking the stricter licence here 9 (six of them in guards
that predate the change, because a local-only install would lose its known negatives entirely),
and dropping the sentence that says which unknown it is 4. One site still holds the old
clause and is named rather than changed: js/screens/04-board.js’s
assign-to-driver card.NF.Store.lastSync() > 0, which records that a sync happened and not
that the document merged in full. NF.schedule is one of the four collections a
read replaces wholesale, so a merge that stops before it leaves no lanes rather than the
previous ones — which is why this card is in the confident-empty class rather than the milder
one-merge-behind class. It now asks the same reader the other nine screens ask, and a stopped merge
keeps the same hatch as a read that has not landed while getting its own sentence: the store’s
own, naming the collection the merge stopped at, because “GET /state has not answered on
this page load” is flatly false when it answered and could not be applied, and it sends a
dispatcher to wait for a read that has already happened and failed. The rig tooltip three inches
above it says the same thing, so one state cannot read two ways on one card. This is the tenth site
and the last copy of the clause in the app: the other nine landed in #933’s own PRs, and this
one arrived with #939 after the issue was written. Nothing a working board draws changes, and the
known negatives are unchanged to the byte.background-image: none is also what a
stylesheet that never arrived returns — so the browser check builds a bare hatch in the same
document against the same stylesheets, asserts that is a gradient first, and then asserts the
card’s own hatch is the same treatment pixel for pixel. The stopped merge is driven end to end
through the shipped store: a whole document is read first (which is also the control — the
same empty roster with a complete read behind it), then one that cannot be merged, then a whole one
again to prove the hatch is not permanent. Measured on this card, all against a baseline of 59
checks: reverting the fix reddens 9, swapping the hatched and dimmed-and-dashed treatments
10, borrowing a hatch the dispatcher never loads 6, and dropping the sentence that
says which unknown it is 3. The one worth the ceremony: an override that leaves the markup
byte-identical and takes the hatch off in the cascade reddens 6 here and 0 in the
whole dependency-free suite, which is the half no source-text check can reach.demoData:false) there are none and
every unallocated card offered an enabled picker containing only its own
placeholder. A dispatcher looking at 636 unassigned imported con notes opened it on
each of them and reasonably read the emptiness as a fault in their own data. Where
there is nothing to offer the card now renders the reason instead, in three states
that are deliberately kept apart: we have not read the roster yet (hatched,
because we do not know — and never on a local run, where what we hold is
everything there is), the roster holds no run lines (dimmed and dashed,
because that is a known negative, naming what would fix it), and the run lines
name nobody on the driver register (a fault in the lanes rather than an empty
week, so adding a run will not fix it). A board that does have run lines renders
exactly the control it always rendered.NF.schedule.push occurred at exactly
four sites in the whole tree and all four are inside the demo generator,
which demoData:false — the deployed shape — never runs.
So on a production dataset the roster held nothing, and both of the app's own
documented routes to putting a driver on a job were shut for the same reason:
the dispatch board's ▸ Assign control lists run lines, not
the driver register, and the engine room's driver picker swaps the driver on a
line that already exists. A dispatcher looking at 636 imported con notes
could not assign one of them. The control is a Run lines card on the
schedule screen, above the grid, with the form already open when the board holds
nothing — that is the state somebody is standing in when they cannot
allocate anything, and making them find a button first is one more step between
the diagnosis and the fix.active:false alone would retire nothing:
about 87 sites across 14 files read NF.schedule, and
the three that decide where work can be sent — the board's picker,
assignJobToDriver()'s lane lookup and the engine room's swap —
all key off l.driver and have never heard of active. A
retired line that kept its driver would go on being offered on the dispatch board
and would then take work the grid no longer draws. Measured: keeping the driver
reddens 2 in tests/run.js and 3 in
tests/manual/board-assign.js, and one of the three is the empty
picker coming back. Nothing is destroyed — who was on it is kept, and
restore puts them back or refuses by name if somebody else has them now.NF.schedule with its cells and its
lane# row intact, and the suite asserts both. A line still carrying
scheduled work refuses to be retired and names the con notes that would
have been orphaned rather than counting them; a line stored under its
driver rather than a name refuses too, because retiring releases the
driver and NF.laneKey would then answer '' — the
retirement would never reach the database and would come back undone on the next
read.name || driver,
percent-encoded, one lane# row each. So two lines of one name are
two rows fighting over one key; a second line for one driver is a row nothing can
ever route work to, because every driver lookup takes the first lane that
names them; and a yard written as its CODE rather than its id is refused
by name — three of the seven yards shipped have id ≠ code,
and one unresolvable depot throws inside renderSchedule() and empties
the whole screen. The refusal says “KTA is the CODE of Karratha, not its
id”, derived off the document rather than written down.schedule has been in snapshot(),
apply(), the backend whitelist (shard:'lane#',
audited:true) and AUDIT_COLLECTIONS since #269, so a
client-created line persists onto its own row and is filterable in the change log
with no deploy. The suite creates one, calls NF.Store.save(), reads
the saved document back and checks all four — because “we checked and
nothing was needed” is only worth anything if it can go red.jobAssignScope() counted every row in
NF.schedule, and a retired lane is archived rather than deleted, so
the count stayed non-zero and the ▸ Assign card answered nodriver
— “the roster’s run lines name nobody we can resolve…
correct the driver on those lines, or add those people to the register”
— over a roster holding no line anybody could correct. The truth is
nolanes: make one. The refusal was safe either way, which is why it
needed a test rather than a crash — retiring releases the driver and the
list is built on l.driver, so no retired run was ever offered as
somewhere to send work, and what was wrong was the sentence. One clause,
NF.Schedule.live(), pinned in the root suite as well as the
browser check, because CI shards that file separately and this shape has
measured a verify-to-fail of zero before. The guard pins the other
direction too: a live lane naming nobody we hold must still answer
nodriver, or the fix would have traded one wrong sentence for another.grep -c on
the changed text, against a 24,691 baseline every red count sums back
to. Removing the two uniqueness refusals reddens 10 in
tests/run.js and 7 in
tests/manual/board-assign.js; a truthiness test in place of
active !== false, 28 and 6; dropping the has-work
refusal, 3 and 4; keeping the driver on a retired line, 2
and 3. The browser figures are floors where the break stops the
journey part-way — the three claims outside the wrapper always run, the
detail checks after the broken step do not, and the total is printed so the
difference is visible rather than inferred. Adding a second NF.schedule.push to a screen reddens
1, which is a finding about the test and is left as one: the one-writer
guard is a single claim whose detail names every offending file and count, and a
second assertion would be a second wording of one fact.renderSchedule() — taking
away the whole thing the end-to-end section is about — reddened no
assertions: the section's first waitForSelector threw, the suite
aborted and exited 1 with no verdict. That is a failure signal and not a
count, and a verify-to-fail taken on it reads 0 and concludes that nothing
defends this. The journey is now wrapped and three claims that cannot be skipped
are asserted on flags set as it goes, so a throw anywhere becomes reds against
what the journey exists to prove. Re-measured with the repair, the same break
reddens 12.tests/manual/board-assign.js; resolved keep-both, the file compiles
and a first attempt reported pg.evaluate is not a function —
setState/readCard take a page as their first argument
since #933, and a section heading is a comment that never prints. Both halves are
kept, #933's byte for byte as section 7 and this work renumbered to section 8,
and the check is the count: 34 + 25 + 47 = 106, measured, with two
more added for the residual above → 108. Proven to be two live
sections rather than one: a break touching only section 8 reddens 12 while
section 7's 25 checks still run and pass, and a break on #933's own
apply-failed wording reddens 3 of 108 with the run finishing, all
three of them section 7's while section 8's 47 keep passing. Each half goes red
on its own subject while the other still runs, which is the thing
node --check cannot tell you. Re-taken against
239d4de9, the uniqueness break is unmoved at 10 on a
25,523 baseline.The Run lines card on the schedule screen no longer unfolds its form on its own. On a board with no run lines the New run line form used to open by itself, on the reasoning that an empty roster is exactly where somebody is stuck and a button is one more step in the way. In practice the form is eight fields, and unfolding it pushed the list — and the sentence explaining why the roster is empty — off the top of the screen, so the card answered “fill this in” before it had finished answering “what is going on”. The card now opens folded on every state of the board, the empty state still says in words that a run line is the first thing to create, and + Add run line is one click from the same place. Because the form is now only ever opened deliberately, there is always a card behind it to go back to, so Cancel is always offered — it used to be withheld in the one state where it would have appeared to do nothing.
PUT /state answers 413 for a
record that is too big for one row, and NF.Store reads that answer
as a list of records — which is right, because that is what it has
always been. There is now a second thing a 413 can mean, and read through the
same branch a whole-document refusal produced blocked: [],
unprocessed: 0, pending: false and therefore
syncState() === 'synced'. It takes #680's mechanism rather than a
third one: writeRefusal carries the API's own sentence and outranks
every reassuring state, so the store cannot say it is up to date over a document
the database never received.PUT /state is a whole-document write, so every byte
the browser holds is parsed, diffed against the stored document and assembled
into a write batch inside a 256 MB Lambda, and nothing bounded that.
Measured on the real handler over the store double — peak resident memory
of one full rewrite where every record changed, so the audit diff is doing its
maximum work — 69 MB at 298 KB (roughly today's live
document), 151 MB at 3.02 MB, 180 MB at 5.95 MB:
about 28 MB per MB of document, in a bare node process that never loads the
AWS SDK or the Postgres adapter. The ceiling ships at 2 MiB, is
declared on ApiFunction in the template so it can be raised without
a code change, and the measurement it was chosen against is recorded beside it.
It deliberately does not name which collection grew — naming one
means parsing the body, which is the thing being refused.NF.Store
marked the write pending and re-PUT the identical document on a timer, so a
permanent failure wore a transient one's words. A size refusal is reported as
blocked — not a new state name, because blocked
already means the API refused this and retrying will not help, the
driver's pill already renders it as “Not saved — call the
office” without auto-hiding, and an invented name would have fallen
through every pill's unknown branch to “Syncing…”.
syncBlocked() stays empty, because no record is at fault and naming
one would be a claim. Unlike the demo refusal it is not terminal: the
next write that is actually acked clears it. Measured, on
backend/tests-state-body-limit.js's 44 assertions: removing the
backend refusal reddens 12, moving it to after the parse 2,
removing the client branch 8, and naming the wrong refusal, answering
refused or never clearing it 2 each — while that
client-half break reddens 0 in tests/run.js, which stays at
24,000, so a backend suite is the only thing in the tree that can see a
defect in the browser's reading of a backend refusal.500 a live dispatcher saw on this route: that browser held
2.97 MB, and the same size driven through the handler answers
200 in 111 ms. Which of memory, wall clock, the Postgres adapter or
the platform produced it cannot be seen from inside this repository, so it is
written down as unknown rather than guessed at, and the ceiling is
justified only by the curve above.NF.Store.syncState() was composed entirely of write facts —
is a push waiting, was one refused, was one blocked, did one land over somebody
else's — plus the short-document state added in #919. It held no fact about the
read at all, so with nothing to send it fell through to
synced, which is the one word on that list that makes a positive
claim about the database. Measured on the real client: boot with an
apiBase and a GET /state that answers
HTTP 500 gave syncState() === 'synced',
lastSync() === 0 and no failure on record — byte-identical, on every
reader a screen has, to a read that succeeded. That is the second row of #938's
own reproduction table. A failed read is now its own answer,
read-failed, with NF.Store.syncReadFailure() naming the
HTTP status the server actually sent (and 0, a distinct answer, where the
request never got a response at all) and carrying the store's own sentence for a
screen to print. It sits last in the chain, so it can only ever replace
synced: a driver's unsent capture still reports as
pending, a refused or oversized write still reports as itself, and a
device with its radio down still emits offline. It clears the moment
a document arrives, so it is a state and not a latch.200, not dirty, 2.9 MB,
20 sample drivers, with a clean database and a clean incognito window on
the same machine. The mechanism is in the code: writeCache()
skips a write only while the key is still present, so the removal is exactly
what lets the other tab's next write land, and reconcile()
rewrites the cache after every merge.storage
event for the cache key into NF.Store.reloadLocal(), and the
discarding tab writes the stamp before removing the cache. That reader
used to see an absent key, return, and leave the pre-purge document in memory
for the autosave to put back. It now asks first: a purge stamp this tab has
not handled means stop writing and reload, and every writer
(writeCache, save, flush,
pushNow) is a no-op from that moment. An absent stamp decides
nothing in either direction — a fresh install is not a purge, and a stamp
that vanishes is somebody clearing site data, not a discard. It reaches only
tabs on the new bundle: a window open since before the deploy runs old code
and no event can reach it, so closing every FleetView window once is still
required for those. Measured, on the second tab driven on the real store:
removing the check reddens 4, the closing-tab gate 2, the other
three writer gates and the own-event guard 1 each — and the
vanished-stamp negative reddened 0 when first written, because with no
stamp anywhere both sides of the comparison are empty and agree.localStorage with the cached
copy put back between them — which is exactly what a FleetView window
left open on an older build does within five seconds of the removal
— produced four discards and four reloads, every one of them correct, and
the console line claimed each was the only one. purgeSeen records
that a discard ran for a purge and has nowhere to record that it ran
again, so the one sentence that ends it — close every FleetView
window on this machine — was never said.never (nothing dropped for this purge), dropped (it
ran, and the copy stayed gone) and returned (it ran more than
once, so something on this machine is writing the copy back). Nothing in the
discarding tab can write it back — purgeReloading gates
writeCache, save, flush and
pushNow from the decision to the reload — and since #940
every tab on the current bundle drops and reloads on the stamp's own
storage event, so what is left is exactly #938's decision 3: a
window running code with no listener for that event, unreachable by any message
we can send. The returned line names that cause, the count it was
measured at, and the remedy, because the remedy is a human act at that machine
and nothing in the app can perform it.
The record is keyed on the purge it describes (<purgedAt>:<n>),
so it cannot outlive it: a record naming a different purge reads as
never rather than as a smaller count, which is the direction
stateScope() and the residue scope already take for a value they do
not recognise. A fresh purge starts again at one with no cleanup pass to
forget, and the record deliberately survives the removal it records —
cleared by the act it describes, it could never count past one. A count that
cannot be read answers null, never 0:
Number('') is 0, and 0 here would ship “no drops” as a
measurement. NF.Store.purgeDrops(purgedAt) is the diagnostic
reader, on the same footing as purgeResidueScopeReport().
Suite 40-reconcile-runs 168 → 205. Verify-to-fail, each measured off the
runner's own summary line with reds and passes reconciled against the 205: the
third answer folded into the second reddens 10, the recorder removed
while the reader stays exported and called 8, the record cleared by the
discard it records 8, the stamp keying removed 5, the literal
“Reloading once.” restored 3, an unreadable count shipped as
0 2, and the store's reader answering out of its own head
2 — with 0 anywhere else in the tree.purgeSeen equal to the stamp —
already dropped, exempt, and with no other tab alive for the cross-tab
discard to reach.drivers, learners and schedule are
consulted — the three the purge empties and a clean read carries through.
Both discards now go through one shared discardCache(), and the
older assertions that read the removal off its previous home were restated onto
it rather than deleted. Suite 104 → 128; verify-to-fail on the final suite:
the `reconcile()` call removed **2**, the dirty guard **2**, the no-stamp guard **2**, *absent counts as empty* **2**, a partial server read as residue **2**, and the check moved to after `apply()` **1** — ⚠ *absent counts as empty* measured **"2 and a crash"** on first writing, three assertions dereferencing `.residue` bare on a verdict that no longer carried one, so the group aborted and a break that should redden more reported fewer; they are defensive now and the figure is a measurement.learners and schedule) are outside that scope, so
they were absent from every portal response because the portal never asked,
while settings is inside it, so the purge stamp was visible and the
check ran. Measured on the shipped function with the database fully populated
and nothing purged away: the dispatcher answered keep and the portal
answered residue on learners:2, schedule:2 against the same
cache — one spurious discard-and-reload on any machine where both apps are
opened on one origin. The check now consults only the collections the declared
scope actually reads. No cache was ever at risk of losing unsynced work: the
dirty guard held in every case throughout.drivers is in portal scope, so a genuinely
purged database is still detected there, naming drivers and not the
two collections nobody asked about. An app that declares no scope — the
dispatcher and the driver PWA — is byte-for-byte unchanged, including its
console line. A scope that reads none of the three now answers
nothing-in-scope rather than agrees, because "agrees" is a claim
that a comparison happened, and a scope value that is not a list consults
nothing rather than everything: refused rather than widened.NF.purgeResidueScopeReport() states the
intersection for every known scope, pinned by an assertion, so the same hole
cannot quietly reopen the next time either list is edited.navigator.onLine) and only then the
document, so the branch fixed one release ago for "Syncing…" was
reached by a second route: lose the aerial shortly after a response arrives and
fails to merge, and the pill painted a connection problem over a short load list.
It now answers the document first and reads "Data incomplete — not
saving. Refresh the run when the signal is back."save() is gated on the merge failure precisely so a
partial document is neither cached nor pushed — that gate is working, so
"saved on device" asserted the one write the store had just declined to
make, and a driver who reads it has been told their capture is safe. No sentence
on this pill may now claim a write while the gate is closed, asserted with signal
and without it.offline
listener paints directly, so the state arriving really is offline and no
reordering of the chain alone could have seen it. blocked and
conflict keep their precedence over a short document, unchanged.tests/manual/in-cab.js, which
goes from 203 checks to 226: restoring the radio-first ordering reddens 7,
letting a dropped signal read as a failed merge 4 (two of them the control),
keeping a save claim in the new wording 2, and borrowing a class the cab's
two stylesheets never define — the flat-pill failure — 4, two
of those the guard the previous fix left behind. node tests/run.js
stays at 24,095 under every one of them, because it loads the shared layer
and never a driver render.qty: Number(r.qty) || 0
straight into the saved quote, and 0 is a deliberate nil
— a real answer. So the quantity check added in v0.152.0 reported
complete: true over a $2,400 cartage line priced at $0.00, and was
right to: nothing in the record said the value had ever been anything else. The
three quote forms now go through the same writer the job thread's quantity box
uses, which stores a number (including a deliberate 0), clears a
blank rather than zeroing it, and refuses anything else in the form, beside
the box, quoting back what was typed — with the charge left alone, so a
mistyped keystroke cannot destroy the number that was already there.$0.00 in its Amount column while the
Subtotal three inches below correctly left that line out; the quote's How
this price is built panel printed "2 pallets × $2,400.00 …
$0.00" and headed the figure Total, because its completeness claim
came from a different read than its subtotal; and the fuel-levy button counted a
charge it could not price as $0, so a levy could be struck as a percentage of a
base short by a whole line. All three now read the one quantity reader, draw
what they cannot read as hatched rather than as nil, and name the charge.1 and multiplied it by the rate — a figure nobody
entered, indistinguishable on screen from a measured one, and then inverted into
the dollar movement somebody sets a rate from. Those quotes are now listed in the
preview's own cannot be measured section, hatched, each with the reason;
a quantity we cannot read gets a different sentence from a levy struck at nil,
because the two send somebody to two different corrections.type="number" box hands the app an empty string for anything it
cannot parse, so the operator's own words never arrived and a refusal could only
ever have said "no quantity is recorded" about somebody who demonstrably entered
one. Reusing the job thread's writer also brings its refusal of a negative
quantity, so the two quote forms now agree; the alternative was a second
quantity rule, and the point of this work was that there is exactly one.wireScheduleDnD() set
schedDrag on dragstart and cleared it on no
dragend handler at all, so a drag that ended without a drop
— Escape, released off every cell, dragged out of the window — left
it live in the handler, and the next drop a cell received from any source at all
was written with it, into lane.cells[day], which is the schedule
itself. It is now cleared on every dragend and consumed by the drop
that uses it, so one drag is one write. Reachable with a mouse, and measured
in Chromium rather than reasoned about: drag a run out of a cell and let go
over the page chrome, then select the word NORTH in the brand mark and
drag that text onto an empty cell on another run line — before
this, the abandoned run moved, carrying its route, its times and its con note.
A cell accepts a drag that began anywhere, because dragover calls
preventDefault() and cannot ask where the drag came from, so text
from another window and a file dragged out of Outlook reach the same handler.
There are four drag sources and there were three dragend
handlers: the type palette had a dragstart and no
dragend at all, and abandoning a fatigue day swatch and then
dropping text on an empty cell created one there. All four clear now. The
pool-chip drop sites already guarded on the drag's type and are
unchanged.tests/run.js: restoring the uncleared schedDrag
reddens 19 assertions, restoring the whole shipped defect — that,
plus the drop reading the stale drag — reddens 31, the drop no
longer consuming the drag reddens 17, dropping the refusal block
entirely reddens 17 (and 9 of 407 checks in
tests/manual/engine-room.js), gutting its wording reddens 3,
removing the type palette's new dragend alone reddens 3, and
drawing it hatched rather than dashed reddens 2 in each suite — in
the browser off the computed background-image, because a class no
loaded stylesheet defines ships flat and passes CI. The two headline figures
were re-taken after rebasing onto 7b0fb813, baseline 24,895, and
are unchanged.tests/manual/stop-rule.js
was 34 of 53 red on master — not because the cab regressed, but
because the check picked its fixture badly and 12 September was one of the days
that showed. It asked only that a con note resolve an L&D template and that
one of its jobs sit on a driver's open day; it never asked that the run still be
the driver's to act on. driver.js's jobCard()
gives a delivered or failed job a badge and nothing
else — no docket button, no L&D button, which is correct, that run is
over — so the docket never opened and every assertion that reads the
rendering failed while every assertion that reads the model
passed. Measured over 365 simulated dates through
js/data.js's own generator: the old picker lands on a finished run
on 25 of them, all 25 Saturdays and never any other weekday, which
is why CI — which runs on merges, and merges land on working days —
went seven weeks without saying so. The repaired picker lands on a live run on
365 of 365, and the suite is green at twenty simulated dates including
every one the old one broke on.find(x => x && x.driver &&
x.times), which asks for nothing that puts a card on the screen. Measured
12 September it chose a con note belonging to a different driver, two days out,
and already delivered; any one of the three is enough for there to be no button
to click. It found a usable card on other dates by coincidence. It now makes the
state on the con note under test and puts it back before the freight table reads
the same record. Fixing the first picker alone took the suite 34 red to 3,
which is how the two were told apart rather than assumed to be one.getComputedStyle().backgroundImage rather than a class name. Two
harness guards were added (53 → 55), each of which now says which
fixture went wrong in one line instead of leaving it to be inferred from
thirty-odd assertions about a panel that was never on screen — the second
keyed on the con note it made unresolvable, because keyed on the job under
test it stayed green under the very break it exists to catch.config.js.
Two things compose that file, and they did not agree. .github/workflows/deploy.yml wrote ten keys;
backend/deploy.sh — the fallback path for when the workflow is not used — wrote six, so
the same commit deployed two ways published a materially different site with no warning on either side. The four
it dropped were siteBase (so every portal QR code, shipment-tracking link and quote-acceptance link
fell back to the CloudFront domain, and a driver scanning one installs the PWA from a second origin and enrols as
a second row on the device register), sso (staff single sign-on published off, every write
riding the shared site token and the change log losing the acting identity), cameras (yards somebody
had wired up reading “No feed configured”, which is the wording for nobody told us there is a
camera here — that key has since been removed with the yard cameras themselves) and
environment. All four were written, each optional, and each defaulting to the
value that behaves exactly as the absent key did — so a run that exports nothing publishes the site it
always did, and can now publish the rest.production when nothing is set, and says in a warning that it is an assumption.
The laptop path deliberately assumes nothing, so the site declares itself undeclared, draws the hatched
“ENVIRONMENT NOT DECLARED” banner and refuses to send customer-facing mail. The asymmetry is the same
one already chosen for the API token: a workflow run records its warnings, is re-runnable by anybody and has a
smoke test behind it, while a laptop run has scrollback nobody keeps and is being done by somebody improvising
under pressure. Export ENVIRONMENT=production to declare it, which is a different act from
assuming it. It is now pinned by an assertion, so aligning the two has to be a decision rather than a one-word edit.config.js emit the same keys.
It does not read either heredoc: both are a single line, and a line's shape is not what NF_CONFIG
ends up holding — four guards in this repository have been defeated that way. It obtains the laptop script's
key set by running it against test doubles and evaluating the file it wrote, composes the workflow's from
its own heredoc, and compares the two as sets, reporting any difference by name in both directions. It also checks
that every key the committed config.js declares is published by both, because a key documented and
published by neither is a feature that is unreachable on the deployed site however well it is built — which
is exactly what the yard-camera config was, before that capability was removed. The next key added to one writer now reddens a test instead of
going missing in silence; the last one went missing for an hour and nothing anywhere could have said so.backend/src/handler.js:3950. The
line is :3955 — and was already :3955 at the
commit that landed the check, so it went onto the default branch red and stayed
there. A line number is the one citation in this repository guaranteed to rot;
the check now asserts the name and the file and requires some
line, which is the shape its own sibling twelve lines below already used. No
claim, no census result and no application code changed. (#972)CLAUDE.md’s existence claims a machine can
settle, measured — and the small remainder now checked on every test
run. Five index entries mis-aimed a dispatch in the five days after
#826 swept twelve stale ones out, each in the same direction: this is not
done when it was, or this is done when a different half was not.
node tools/claim-census.js classifies every existence claim in the
index and prints the counts per class, derived on the spot rather than written
down — a census recorded as prose would be one more claim in the genre it
polices. The answer is that four of the eight classes cannot be settled
from a checkout at all (row counts in a live database, what is live in
AWS, an Odoo tenant’s access rights, an assertion count that needs a suite
run), and that they are the large majority of the matches. The guard covers the
remainder, and that narrowness is the finding rather than a shortfall. (#972)tests/suites/101-claim-census.js recovers each
from git rather than paraphrasing it, and checksums them so a rename sweep
cannot quietly retune the evidence — one fixture was corrupted
that way while this was written, and still read plausibly. Three of the
five are caught; two are asserted NOT caught, by name, because
“we cannot check this automatically” is a third answer and
folding it into a pass is this issue’s own defect one level up. A claim in
the past tense, or one the correction convention has already marked FALSE, is
counted and never checked — a matcher that reddens on the convention that
fixed these entries is a matcher somebody deletes. (#972)1d248748 — the same sentence sat in
check-suite-wiring.sh too, which is one error copied and not two
sources. The asset-cost entry still said provenance was “prose rather
than data” after #668 and #76 made it a field, and
staff-sso.md’s “Not built” list still
said scopeOf had no consumer and there was no role editor, both
false since #233 and #377. Each keeps the original wording, marks it false,
names the falsifying commit and states the surviving half. (#972)tests/manual/ no longer raises the
test suite's assertion total. Four guards enumerate that directory and
assert over what they find, one of them emitting one assertion per file —
so a file that is not a browser suite at all raised
node tests/run.js's total by one, still printing
ALL PASS and still exiting 0, with no name anywhere. That total
is what every verification claim in this repository is arithmetic on
(baseline − reds = passes proves a break;
reds + passes = baseline proves a run finished), so the baseline moved
silently in the reassuring direction. Measured at c0177440: 25,523
clean, 25,524 with one empty file — and 25,524 with a
dotfile, which is the case that turns up. A stray is now named, by
tests/suites/88-manual-dir-census.js inside the run whose number it
moves, against a ledger read off ci.yml and the wiring check rather
than a hand-written list. (#979)npm run wiring asks exactly the right question
— is anything on disk in tests/manual/ that nothing
runs — and enumerated with glob.glob('tests/manual/*.js'),
which is blind to a leading dot in the same way ls tests/manual/*.js
is, while the guards doing the counting use readdirSync and see one
perfectly. Measured both ways in one worktree: an ordinary scratch file failed
the check and was named; .probe979.js passed it green. One worktree
was found holding eight untracked dotfiles there. It lists the
directory now, and proves its own enumerator can see a dotfile on every run
rather than assuming it. (#979)node tests/run.js now prints an inputs ·
line naming both directories the number depends on with their file counts, any
dotfile in tests/manual/ (which ls cannot show
you), and any untracked path in either — so two totals can be reconciled
instead of guessed at. It is output and never an assertion, so no baseline moves
because of it, and a legitimate new browser suite is still free to change the
number. The comment claiming an untracked file in tests/suites/ was
“the one way an untracked file moves the number” is
retracted in place rather than quietly rewritten: it is the sentence that stopped
anybody looking at the other directory. (#979)tests/manual/lib/clock.js handed its skew to both the context and
the page, and browser.newPage() builds a context by calling the
public browser.newContext() — so a page opened that way was
skewed twice: measured, a run named 2026-12-25 put such a
page on 2027-04-07. Latent rather than live (all 134
newPage() calls in the suites today are on a context), and now
refused by an idempotency flag, because an instrument that lands on the wrong
date manufactures exactly the finding it exists to detect.
tests/manual/tickets.js filed a gate-clearing credential expiring
'2027-06-01', so from that date on it read as
“the gate over-blocks” about a ticket that really had run out.
tests/manual/portal-history.js stood in for a 20-days-in-31 sorting
rule with a one-character proxy that is wrong on the 20th of a month, so
it reddened twelve days a year.
tests/manual/rates.js asserted the fuel levy in force as the
literal 32 and stamped its levy-provenance con notes
'2026-08-20 09:02' while seeding the rate in force
“yesterday” — 3 of 248 red at a simulated 1 June 2026 and 6 of
248 at 8 August 2026. No assertion was skipped, disabled, quarantined or
loosened, and every suite keeps its count.tests/suites/29-fuel-levy.js read
R.localDay(new Date()) inside a realm pinned to 22 Aug 2026, and
tests/suites/20-traffic-light.js built a 230-day-old contact from
the process clock for a realm pinned to 14 Aug 2026 — which read as 91
days at a simulated 31 Dec 2026, against a 180-day threshold. Both now step
calendar days inside the realm. tests/suites/29-fuel-levy.js
already recorded this exact trap in its own words a thousand lines below the
line that committed it.tests/manual/ was run at thirteen simulated dates — the real
clock, all seven weekdays, four Saturdays and both sides of a month boundary,
1,027 suite runs — and re-run after master moved 27 commits.
Two were date-dependent, 77 measured clean, none could not be measured.
board-conflict.js held a drag open for up to twenty seconds waiting
for the conflict prompt to paint and then dropped on a coordinate measured
before that repaint: faked to Thu 17 Sep 2026 the drop point was over
HAZ Grey fleet 1 day 63 when it was measured and over
day 64 when the pointer got there, so the drop landed perfectly and
the check reported it lost — red on 3 of 4 Thursdays, twice each, at one
worker and at six. No application file changed; the board was right
throughout.tests/manual/route-attachments.js branched on whether the cab
happened to be showing the con note its fixture had seeded, and where it was not
it fell through to a single check that the screen “rendered a stop”
— leaving both assertions about reference material unmade and
printing all 9 checks passed. Measured over 41 simulated dates: the
weak branch on 6 of them, across five different weekdays. The stop under
test is now put into scope through the app’s own writer instead of being
hoped for: 9-or-10 → 11 assertions on every one of 42 consecutive
dates. Restoring the old branch reddens nothing, which is the finding
rather than a shortfall — a green run whose assertion count moves with the
calendar is the failure.tests/manual/lib/clock.js runs a suite as if it were a
different date —
node lib/clock.js 2026-09-19 stop-rule.js — with the clock
faked inside the page’s own realm so js/data.js’s
roster generator moves with it. It is a preload rather than a harness helper, so
no suite file changes and it reaches all eighty rather than the two that had
grown their own copy. It found a defect in itself first, which is the part
worth keeping: resolving the anchor per realm — what those two copies do
— makes a page opened later run behind one opened earlier, and
that put portal-history.js at 1 of 248 failed at every hour of the
day while it was green on the real clock, because vouchFor()
correctly read the first page’s cache stamp as future-dated. One offset,
computed once, handed to every realm. CI never sets it.tests/manual/stop-rule.js was 34 of 53 red on master and
reproduced at every commit that had ever carried it, flipping green by moving
the clock one day. CI cannot see that, because CI only ever runs at today's
date, and tests/run.js reddens 0 for all of it: it loads no
browser and no stylesheet. Two sweeps have now found seven checks of that
shape between them. .github/workflows/date-sweep.yml runs the whole
browser folder nightly at a date the calendar has not reached yet, so a fault is
found before the day it would bite rather than on it.
The date rotates and is derived rather than random, advancing eleven days
a night: all seven weekdays inside a week, and every day of the month inside a
month — which is the limitation the first sweep had, its thirteen dates all
sitting in one three-week window, so a fixture turning on the 20th was invisible
to it.
A run in which the clock skew silently did not apply looks exactly like a
folder that is genuinely day-independent, and it is the reassuring one of the
two, so nothing is swept until a control has opened a page by both routes and
read the day back through the app's own NF.localDay(). Measured:
with the instrument's idempotency flag removed, the control refuses a run named
2026-12-25 whose second page reads 2027-04-07.
Three answers per check and three exit codes — green, red, and
could not be measured, which is never folded into either. An exit code of
0 is not accepted as a pass on its own: a check that printed no verdict is
unmeasured whatever it exited with.
And what it finds reaches a human, which is the reason the guard had been
recommended and not built: a sweep that comes back anything other than all-green
files a GitHub issue naming which of the three it was, with the one command that
reproduces it. It costs nothing on a push — the per-check alternative was
costed at about seven times the browser CI time on every commit, to buy a
fault that moves on a scale of days.GET /state returning well-formed JSON that is not a
v:2 document — an empty object, a v:1 document, an error
envelope a gateway answered 200 with, a reply from something that is not our API but speaks
JSON — skipped the merge at the gate that screens for exactly that, and then ran
everything downstream of the merge anyway: the sync stamp, the “this database has been
read” flag and a synced status. Measured across five body shapes,
NF.readComplete() — the one reader the nine screens ask before stating a
known negative — answered read, ok, over a read that merged not one
collection. A 200 carrying HTML was always handled correctly, because the body will not parse;
this was specifically the well-formed-but-wrong-shape case.
It was a write defect too, which is the half nobody had measured. The
“we have read the database, so it is safe to write over it” flag went true on a read
that merged nothing, so on a stale (not dirty) cache the next edit sent the local copy straight
back over the database — the reversion incident this store’s own reconcile comment
was written about, reached through a 200. The HTML case measured zero writes on the
identical fixture.NF.readComplete() gains a fifth state,
not our document, consulted only where no whole document has ever merged in this session,
so a later odd answer can never revoke a licence an earlier whole read already gave — the
same judgement taken for a read that fails. The sync pill keeps the one word it already had for
“this browser has no usable document”, because a fifth word there would paint
Syncing… in the cab, which is the one thing a fault that does not clear itself must
not say.
Eight screens name which unread it is; three deliberately do not. Nothing is drawn
differently anywhere — the same card, the same single hatch, the same dash for a figure
nothing was counted for, and every existing wording unchanged to the byte. What changes is the
sentence, because “GET /state has not answered on this page load” and
“it fills in on its own the moment the read lands” invite patience for a fault that
never ends by waiting. The three that take no guard were judged one at a time: the intake queue
already answers cached and would be wrong to claim a stopped merge, the cab’s truck
picker already hatches every state that licenses nothing and prints whatever sentence the store
supplies, and the customer’s terms page already refuses to say “wording no longer
held” — and a deployment diagnostic is not something to put in front of a customer.backend/src/handler.js:3950 out of
#972's claim-instrument assertion, which is what had every deploy from run 601
to run 604 stopping at Verify before publishing — four merges on
master, none in production. This covers what that citation was standing in for,
because handler.js names pointsForCells somewhere and
handler.js calls it ONCE are different facts and only the second is
load-bearing: a second copy of fetchCells is how the route silently
goes back to asking Main Roads about a cell's geometric centre rather than a
real fix inside it, measured at no_features 63 of 200 at the centre
against 0 of 200 at the fix, and two of five real depot yards resolving to a
lower limit — a truck in the Port Hedland yard on a 60 road measured
against 50. CLAUDE.md has claimed “exactly one production call
site each” since #85 closed; nothing inside node tests/run.js
— the step the deploy runs before it publishes — had ever asserted
it. (#991)CLAUDE.md's own prose, corrected by
name rather than by re-typing it. The posted-limit entry cited
:3689 and :3707, true at f4f511c4 and 266
lines out by now — one defect in two places, of which only the executable
copy could stop a deploy. It now cites the mechanism and the grep that
re-derives it, keeps the old numbers as what they were, and corrects one thing
nobody had checked: the “self-guard in handler.js” it
named is a guard over handler.js's shipped source, living in
backend/tests-speedlimits-route.js, which is where somebody looking
for it should go. (#991)#track/<cn>?t=<token> and
#906
their handwritten signature; c.photos was rendered on the same box,
to the same audience, by the same path. That link is a real per-record
capability and a bearer one — the holder is whoever the email
reached and whoever they forwarded it to, and it does not expire — and
once a media bucket is configured PUT /state rewrites the inline
data: URL to an absolute https://<host>/media/<key>
that GET /media/{key+} serves to any caller the shared-token
authorizer lets through, on a token that ships in a published bundle. So
publishing the src published a fetchable URL, not merely a
picture on a page, of something that can carry a person at a gate, a plate, or a
neighbouring customer’s freight. The images are not rendered at all rather
than hidden: a CSS-hidden <img> still hands the artefact to
View source. Nothing about the stored photos changes, and the #docs
delivery record — the surface built for the evidentiary use, which
docAccess() refuses to a link holder by name — keeps them
untouched.NF.PortalTrack.photos():
“3 delivery photos are on file, and not published here” with
the number the customer can quote back at us and the line to call;
“No delivery photos were taken”, a different fact and a
different phone call; and — where the record itself could not be read
— “We can’t tell you how many delivery photos we
hold”, with a count of null and never 0. The
first two are dimmed and dashed, because we counted them and are choosing
not to publish them; only the third is hatched, because only it is an
unknown. Presence, never content: the decision reads how many photos are on file
and never a byte of one, so no data: URL and no /media/
key is in scope to be returned, and two different sets of the same size render
byte-identically.c.photos is one kind of photo, not
three. The worry that a blanket rule might be too wide or too narrow does
not survive reading the writers. The only writer of a consignment’s
photos is NF.Odoo.recordPOD, fed by the delivery POD
modal in the cab and by the dispatcher’s own #/signature
screen in deliver mode. A prestart photo lives in a form submission, an
incident photo in NF.Incidents, a restraint photo in
c.loadCheck.photos and a collection photo in
c.pickupPhotos — none of them ever reached this render. So no
narrower rule was missed, and none was available either: the capture carries no
kind to narrow on, and inventing one in a renderer would manufacture a
fact the record does not hold.docs/decisions/absence-as-success.md records it, including one
finding about the sweep itself: neither of its two search commands could see
the worst of the five, because they look for words of absence
(No, Nothing, Nobody…) and the claim was carried
entirely by a present-tense verb, Monitoring, with the absence sitting
harmlessly in a subordinate clause. Two of the nine screens had a diagnosis on
the record and one of them is now stale: the GPSWOX screen's three-empties-as-one
defect was repaired some weeks ago and is among the best-built examples of this
pattern in the app, so it is recorded rather than re-fixed. The first sweep's own
finding — that every defect of this class is one line inside a bigger
render, where nobody was thinking about the card as a card — held on all
five.10532) is not
the number painted on its door (NF1035), and several screens were
printing the former: the in maintenance but rostered conflict alert, the
Truck line on a signed capability record, the asset-document rows on Reports and
on Asset base, and the message that pops up when a truck arrives at a yard. All
five now name the truck the way everybody says it.
docs/decisions/gated-assertion-census.md, on the dispatch-board suite
the previous two passes had to exclude while the board conflict-guard work was
live on it. Twenty-two gate sites, of which eleven turn out to be a
property of the shipped screen rather than of the sample board their labels name:
every one reads "a rostered driver is on the sample board" or
"FIXTURE: a free lane", and eleven of them resolve that lane through
laneEff() in js/screens/07-schedule.js — one of the five
screens the file drives — while the other eleven read only the seeded roster.
Measured on the whole run, with laneEff() regressed to read a per-day
driver override: 34300 passed, 59 FAILED against a baseline of
34685 — fifty-nine reds over a run 326 assertions short of itself —
became 34300 passed, 261 FAILED, the same passes and 202 assertions
measured back into the denominator. One site alone held 98, the largest
recorded anywhere in this census. The green-tree total is unchanged: nothing was
added, removed, weakened or exempted, because what had gone missing was the
denominator and never the assertions.[].every(f) is true, so the claim that the dispatch board is
either uniformly unguarded or exactly in step with the conflict grid — the one
sentence that group exists to hold — passed over a matrix with no cells in it at
all. It establishes non-empty output first now, which is the same rewrite the
previous pass made eleven times. The residual 124 assertions that still
leave the total under that break are named rather than absorbed: 63 are
loops over the gate's own now-empty list, where hoisting recovers nothing by
construction, and 61 sit behind two bare if (f) { … } gates of
the family this sweep leaves alone on instruction."X has zero callers" is the most actionable sentence in the
documentation set and nothing counted it, so four dispatches were spent re-proving work
that had already landed. A brief says a mechanism is unreachable; somebody wires it;
the brief goes on saying it is unreachable, because the person who fixed it was fixing
code rather than editing a document written weeks earlier. The failure is
one-directional — always reassuring to the reader and wasteful to the doer —
and it has bitten on NF.Fms.refresh(), on due() and
inFlight() in the intake queue, on maySendCustomerMail(), and
on NF.Locations.resolve(), which was measured false six days after the
wiring landed and was still being read as current.
tools/caller-claim-sweep.js re-counts them. It is a census first: of the
sites it finds in docs/decisions/, docs/modules/,
docs/audits/, docs/policy/ and CLAUDE.md, most
sit in a paragraph, a nested bullet or a table rather than on a top-level list item, and
are therefore outside tools/claim-census.js by construction — run
--gap, the figure is derived rather than written down. The sweep answers
three ways and never two: holds, OPEN (a candidate, not a verdict — it
cannot read English) and we could not look, which is reported by name and never
folded into a pass.
The lesson in its header is the reason it exists. A positive control proves an
instrument RUNS; it does not prove it can SEE the thing being counted. A
fully-qualified pattern for NF.Locations.resolve( fires perfectly on its
control and still returns the wrong number, because this app reaches its own namespaces
through accessors — const LOCS = () => (NF.Locations || null), then
LOCS().resolve(q). So the count is cast wide over every
.<name>( in the scope and classified by receiver afterwards, through a
deliberately one-hop alias map, and anything it could not attribute is reported as a
number rather than dropped — which is the difference between "we found none" and "we
found none we could attribute", and only the first of those settles a claim of zero.
The subject and the scope are read by tools/claim-census.js's own
clause rules, not by a copy of them. The sweep first carried its own parser, and its
commit said it took the same approach as the census fix for #1270: a clause boundary and
a refusal for enumeration members. Measured with one fixture per #1270 rule, that was not
enough. The copy still skipped a token it could not use to reach one it could, had no
attachment test, took any token after the figure as its subject, did not mask backticks,
and did not stop at a full stop inside bold. Worst, it read "a() and
b() have zero callers" as a claim about b() alone and printed a
confident pass with a() never counted. It now calls the census's exported
subjectFor and scopeFor, so a change to a clause rule there
changes it here too. On top of that it adds only three narrowings specific to counting
calls. A list is refused, because "N callers" over a list does not say whether that is N
each or N in total. Only call-shaped, three-segment or camelCase names are counted. The
line above is read only when this line continues it. Separately, a module-qualified call
of a bare name (intakeq.inFlight(…)) is now reported as a site it could not
attribute, rather than as somebody else's method. The old handling had answered "holds"
to "inFlight() had zero callers" over the very call that made that sentence
false.
Reader claims are counted and never checked, because a read is a mention, and a mention cannot be told apart from a write, a declaration, or the same field name on another record. Every verdict prints the subject and the scope it was judged on.
What it finds today. It finds 205 sites across 67 documents, and 137 of them are outside the census's corpus. On the current tree, 6 are open and 2 hold, and each was read against its own sentence. Both holds are right. Of the six open, 4 are real stale claims:
NF.Pools.qualified() "has zero callers", in both the #255 and the #333
brief. The index retracted this claim; neither brief did.mayUseLiveIntegration() "has one caller", which is one per app and so
three.maySendCustomerMail() is called by nothing". That
has been false since the browser gate began asking it at the send seam.The other 2 open sites are true sentences that the sweep misreads.
The cost of inheriting #1270's attachment rule is stated in the suite. A subject behind
a dash aside or a parenthesis no longer resolves. That includes the
NF.Locations.resolve() sentence the sweep was built for, and a real drift in
the FMS plan's "three call sites" (the predicate now has seven). Those are counted as
"could not look" with a reason, not dropped.
The caller-claim group in
tests/suites/101-claim-census.js is the gate, and drives the sweep over
fabricated corpora and a fabricated source tree before any figure taken from the real
one is believed. It pins the open set exactly in both directions — a new one reddens,
and so does a repaired one left declared — keyed on file, subject and asserted count
rather than on a line number. Each open site carries a human's judgement, and the ones
that are genuinely stale claims are named as such rather than folded in with the false
positives. A guard against a claim declares itself with a marker in its own block, so
site-level exclusion can tell a statement of the claim from a guard against it, which
nothing reading English can.
Seventeen regression tests that could be silenced by the very fault they watch for now fail loudly instead. A test in this repository can be written so that a precondition is checked first and the real checks only run if it holds. Where that precondition is a fact about the sample data, that is right: if the fixture cannot be built the run stops loudly. Where it is a fact about the software being tested, it is the opposite of right, because the fault the checks exist to catch is the same fault that stops them running — one complaint is printed and the rest simply leave the count, so a run comes back looking almost clean while dozens of checks never happened. A census of every such site had already repaired eighty-eight of them across four passes, and twice recorded that a site had been filed as safe on the strength of the words in its label rather than on which function actually answers it. This pass re-derived the whole population from the files on disk and traced each one to its answering function. Seventeen more were wrong the same way. The largest single predicate reads as sample data — "the dataset carries a customer who allows one invoice per purchase order" — and is in fact the shipped per-customer terms resolver that every invoicing refusal behind it depends on; it sat in front of sixty-seven checks about what a dispatcher is told before an invoice goes out, across three files. Measured on the whole suite: with the terms resolver made to drop its per-customer record, the run previously reported 1 failure and was 95 checks short of itself; it now reports those checks as failures and the arithmetic reconciles. Six of the seventeen are in test files written after the census, so the pattern is still being authored; twenty-eight sites the census had already named as unsafe are still unrepaired and are listed by name. No check was added, removed, weakened or skipped — the suite reads the same total before and after.
The last twenty-eight regression tests that could be silenced by the very fault they watch for now fail loudly instead — and the arithmetic used to find them turns out to have a blind spot, which is now written down. A test here can be written so a precondition is checked first and the real checks only run if it holds. Where that precondition is a fact about the sample data, that is right. Where it is a fact about the software being tested, it is exactly wrong: the fault the checks exist to catch is the same fault that stops them running, so one complaint is printed and the rest quietly leave the count. A running census had repaired more than a hundred of these and had listed twenty-eight it had named and not fixed; this closes that list, across fifteen test files. Among the checks brought back are whether the customer portal still works out its cut-off from the calendar day rather than from a clock in another timezone, whether the driver's docket still refuses to print a raw database key at a driver, whether an imported consignment still keeps the number the customer was actually given, and whether the one safeguard stopping the app echoing the backup catalogue back to the server is still in place. Eleven of the recovered checks would have passed over nothing at all if they had simply been un-bracketed — a check that something is absent is satisfied by there being nothing to look at — so each now has to find real output before it is allowed to agree. Measured on the whole test run: with the shared data layer's start-up step disguised, the run previously reported 1 failure and was 6 checks short of itself; it now reports 5 failures and the arithmetic reconciles. Across twenty-one deliberate faults, 63 checks were measured back into the count. Three faults produced no usable measurement at all — one never applied, one left a file unreadable, one crashed the run — and all three are recorded, because a number nobody can tell from a non-number is the problem this work exists to solve. Three places were also found where a failed check was followed by a line that crashed outright, stopping the run and every file after it; those are repaired too. No check was added, removed, weakened or skipped, and the run reads the same total before and after.
Two FMS screens no longer report a database nobody had read as a clean one. The duplicate report told a dispatcher “No duplicates found across 0 active record(s) — a measured none, the records were read” for clients, places and contacts at once, before the database had answered. The write-back worklist said “Nothing has been imported yet … FleetView holds no con note FMS sent” in the same state. Both had checked that the collection was a list and not whether anybody had read it — and on the shipped configuration each of those lists starts out empty, so the check passed for an unread book every time. Both screens now ask the app’s one reader, and both say which empty it is: a read that has not landed is drawn hatched and withdraws the claim by name, a book we read that holds nothing at all says there was nothing to look at, and the original sentence is unchanged for the case it was always right about — we looked, over this many records, and found none. Duplicates the screen can see are still named either way, so nothing is hidden behind the new caveat. The duplicate report’s own heading had warned against exactly this: “reporting an unread book as tidy is the one wrong answer this screen could give”.
The absence-as-success census has been re-run, and now counts the screens that exist rather than the screens it counted last time. It claimed cover of “all 67 screens”; there are 71, and three of the four it missed were already on the tree when that claim was written — the count had been carried forward from an earlier pass instead of being taken again. The four are now swept: 24 candidate phrases, 21 of them a card, two defective and both fixed, which holds the defect rate at 9.7% across 145 cards and three passes. The superseded figures are kept and marked rather than replaced, and the test suite now reads the screen directory off disk and fails if the census states a number that no longer matches it, so the next screen to land cannot sit quietly outside a document claiming to cover everything.
Checking a change costs about half the CI time it did. CI no longer re-runs the whole test suite on master after a merge — the pull request's own run and the deploy's verification step already cover it — and the six browser test shards now wait until a pull request is marked ready for review instead of running on every push to a draft.
Two entries in the CLAUDE.md index still said the browser never asked
the customer-mail environment gate. The RM-band triage entry said
maySendCustomerMail() left FR-AO-05 "enforced nowhere"
and was "still the live half of the finding". Both have been false since
5d0a13e8 (#1248). NF.Intake.envSendGate() is now the
browser's one reader of the gate, and NF.Intake.sendPath() and the desk's
send capability() both ask it first. Re-counted by name, the gate has
4 call sites: the three banners plus that reader. The claims are kept word for
word and marked FALSE, as the brief itself was in #1286. The #346 entry, already
corrected on master, now also says that "the weaker of the two by design" still
holds, but its stated reason ("a browser is not making" the send) is now true only
of sendPath(), because the desk's Send button is a browser-initiated send.
Docs only; no guard reads either sentence.
The project guide every development session loads is 96% smaller.
CLAUDE.md was 351 KB, about 88,000 tokens read before any work
started. It is now a 14 KB spine: one line per module and the rules that apply
everywhere. The full index moved word for word into docs/index.md,
and the checks that read the index now read both files.
3337b6a9: #560's
"`/fleet/history` was the one windowed read on none of this until #706, which
is on a branch and not yet merged" (#706 merged — HIST_MAX_READS
at handler.js:1050, the deadline at :2740,
calls at :2843); #349's "two of the three
survivors" and the 20×20 tick box in the cab "no assertion
reaches" (one survivor, and both apps' ticks are scored rows at
tests/suites/95-target-size.js:116-117); #603's "the
writer is not `NF.Registry`, which holds six kinds and no `depot`" (seven
kinds, and depotForm() calls it); #346's
"`process.env.ENVIRONMENT` is read by nothing in `backend/src/` …
consulted by no gate" (read at handler.js:5122, gating a whole
FMS batch against a positive allow-list); #594's "there is no
migration runner at all"; #351's six uncounted suffixed requirement
ids; #236's reduced requirement report; and #255's empty-legs card
printing a green tick over an unreadable board.CREATE … IF NOT EXISTS, so the ALTER a foreign
key needs is still outside it and "editing line 161 changes nothing on the
live cluster" stands; #346's gate is one gate on one route and
FR-AO-05's send seam is still uncalled; #603's
id-minted-from-code is still Simon's question, still
unanswered, and was deliberately not tidied into an answer. Six open questions
for Simon, #719's production purge included, are left exactly as open as they
were.tests/suites/18-tms-pools.js:1312-1314 and
tests/manual/subcontractors.js:652-653, broken eleven ways with
dayRate reddening 1. Both files still exist; neither line region
carries it, and dayRate, PM-562 and
"out of scope by decision" return 0 across tests/ and
js/. A grep cannot tell reworded from moved from
removed, so the entry now says we could not look up the assertion this
recommendation turns on, which is not the same as finding it gone, and names
what would settle it. Two entries already refuse in these terms — #267's
"a read I could not perform is not a read that agreed" and #602's capped
window — and this is the same refusal.NF.Store.load()
:8877→:10627, the Overpass endpoint
:1356→:1585, the splits mailto:
:497→:515, and all five of #560's handler lines
— so a line that no longer matches is evidence of nothing at all
and must be re-derived by name before any conclusion is drawn from it.
That is the reverse of the failure #350's brief records, where a citation to
prose was mistaken for a citation to code, and it generalises the same
way: cite the mechanism, and re-measure by grepping the mechanism's name.
And age predicts nothing — the shortest-lived claim in the index was
falsified 14 minutes after the commit that wrote it (#351's
requirements-count-check.js: entry at 69aca0b,
09:00:08; fix at 6c6b895, 09:14:54), by the next slice in its own
wave.A merge message that said an issue was not being closed closed it anyway, and stopped the site from publishing. GitHub decides what a merge closes by looking for a keyword next to an issue number, and it does not read the sentence around it — so wording that was written to keep a scheduling issue open did the opposite. The repository already has a check that reads its own history for this, and that check caught it, which is why the site deploy stopped rather than quietly going out. The record now names the commit, what happened and why, alongside the earlier cases. One thing here is new: every previous instance had to be undone by hand, because something unfinished had been closed. This one did not — the issue really was complete, so the wording was wrong in both directions at once and the two mistakes cancelled. That is worth writing down precisely because nothing downstream looked wrong.
The backend can be deployed again. Since 18 September every backend deploy had been stopping at its pre-deploy verification step with nothing changed in AWS: one of the regression tests reads an earlier merge out of this repository's own history, and the backend deploy downloaded only the latest commit, so the test could not find it and failed on purpose rather than skipping. The deploy now downloads the full history, the way the site deploy and the pull-request checks already do. No application code changes; this only lets the backend fixes merged since 18 September reach the live system.
docs/decisions/333-dangerous-goods.md
said, in four places, that NF.assetDocs was absent from
snapshot(), the backend COLLECTIONS whitelist and
AUDIT_COLLECTIONS — true when it was measured on 3 Sep 2026,
and false since 1f3d96db (4 Sep 2026), which landed all
three the same day. The correction had been written into CLAUDE.md
only, so a reader opening the brief on its own still got the stale answer.
Each site is now marked FALSE since 1f3d96db with the
commit named, re-measured on a clean checkout
(grep -cE 'assetDocs *: *NF\.assetDocs' js/integrations.js reads
0 at the parent commit and 1 from that commit on,
with a control expression confirming the instrument can register a positive).
What survives is stated rather than dropped: NF.assetDocs still
empties to 0 under demoData:false, so the register
is empty rather than unpersistable — a weaker version of
the same finding, not a different one — and the brief's recommendation
(decline dangerous goods by rule) never rested on this clause and is
untouched.A run that lifts off the schedule board but will not drop now says why, and the run line it could not find is found. A dispatcher reported runs picking up and following the cursor, then doing nothing at all on release, with no message. Two things were wrong and the first caused the second.
A run line in FleetView has no id of its own: it is identified by its name, or by its driver where it has no name — that is the rule the database stores each line under, and the rule the Run lines card already followed. The board followed only half of it, labelling every cell and every run card from the line's name alone. So a run line identified by its driver labelled everything on it with an empty identity, nothing could be looked up, and the drop silently did nothing. The board now uses the one identity rule the rest of the app uses, so a line stored under its driver behaves on the board exactly like a named one. Nothing changes for a named line: it is labelled with its name, byte for byte, as before.
The second half is that three separate things could refuse a drop without saying a word, and all three produced that same symptom. Each now prints its own refusal in the cell the run was dropped on, dimmed and dashed, staying on screen rather than sliding away: the run line you dropped onto could not be identified, the run's own line could not be identified, and the run is no longer in the cell it was dragged from — which is what a second scheduler moving the same run looks like, and which now says so, and says that their change has not been overwritten. Each names the run-line identifier the board was given, so a mismatch can be read off the screen, and each ends with what to do next rather than only with what failed.
What is proved is that the board did break on a line identified by its driver and no longer does. Whether the reported board actually holds such a line is not established here — the refusals above are what make the next occurrence readable off the screen either way.
wireDrag() set
dragId on dragstart and never cleared it, so a drag
that ended without a drop — Escape, released off every column,
dragged out of the window — left that card's id live in the handler, and
the next drop the board received from any source at all moved it: status
written, the con note written with it, and the job rostered onto a lane. The id
is now cleared on dragend and consumed by the drop that uses it, so
one drag is one move. Reachable with a mouse, and measured in Chromium
rather than reasoned about: drag a card out of Unassigned and let go over
the page chrome, then double-click a column heading to select the word and drag
that text onto Delivered — before this, the abandoned card was
delivered. A column accepts a drag that began anywhere, because
dragover calls preventDefault() and cannot ask where
the drag came from, so text from another window and a file dragged out of
Outlook reach the same handler.dragId so a drop acts
on a stale id reddens 7 assertions in tests/run.js, dropping
the refusal block entirely reddens 13, gutting its wording reddens
4, and drawing it hatched rather than dashed reddens 2.The Fleet Dynamics clock tolerance could not be set to zero. The reader
behind FD_FIX_SKEW_MS — how far into the future a truck's
reported fix may be stamped before it is refused as a clock or timezone fault
— treated a deliberate 0 as if nobody had set anything, and
silently used two minutes instead. Zero is the one value that makes the knob do
its job, so an operator asking for no tolerance at all got a number they had not
chosen and nothing anywhere said so. Measured on the retired expression, ten of
fifteen probe values came back identical at 120000 ms — the variable being
absent, being empty, being an explicit 120000, being unreadable, and
being a deliberate 0 — so the setting could not be told from
its own absence. A plain whole number is now honoured exactly as written,
0 included. Nothing else moved: the default is still two
minutes, and every other value resolves to precisely the number it did before,
including the ones nobody would type on purpose — 1e5 still
reads as one millisecond and 0.5 still falls back to the default,
because silently repairing a tolerance somebody configured would shift every
fix's future-check by an amount nobody asked for. What is new is that each of
those now writes a line naming what is being used instead, rather than passing in
silence. The tolerance in force is also reported on the fleet feed's own
diagnostics beside the count of fixes it refused: that count means a timezone
fault at the two-minute default and ordinary clock drift at zero, and until now
only one of the two numbers was ever visible.
undefined on every row, with the description
and the weight blank beside it — over freight whose description, quantity
and weight were on the record the whole time. Three different item shapes reach
these screens (our own forms, a quote drafted from a customer’s email, and
the shape FMS sends) and every reader in the app knew at most two of them: the
cab and the customer’s own tracking page printed the word itself, the
dispatcher’s tables and the printed con note drew blank rows, four
screens had each written their own local two-shape repair, and the item counts
on the job card, the run sheet and the signature panel answered
NaN. There is one shared reader now — every freight line in
all three apps goes through it — and a test that refuses a fifth
hand-written copy.NF.PortalTrack —
the decision that keeps a delivery photo off a link a customer can forward
— used to turn ALL PASS — 29728 assertions into
29676 passed, 1 FAILED: one red, and 51 checks that simply
did not run, so reds plus passes came back 51 short of the baseline and nothing
said so. The same break now reddens 52 and the total holds. A deleted
stylesheet rule on the portal hid 5 the same way, and the
erDepotTag depot-label guard hid 15.docs/decisions/gated-assertion-census.md
— 235 gates examined: 94 harmful, 87 benign, 1 undecided, measured
with an execution-level instrument rather than a grep, and proved against both
previously known cases before any number was believed. It settles what separates
the two: a gate on a property of the code under test is closed by the very
regression it exists to catch, while a gate on a fixture fails loudly and
correctly. It also records the family nobody had counted — a bare
if (block) { … } costs no red at all, and renaming one
comment in js/screens/07-schedule.js that the application never
reads takes one suite from ALL PASS — 262 to
1 passed, 1 FAILED. Across both families 2,604 assertions, 8.8%
of the suite, sit inside a gate body.docs/decisions/gated-assertion-census.md's named remainder, across
five files. A gate written if (ok(cond, '…')) { … }
prints one red when it closes and its body's assertions leave the run
total altogether, so reds + passes stops reconciling with the
baseline and nothing says so. Measured, before and after, on the runner's own
N passed, M FAILED line: renaming podBox() — the
one renderer behind a forwardable customer tracking link — reported 2
reds and removed 16 checks about what that link publishes; it now reddens
17 and the total holds. Renaming the driver app's
stampTime(), reformatting NF.Outbox's own opening line
and giving the outbox's receipt an id no queued intent carries hid 4,
2 and 5. The suite reads ALL PASS — 34338 before
and after: no assertion was added, removed, weakened or exempted, because what
had gone missing was the denominator.tests/suites/71-send-stamp-order.js picks its fixture through
NF.Desk.tickets() and then NF.Jobs.lifecycle(), asking
for a thread whose sentAt is still null — and
sentAt is the field every group in that file exists to assert
about. So a lifecycle that stamped a thread it should not have was the one
regression that emptied every group written to catch it. Measured: the file went
from ALL PASS — 75 to 30 passed, 8 FAILED — eight
reds over a run 37 assertions short of itself. It now reads 37 passed,
38 FAILED, reconciling exactly. The earlier pass's own note that “the
ticket gate above it IS a fixture…and stays” is kept in the census
and marked false, with the measurement that falsified it.podBody.lastIndexOf(…),
intent.dev/intent.seq, and
N.Desk.send(t.id, …) — each now read through a stand-in
whose every field is a sentinel no real record can equal, so a group whose
fixture did not resolve still runs and still reddens claim by claim. Eleven
negative claims were also rewritten to establish non-empty output first, because
''.indexOf(x) < 0, !/x/.test('') and
(null || []).length === 0 are every one of them true: they
had been certifying that a customer's tracking link dereferences no delivery
photo and no signature artefact, over a renderer nobody had found.The search box no longer drops matches without saying so. The dropdown holds eight rows and six searches feed it, so on a common word the passes that ran last never appeared at all — and nothing on screen said anything had been left out, which reads as that is everything. Measured on the shipped screen: 41 records matching one typed word returned eight con note rows and nothing else — no enquiry, no message, no asset, and not one word saying so. The asset pass, which stops at four of the 1,189-row register, was silent in the same way: seven trucks carrying one fleet prefix were four trucks as far as anybody could tell. Worse, the enquiry and message searches did name what they left out and the shared eight then cut those notices first, because they sit last in their own search — six con notes and six enquiries drew one enquiry and threw away the row that said five more matched. Every one of the four now says what fell off it, by type and by count (“34 more match and are not shown — 13 connotes, 6 enquiries, 6 messages, 9 assets”), the asset overflow opens the asset register and the enquiry and message ones still open their own screens. The caps themselves are unchanged: an unbounded dropdown over 1,189 assets is the problem they were added for, and what was wrong was the silence, not the eight.
The last copy of a wrong reason in the app's own source has been retracted where
it was written — and this one, unlike its three siblings, really was true once.
A comment in the shared data layer explained why a driver's qualifications are kept in
their own small store by saying the driver list "is not in snapshot() at
all", so anything recorded against a driver's own record "would never survive a sync".
The driver list is saved, and is in all three of the places a collection has to be
in to persist — one of them a few hundred lines further down the very same file.
Three other copies of that sentence, corrected separately, were written more than a week
after the change that made them wrong, so no version of the repository ever made those
true. This one was written the day before that change and was correct for about
twenty-seven hours. It is therefore retracted as a claim that went out of date, not as
one that was never right, because those are two different things and recording the wrong
one teaches the next reader to date the mechanism instead of dating the claim.
Nothing about how qualifications are stored, merged or read has changed. The arrangement the wrong reason was defending is correct for the other reason given alongside it: a set of qualifications arriving from an import or a future integration has to take precedence over what somebody ticks by hand, so the driver record's own field stays reserved for that and the editable map stays separate. Only the comment moved — every changed line is inside it, and the code is byte-for-byte identical once comments and spacing are taken out. The note also points the reader at the place in the same file that always described the same history correctly, since two statements disagreeing three hundred lines apart is what let the wrong one survive this long, and it records that the sentence wraps across two lines, which is why an ordinary single-line search for it finds nothing and reads as though it had already been fixed.
jsonb, which does not keep the order the fields were
written in: it orders them by length and then alphabetically, so
driver and colour — six letters each —
swap places on the way back. The comparison was on the text, so a swap read as a
change. Trailers were unaffected only because their three fields happen to be in
that order already, which is why one half of the load cried wolf and the other
did not.
null against an empty string are all
still reported as differences.backend/src/staffgate.js and
docs/modules/geofence-alerts.md both carried the pre-wiring wording:
the fail-open default was defensible only while nothing in js/
called POST /geofence/alert, and 183472a0 made it
reachable, moving the default in the same change. Absent now means our own
domain and our own country — derived from
GRAPHMAIL_MAILBOX and ALERTS_SMS_COUNTRY, never a typed
literal — and ALERTS_EMAIL_DOMAINS=* lifts it and has to be
typed. Both copies now carry the correction with the old wording kept beside it,
because finding the claim in two places was one error copied and not two
sources. Prose only: no behaviour changed and no deploy is needed. (#83)position:sticky so it stays in view while a long run sheet
scrolls under it. The page’s laid-out height is fractional while
scrollHeight is a whole number, so the browser’s furthest
scroll can stop up to half a pixel short of bringing the document’s last
row into view — on the sheet that failed, 2265.48px of page against a
scroll maximum of 1497 where 1497.48 was needed. The banner then does what it
was asked to and lifts itself back to its offset, while the version line above
it is in static flow and does not move, so the gap between the two closed by
exactly that remainder and the banner’s border landed inside the version
line’s bottom padding. It carries a 1px flow separation now, which is a
bound rather than a tolerance: the remainder is the document’s overshoot
past a whole-pixel scroll maximum and so is strictly under one pixel by
construction — measured at 0.438px worst across 40 sub-pixel page heights,
where the overlap was 0 at every one of them afterwards. No glyph was ever
covered (the version text stayed at least 13.5px clear), and nothing was bought
from the banner, which may not be shrunk.tests/manual/in-cab.js
now walks the page height through a whole pixel in 1/8px steps and asserts the
version stamp is clear of the banner at every one, with a precondition that the
sweep really did drive the remainder past zero in both directions so a sweep
that varied nothing cannot read as a pass. Removing the 1px separation reddens 2
of 161 checks in that suite, against 1 of 159 before, and the new one does not
depend on the calendar.#/speeding drew one tick per local
midnight by adding a flat day to the previous one. That is exact in Perth and in
UTC, and across a daylight-saving fall-back it lands at 23:00 of the day it
started on — so the tick sat an hour before the real midnight and the
weekday label named the same day twice, with the next day getting no tick at all.
It steps through NF.localMidnightPlus now, the one calendar step
NF.localDayPlus is already built on.js/, driver/,
portal/ and backend/src/ — 12 of them comments
warning against it — and two opposite things wear that spelling: a duration, a threshold or a cutoff (correct, and made worse by a
calendar helper) and a calendar step or key (the defect — it repeats one day
and drops another, with every row still carrying a real date). The new guard
watches the pair — an instant built by the constant and then read
through a wall clock — so the legitimate two thirds are left alone by
construction rather than by an exemption list, and it re-spawns itself under UTC,
Australia/Perth and America/New_York because two zones with no transition cannot
see this at all. It reaches three trees the existing check does not, follows
t += 86400000, and catches a weekday read that mints no day key,
which is how the one survivor outlived two sweeps.The suite-number report now names the one clash that turns the main branch red, instead of calling it a missing row. Test suites are numbered, two branches cannot share a number, and the tool that hands numbers out reads published branches — so two agents who each ask before the other pushes are both given the same one. Until now the reconciliation had five verdicts and none of them could say two different suite files claim a number the main branch does not hold yet: it reported that as a missing bookkeeping row, when the actual cost is that both branches pass on their own and whichever merges second fails the whole run. Measured on the tree this landed against: one such number was claimed by two files across three open pull requests, and dropping both files in and running the check exits with a refusal naming the pair. That one settled itself over the following few hours — one of the two merged, the other renumbered, and both branches have since been deleted — which is the point rather than a let-off: nothing would have said so either way, and the settlement was found by re-running the check rather than by anyone noticing. It is reported as its own state now, printed first and never truncated, with both files and their branches named so the choice of which one moves is somebody's to make before either merges. It is deliberately not silenced by adding the row the old verdict asked for — a row is bookkeeping and settles nothing about which file keeps the number — and one file carried by several branches (a rebase, a copy, a second attempt) is not counted as a clash at all, because they all merge to the same single file.
The hand-maintained reservation table behind it was reconciled at the same time, five times over, because the tree moved under every single pass: six reservations whose work had already landed were struck, four numbers a branch had taken with no row naming them were recorded, and every one of the eighteen clashes the report had been carrying was checked by content and found to be historical — twenty of the twenty-seven files involved are already on the main branch under a later number, two were deleted from it outright, and the remaining five were matched by the mechanism they test rather than by their name. Comparing commits would have said the opposite, because every one of those branches was squash-merged. No open pull request is affected by any of them: there is currently exactly one, and it adds no test suite at all.
And a reservation whose work has already landed now fails the test run instead of sitting there. The reservation table is maintained by hand, and until now nothing reddened when a row went stale — the report named it and exited clean, so the only thing between the table and somebody trusting it was remembering to run a command. Measured: the three rows struck above, put back as they stood, left the whole suite passing. A row that still reserves a number the test directory already holds is now a failure naming the number, the branch, the line and the file. It reads the table against the directory rather than against itself, so it cannot go quietly green; it leaves alone a row that says the reservation landed, which is the table agreeing with the directory and is written on purpose; and it can only ever fail the branch that is landing the file, never somebody else's merge.
It has since caught four real stale rows on four consecutive checks of the same table, every time before anybody looked — and not one of them was found by deliberately breaking it. On each occasion a row that was correct when it was written had gone stale within the hour, because the work it reserved a number for had merged in the meantime, and the run went red naming the number, the branch and the line. That is the evidence the check was worth adding, and it is stronger than a staged break because nothing about it was arranged.
purge-records.js
takes a dump of everything it is about to remove — every customer,
contact, con note, invoice and form submission, with the photos in them. It
wrote that file into the project folder, and both ways of publishing the site
upload that folder wholesale to the public web server. The list of things they
skip covers folders and file types; a dump sitting at the top level matched
none of them.
.gitignore does not fix this, because the upload reads
the folder rather than source control — it would keep being published and
would stop showing up as an untracked file while it happened.docs/session-handoff.md's in-flight dispatch table still named
claude/608-booking-snapshot, claude/1209-dispatch-claims and
claude/261-pipeline-residual after all three had merged, so
node tools/dispatch-claim-check.js answered could not look — on no ref
at all three times about work that was on master — which two agents independently
reported. Every row in the file has been re-derived at 52bf4675 by mechanism
rather than by file:line, with a positive control on every negative: five rows
moved, and the reserved-ordinal table, the carrier-branch counts, the backend-suite scope and
the open-pull-request measurement are corrected in place with the original wording kept.139+ free at time of writing) names no particular ordinal, so the register's
stale-row assertion skipped it by design — and it is the only row that is a positive
claim about numbers nobody has taken. Measured: it read 139+ while
tests/suites/139-trailer-manifest.js had been on master for a day, and
node tests/run.js was ALL PASS throughout.
suite-number-check.js --claims now reports a stale marker by name and Group 7 of
tests/suites/130-suite-claim.js reddens on it — 1 assertion for a stale
marker, 1 for un-wiring the report, 4 for a helper that can no longer register a breach.
It stays silent about ordinals held only on unmerged branches, because
--next is what withholds those and duplicating the judgement would make the
answer depend on how recently anybody fetched.Written down: how several Claude sessions work on this repository at once
without breaking each other. On 18 September 2026 they collided three times
in one morning without any of them doing anything wrong — three branches
independently minted the same regression-suite number, and then two more minted
another one because the tool that hands out free numbers answered the same number
to both, each having asked before the other published. Each branch was green on
its own; the breakage only appears once both are merged.
docs/session-handoff.md records the four ways two correct sessions
produce one broken build, with the measurement behind each, and carries the
register where a session reserves a number before its work starts rather than
when it finishes. The existing dispatch document covers collisions inside a
single session and now says so explicitly, with a pointer across, because the
two problems look alike and the fix for one does not work on the other.
A collection the browser saves and the server quietly throws away is now caught
by a test instead of by a phone call. Everything FleetView holds — consignments,
credentials, rate agreements — travels to the database as one document, and the
server writes back only the collections it has been told about by name. A collection
added to the document and not to that list was not written, and nothing said so:
no error, no warning, and a screen that looked exactly as though it had saved. The
records then lived in one browser's cache and nowhere else, so something entered in
Hazelmere was invisible in Port Hedland until somebody noticed it missing. Every check
in the test suite asked only "is my collection on the list", which can find a
collection somebody thought to ask about and never the one nobody did. The two lists are
now compared as sets, in both directions, and a difference is reported by the
names that differ rather than as two counts that disagree. Measured on the live
tree: the document carries 52 keys, the server's registry names 50 collections, and the
only genuine difference is termsVersions — published terms and
conditions, saved in the browser and dropped by every save since they were built, which
is what this was written after. Two kinds of deliberate exception are declared rather
than left as comments and are each pinned so they cannot outlive their reason: a
collection shipped ahead of its server-side half goes red the day the server catches up,
and a collection only the server may write goes red the day a browser starts sending it
back. If either list cannot be read at all, the check says so loudly and names every
collection, because "we could not look" and "they agree" must not look the same.
Four decision briefs still said a mechanism had no caller after somebody had wired
it. tools/caller-claim-sweep.js flagged them, and each one was
re-counted by name, comment-stripped over js/, driver/,
portal/ and backend/src/, on b0858a07. Each is
corrected in place: the claim is kept word for word, marked FALSE on its own line, and
the commit that falsified it is named.
NF.Pools.qualified() "has zero callers" in the backload brief
(#255) and the dangerous-goods brief (#333). 2 call sites today: the engine
room's requirement read from 1f3d96d (#592) and the departure decision's
rig advisories from e26cd1f3 (#601). The dangerous-goods recommendation
does not move. DG still never reaches the check, because both callers ask only for what
a rig requires.mayUseLiveIntegration() "has one caller today (the banner)" in
the environments brief (#346). That was 3 call sites from the start, one banner
line in each app, and nothing enforces FR-DV-12.maySendCustomerMail() "is called by nothing" in the RM-band
triage. 4 call sites since 5d0a13e8 (#1248), which made the
browser's send seams ask it. That means none of the four uncalled mechanisms that
triage named is still uncalled.The sweep's gate in tests/suites/101-claim-census.js now asserts that
each corrected claim is still present and reads as retracted, rather than just no
longer open. A sentence deleted outright would pass a check for "no longer open", and
the convention forbids deleting one.
Two entries in the developer index contradicted themselves or the tests they cited. The geofence-alerting entry said, in one paragraph, both that sending was wired and that a saved email address or mobile number was stored and not sent — because a correction had been written over the head of the old sentence and its tail was left standing about 1,500 characters further on. The tail was the false half: the route went in on 6 September and the page was wired to it on 12 September. The accessibility entry said a test file still named 26 unreadable colour pairs in the customer portal and the cab; all 26 were fixed on 12 September and that file now asserts the list is empty. Both retired sentences are kept and marked false with the change that falsified them, rather than quietly rewritten, because 26 is the figure the colour work was sized against and deleting it would destroy the record of what was fixed.
The stale-claim checker now catches a correction that replaced a sentence and left its tail behind. It reported nothing wrong on the same commit that carried both faults above, so it was widened in two independent ways: it now reads this project's own wording for something that is built but not connected, and it separately reports an index entry that runs a finished sentence straight into a dash-led fragment with nothing to attach to — the punctuation a half-finished correction leaves behind. Measured on the real entry, each catches it on its own, and reverting the repair turns two checks red. Both faults are carried as fixtures so a zero from the checker means something.
And it now says in writing that it cannot check a count written in prose against a number in the tests. The second fault above would need a machine to map the phrase “budget lines” onto a name in a test file, and measured, nothing does: the cited file holds two lists a reader could count — one with no entries and one with 26 — and they answer the sentence in opposite directions. Checking only that the cited file and line still exist was measured too and finds nothing at all, here or anywhere else in the index. So the checker refuses this shape by name and a test asserts it keeps refusing, rather than leaving an unchecked case looking like a checked one.
tests/suites/88-manual-dir-census.js reads the browser checks off
disk, so one added later is covered without being listed. For each file write
it finds the path being written to and checks it is both untracked
and ignored: untracking a file without ignoring it only turns a modified
file into an untracked one. It also checks the screenshot directories
ci.yml passes, and that the /screens/ ignore rule
still does not reach the dispatcher app's own js/screens/. Before
this, removing the leading slash from that rule turned nothing red anywhere.
A path that is only worked out at run time cannot be checked from the file, so
the guard lists those by name and does not count them as clean.tools/fms-agent/ is the first half of that: it reads FMS
(MS SQL Server) as a Windows scheduled task, normalises a window of con
notes into one stable shape and prints it to stdout. There is no HTTP
client in it at all — no fetch, no
require('https'), no URL — because the route that will land a
batch does not exist yet, and an agent that could already post would be posting
at a 404. Read-only is three independent things rather than an intention: a
db_datareader-only login, a loader that refuses any query file that
is not a single SELECT/WITH, and a read transaction
that is always rolled back and never committed. Two of the write shapes
it refuses begin with the word SELECT.int whose meaning is written down
nowhere, so the decoder ships REFUSING and a one-line probe settles it.
PUDate, PUTime, DELDate and six more are
plain integers with no column comment saying whether 46269 is a day
count or a packed YYYYMMDD, and nobody has yet read one beside the
date FMS renders for it. So the encoding is a declared constant that ships
unset, every date refuses by name, and the batch mode will not run. This
repository has already paid for the other habit once: a feed's datetime read
under an assumed format reported a four-minute-old GPS fix as 147.7 days in
the past. --probe prints what all ten candidate encodings yield
for a real value and which agree with the date on the con note — and when
two agree it answers AMBIGUOUS and refuses to choose, because picking the
first ships a decoder that is right about September and wrong about every day
before the 13th.--census counts the three candidate change
sources exactly (FMS has no rowversion anywhere, so the
incremental read cannot hang off a change token), reads row estimates from
partition metadata for every table — which is what turns “FMS's
driver app is unused” from an assumption into a measurement —
lists the configurable BookingStatus rows so somebody can say which
of them mean cancelled, and lists what the columns actually are, because every
column name in the queries came from a DDL mapping and a screenshot and none has
been run against a live database. A query that fails is reported as could not
read and never as a count of zero: a table that is absent and a table that
is empty are different answers, and only one of them means do not build on
this.#/fmsimport, reached from Settings, which is
mostly empty on purpose and says so in words. What FMS sent, what matched,
and what was refused and why. Only the on-prem reader has landed, so nearly
every panel's honest answer is we could not look — and the
reassuring version of that (“0 con notes awaiting import ✓”)
would be read by somebody trying to find out whether FMS's bookings are reaching
FleetView at all, which is the worst possible place to get it wrong. So there
are four states per panel rather than two: no reader (hatched), a reader
that threw (hatched, carrying its own message — a failed read is
not an empty inbox), a reader that answered with nothing (dimmed and dashed, a
measured none), and rows. Every state is derived from the running app
rather than written down, so the screen starts telling the truth about each
piece the moment it merges, with nobody editing it. The one panel that is real
today is the number space, which also draws the missing FMS- prefix
as dangerous rather than merely absent: without it an imported
FMS-153939 lands as CN-153939 and, because the store
merges by id, silently overwrites a FleetView con note — no error, no
conflict, no warning.status:'delivered' and a
delivery stamp with nobody's signature on it is still not a POD; and an undated
delivery is never given a date, so a con note whose only stamp is the driver
app's HH:MM leg time is listed with the time it has and the date
drawn hatched. Duplicate master records names them rather than merging
them, because FleetView cannot merge two records for anyone anywhere — a
twenty-year FMS address book is arriving into a store that merges on an id
collision and appends everything else forever, so the report exists before the
import rather than after it. A collection that has not been read is drawn
hatched, never counted as a clean book.--send
and it prints the batch and touches no network at all, which is how the first
few runs should go: read a batch, check a con note you recognise has the pickup
date you expect, and only then let it leave the building. A batch whose dates
could not be decoded can be looked at and can never be sent.
Running the two systems in parallel is safe to leave going. A con note is
keyed on FMS's own number, so re-reading the same window changes nothing — the
second pass reports every con note unchanged and writes nothing. Overlap the
windows deliberately: re-reading is free, missing a con note is not. And nothing
is ever written back to FMS.20260904, 830 — with nothing in the
database saying what those numbers mean, and there are a dozen conventions they
could have been. Rather than guess, the reader was built to refuse every
date until somebody checked, because a booking silently moved to the wrong week
is worse than no booking at all. The check has now been run against the live FMS
database: con note C153939 reads 20260904 and 830, and
renders 4 September 2026 at 08:30 — exactly what the con note itself shows. Two
of the rival readings turned out to be impossible rather than merely less likely,
so this is settled rather than assumed, and the row it was settled on is recorded
beside the answer. Pickup and delivery dates and times now come through, which is
what the rest of the import was waiting on. Setting it up on the FMS server also
gained two missing pieces of its own instructions: the setting needed to connect
to a server with a self-signed certificate, which the first live attempt failed
without, and the two connection timeouts — all three read by the code and written
down nowhere, now checked automatically against what the code actually reads.--check command reports whether both are set — never
their values — so this can be confirmed before the first live run rather than at
four in the morning.backend/src/fms.js — the pure half of the FMS import's landing route
(docs/decisions/fms-import.md §7). FMS books the freight and FleetView
runs it, and an on-prem agent will POST batches of con notes across; this decides
what a batch may contain and refuses the rest by name, in the sentence the
caller prints. It ships ahead of the route deliberately, because the sending end
is being written separately and needed something to target.
The date rule is the whole of the risk. Every date and time in FMS is stored
as a plain integer whose meaning is written down nowhere in its database, so the
agent decodes them through one function that refuses rather than guesses — and
that refusal now has to survive the trip: a timestamp with no timezone on it, or
a raw undecoded number, is refused on arrival rather than read under an
assumption. We have paid for the alternative once already, when a vehicle feed
changed date format and a four-minute-old position was reported as 147.7 days
old. The same mistake on a con note is a truck at a mine gate on the wrong day.
Money is treated the same way: a figure that arrives as text rather than a number
is refused, because picking the number out of it is the same guess.
One bad con note costs only itself — a batch is never all-or-nothing, and
every row comes back stored, unchanged or refused with its own reason, so 199
good con notes are never lost to one that could not be read.FMS-153939 — and there were
two separate ways for that to go wrong, of which fixing either one alone leaves the
other. The overwrite: the con note counter is max(digits) + 1 over
every consignment, so landing 153939 as CN-153939 would make our next
native con note CN-153940, and the day FMS reached 153940 the import
would merge into it — merge is by id, so no error, no conflict and no warning: a
con note silently rewritten with somebody else's freight. The ratchet: both
counters strip the letters off an id, so FMS-153939 still reads as 153939
and moves the con note counter and the quote/booking counter, putting every
number the business issues from then on into the 150,000s. So this is a distinct
prefix and exclusion from both counters, decided by one reader
(NF.isSourceMinted) that three call sites share — modelled on the
one this repo already wrote for split parts, because two copies of a numbering rule is
a drift it has paid for three times.FMS-153939 would have picked up a second number
(C-24893) that nobody at either company has ever been given. The thread
now renders the FMS number at every stage, including the customer-facing enquiry
reference — honest, because FleetView's quote → booking → con note
lifecycle never happened for a record FMS booked. It also holds no sequence number
at all: handing each imported thread the next free one looks correct on a single
record and climbs by one per import, which is the same ratchet arriving one silent
step at a time. Its id is derived from the FMS number rather than counted, so two
dispatchers syncing the same import mint the same record instead of two.NF.legPrice answered
source: 'job' for a figure struck in the other system, which let a screen
imply FleetView had derived it off a rate agreement. It now names the system and the
number the figure arrived with, keeping the internal route in via.Number(null) is 0 and 0 is finite
— the same trap this code already records about Number('') —
so the number-space reader counted such a thread and, on an install carrying only
imported work, reported a ceiling of 0 sourced from a thread where the
truth is that nothing is on file at all. It now reports the absence.LOC-FMS-4412 → LOC-FMS-9013 on the board, on the office run
sheet and on the driver's own run sheet, and tapping Navigate opened a map
search for that code. The route is now built from the real place names
wherever FleetView can resolve them, an unrecognised code is never printed as
though it were a place, and re-scheduling a job refreshes a label that was
composed before the addresses arrived. Some related repairs come with it: the
two dates FMS holds are now carried across, so imported work sorts by when it
is actually due instead of falling to the bottom of the unscheduled list as
"no date given".NF.placeOf is the one reader for a place, answering five
ways — one of our yards, a site in the address book, an ad-hoc name and address,
bare coordinates for a mine site with no address, and somebody's own words we never
resolved, which is deliberately kept apart from nobody filled this in, because
folding the two prints a dash over what a customer wrote. NF.depotOf
separates the owning branch, and falls back to the collection point only where that
really is a depot — never to a customer site, because "the branch that owns
this job is the Roy Hill main gate" is not an answer.NF.routeLabel
composes a con note's route from its two ends at the moment it is drawn, so correcting a
place reaches every screen with nothing to migrate — the step the depot-code
finding stopped one short of, where a Bunbury return renders as
"Perth (Hazelmere) → Tom Price" because the label was composed
once at scheduling time and copied. It carries the stored label beside the derived one
and reports whether they agree, so a screen can show the disagreement rather than
have one label silently swapped for another. A route with only one end prints the end we
hold and no arrow: "Perth → —" reads as a delivery to a place
called nothing. Switching the schedule board and the driver's run sheet onto it is the
follow-up; nothing on screen has changed yet.NF.get.depot() answers for only one of the two. Five
lines in portal/portal.js read .name off that
undefined, inside the render of a page a Northfleet customer opens
from an emailed tracking link — so the shipment grid, the tracking detail and the
booking confirmation died outright, with nobody there to press F12. All
25 reads across 15 lines now go through the shared layer's own
NF.routeLabel/NF.placeOf, which resolve a depot id, a
location id or name, bare coordinates, an ad-hoc place or free text and never
throw.LOC-FMS-4412 at the customer, which is a database key on somebody
else's shipment page and is worse than an honest blank. There are four answers
now: a place we hold is named; free text a customer wrote is printed as they
wrote it; a value that matches nothing we hold and is shaped like one of our own
keys is drawn hatched as Not identified with the key never printed;
and a field nobody filled in is dimmed as Not recorded. The hatch is
defined in portal/portal.css, because the portal loads no
css/ file and a hatch borrowed from the dispatcher ships flat. On
the map a pin is a claim we can place it, so an end carrying no fix simply gets
none rather than a marker at lat 0 / lng 0.PUT leaving such a tab. What it then told everybody was
wrong: NF.Store.pushNow clears the pending flag for the refusal
(right — a write that will never happen is not one that is waiting) and
nothing is recorded as blocked (right — no record was refused as too
large), so syncState() fell through its whole chain to
synced. Measured before the fix, on a demo build against a
remote apiBase: syncState() answered
'synced' and NF.Odoo.writeOutcome() answered
written — “The database has acknowledged this
capture, so the office has it” — for a capture that had never
left the device. It now answers refused, and a new
NF.Store.syncRefusal() gives the reason and the store's own
sentence so a screen prints that rather than inventing a second wording. Three
answers, never two: the database has it, it has not gone yet, it did not go and
never will.refresh() attempts a push (refused, which clears the pending
flag), sees nothing pending, reads the database successfully and then emitted a
bare synced event. So a screen holding the most recent state I
saw went from the refusal back to synced on one tap
— which defeated not only syncState() but the
nf-sync-status event that the POD write-outcome reader had been
written to consult instead of syncState() to work around
this very bug. synced can now no longer be emitted at all once a
write has been refused, gated at the one place all four emitters go through.
Measured: reverting that gate alone reddens 4 assertions in
tests/store-sync.js, including the one where the POD outcome goes
back to reading “Saved to the database”; reverting the whole
change reddens 11.toast() kept
a single element over a single timer, so the next call overwrote the text and
the colour in place — and because the box was already on screen there was
no entrance animation to say anything had changed. Measured over the flows nine
browser suites already drive: 44 toast writes, of which 22 landed
on a different message still showing, median gap 1,433 ms and
shortest 362 ms. Two screens carry comments recording that they were
bitten by it and each composed one sentence rather than raising two, which is
the right fix at one call site and no fix at all between two that do not know
about each other — one of the 22 was the geofence arrival alert
("NF216 arrived at Port Hedland") wiping a registry confirmation
921 ms after it and being wiped itself 581 ms later, and
a truck reaching a yard arrives on the fleet poll's schedule rather than the
operator's. Every message now has its own pill and its own timer, so each gets
its full window from its own arrival and no call can shorten another.docs/audits/wcag-2.2-aa-audit.md measures no timing
criterion at all — 2.2.1, 2.2.2 and 4.1.3 appear nowhere in it — so
there is no measured basis here for moving the number, and that audit's own
verdict, that nothing may be claimed, is unchanged by this. Four suites also pin
it: compliance.js, locations.js, rates.js
and sso.js each wait 3,200 ms and assert the box has
gone, which is how they prove a refusal could not have been read from it. A box
that holds two confirmations is not a durable surface and must not become
a home for refusals; the new check in tests/manual/registry.js
asserts a burst of five is still gone 3,200 ms later for that reason.
Reverting the one-slot behaviour reddens 14 of that suite's 126
assertions; removing the cap reddens 5. tests/run.js is
unmoved at 17,451, which is expected rather than a weak result — it
loads neither js/app.js nor js/screens/.backend/purge-records.js — report-only by default,
--write to act. It is the first increment of the FMS import
(docs/decisions/fms-import.md §13): FMS books the freight and FleetView
runs it, and before the master-data import lands FMS has to be the only source
of customers, sites and contacts, so the prototype records in production go.
30 collections of records go, 18 of configuration and taxonomy stay, and 2 are
server-authored and cannot be reached this way — and those three lists are checked
against the collection registry to partition it exactly before the first read,
so a collection nobody has classified is a refusal rather than one silently left or
silently purged. The asset register stays: 1,190 real rows, lost once already,
and assetDocs carries the expiries a driver hands to an inspector. The
compliance rules and course content stay; the completions filed against a
person go. It is purge-demo-data.js's operational shape and the opposite of
its logic — that script derives what is sample data by diffing two loads of
js/data.js, which can only find rows the seed mints; this one carries an
explicit list, because the list is the reviewable artifact.GET can never turn into a write.
The read-back is the authority, because the ack speaks for the sharded reconcile
and for nothing else, and a read-back that could not be performed is a failure
rather than a pass — "we could not look" is not "there is nothing there". A survivor
names the likeliest cause, which is the trap that silently undoes the whole thing:
NF.Store autosaves the whole document every five seconds, so one browser
tab left open on the deployed site puts every purged record straight back. The
banner says so on every run, including the dry one.docs/modules/registry.md — is that every writer returns
{ok, error, message} and the screen renders that message beside
the control, "never a toast, which slides away while somebody is still reading which
field it meant". A first pass repaired five screens and named five more as debt; this
is that debt, cleared. Booking a quote was the worst of them and is
customer-facing: a quote the customer has already accepted failing to become a con
note, with a refusal that names the con note it is already booked against, says
nothing was booked a second time and tells the reader to check before re-booking
— a paragraph nobody can read, let alone act on, in 2.6 seconds. Also repaired:
cancelling a job, archiving and restoring a master record (whose refusal
could never have reached #reg-err, because that box lives inside the
record modal and is not in the DOM at all while a list is on screen), the
course lifecycle and completion withdrawal, archiving and restoring a
group, and the con note's own pickup/delivery picker, which is a fourth
caller of confirmContact() on the one screen whose whole subject is a
name and a phone number somebody is about to ring. The new-quote form's broken-relay
refusal was already written into the form and thrown at a toast; the duplicate
is gone. Every box is the existing .reg-err — no seventh pattern,
no new selector, no new stylesheet.'Could not cancel', 'This quote could not be booked' and
'That could not be done' were printed whenever the writer's own
message came back empty — each of them a claim about why,
made by the one part of the app that does not know. They now say "That was
refused, and no reason was given.", which is the repo's own wording for that
state and already shipped on three other screens. The fallback is replaced rather
than deleted, because an empty box is invisible and reads as a control that did
nothing at all. A guard in tests/suites/75-repo-guards.js pins both
halves: no screen carries any of the three phrases, and each repaired screen carries
the absence wording.toast(r.message, r.ok), which
cannot draw the two outcomes differently even in principle. Split: the confirmation
stays a toast, which is what a toast is for. Worth recording precisely, because it is
narrower than it looks — between them NF.Groups.archive() and
NF.Groups.restore() have exactly one refusal, "No such
group.", and archiving something already archived returns success rather than
refusing. That refusal is by construction about a row the list no longer holds, so
there is no row to print into; the same is true of "already archived" on a
registry list that hides archived rows, measured as zero boxes in the DOM
before the fallback existed. Both screens now check whether the message actually
landed and fall back to a toast only when there is nowhere at all to put it —
strictly better than saying nothing, and the same last resort the address book
already takes.tests/suites/75-repo-guards.js
asserts containment rather than a count — the set of screens still
toasting a refusal must be a subset of a named list — so one session could clear
five screens without the guard ever going red in between. With the list empty its
stray check now covers every screen in js/screens/. The
behavioural half is in the browser suites, which click the real control, drive the
real writer's real refusal, and assert the sentence is drawn (a computed
background, because a class no stylesheet defines ships flat and passes CI), is the
writer's own wording, survives the repaint that follows, and is still readable at
3.2 s with the toast provably gone.NF.contacts — the one store of client staff, site staff and our own
depot contacts — was kept in a single database row, and a single row has a size
limit. Measured on a realistic contact, that limit is reached at roughly
1,500 of them, and past it every save is rejected silently, on the
screen that caused it: the person typing sees the contact appear, and it is
never stored. That is not hypothetical — it is exactly what happened to the
asset service history, where 3,532 records went in and the headline feature
simply never persisted. It matters now because the FMS contact book is about to
be imported beside the address book it belongs to, and twenty years of contacts
passes 1,500 easily. Contacts are now stored one row each, like the
address book they sit in, so there is no ceiling.
Nothing on any screen changes — the same list, saved the same way. A
database written before this still works while it converts itself: the old
single row is read alongside the new ones, and the first save that touches
contacts moves what it still holds into rows of their own before the old
row is dropped, so a save carrying five contacts can never discard five hundred.docs/audits/wcag-2.2-aa-audit.md measured 42 of 342 boxes under
it and fixed none. The 42 was re-measured before anything was touched, on the
audit's own rendered probe against master, and it reproduces exactly —
though the denominator does not, at 336, which is two days of unrelated
drift and is corrected in the document rather than smoothed over. Sixteen styling
keys were raised: every .chk tick box in the app in one line, the
board's truck/trailer swap chip (414 of them, 21.65 → 24), the
sidebar section headers at one pixel short, the pool chips and legend
swatches that are also the board's drag sources, the depot fold header that was
1313 pixels wide and fourteen tall, and six tick boxes the audit never saw
because its rendered pass visited no screen carrying one. Rendered, the count
is now 3, of which two are fixed by a mechanism the probe cannot see.
Two controls on the schedule board were made bigger without being made bigger.
The criterion sizes the target — the region that accepts a pointer
— not the ink, so the 15px round note marker and the board's quiet
“Hired in?” button got a transparent ::after hit area
instead of nine pixels of height on every carrier row of a board that is dense on
purpose. The offset is -6px and not -4.5px, and that is
the part worth carrying: an absolutely positioned child is laid out against the
padding box, so a 1px border eats a pixel of expansion at each edge and
inset:-4.5px on a bordered 15px box gives 22px of hit area
while passing every static check. Both rules shipped at -4.5px in draft and the
error was found by hit-testing in a browser.
No exemption was applied — four were considered and all four refused.
SC 2.5.8's exceptions are real and they are also the cheapest way to make this
number fall, so each candidate is written down with its clause, its reason and a
dated decision, and the suite asserts every one is still refused. The
audit's own suggestion is among the refusals: it calls the eleven compliance
driver-name links “arguably covered by the inline exception”, and
measured, each is the sole flex item of its own list row and therefore blockified
— there is no sentence and no non-target text on the line, the link
is the line box. It was fixed instead, by moving the list's
line-height from 1.5 to 1.6, which is exactly 24px at the inherited
15px. The spacing exemption was measured to hold for the rig chip —
105.9px to the nearest other target — and refused anyway, because the board
it was measured on drew one rig chip and the case the code's own comment
describes is a five-asset road train stacking several in a cell at a spacing
nobody has measured. That chip is the only way to return a truck to the pool, so
it is left failing and named rather than cleared on the easy instance.
The audit says three things about target size that are not true, and two of
them are about apps it says it did not audit. “Nothing on the portal
measured under 24px” is a claim about the three portal screens the rendered
pass visited: the tick box a customer uses to accept a quote is
22 × 22. And the sentence the whole document rests on for the
driver PWA — that its targets are already asserted at 48–64px —
is about the Drive screen's buttons only; there is a 20 × 20
tick box in the cab that no assertion reaches, and a gloved hand at a roadside
is the population this criterion exists for. Neither file was in this change's
scope and both are named in the suite's budget.
The instrument is committed, and it is deliberately not the counterpart of the
contrast one. tools/target-size-check.js is driven by
tests/suites/95-target-size.js. The contrast headline came out of the
audit's static pass and could be reproduced off disk; this one came out of a
browser and no static tool can reproduce it, so the tool measures a different
and smaller thing on purpose — the size a CSS rule determines —
and says so everywhere it prints a number. It answers three ways rather than two:
a lower bound may condemn and may never clear, so 255 of 291 keys are
indeterminate because line-height:normal is a property of the
font file and not of any stylesheet, and not one of them is folded into a pass or
a failure. Calibrated at the base commit against the audit's own rendered figures,
it derives an exact height for three of the ten rows and agrees with Chromium
on all three to within 0.05px.REQ-MC-LICENCE, whose sentence reads
"MC / HC driver licence"; the eleventh was refused by nothing and
mentioned nothing. The con note now carries a What this rig requires
panel, built on NF.Compliance.rigAdvisories(), which reads the rig
off the vehicle's own recorded type through NF.Pools.needsOf and
answers it against the register. It is the first caller of
NF.Pools.qualified() at the departure decision rather than at
allocation.docs/decisions/601-voc-gate.md measured adding
gate:'puDep' to REQ-VOC-ROADTRAIN and refused it: on
appliesTo:{assetTypes:['prime']} it fires on 17 of 36 crewed
con notes of which only 10 are road trains, it consults a register
holding 0 credentials, and it does not reach the 7 of 20 drivers
for whom no gated requirement resolves at all. So under
docs/modules/crew.md's warn, never block the gate now
sees the road-train VOC and says so, with the soft block, the
120-second acknowledgement and the permanent override record all untouched. One
assertion in tests/suites/08-compliance.js pins the ungated state
as a decision, and it is the only thing that reddens if somebody takes the
decision the other way.RT2 and RT3 both carry
requirement:'REQ-VOC-ROADTRAIN', so the reverse lookup inside
NF.Compliance.capabilityFor had two answers and silently took the
first — every question about the triple was resolved against the double,
in both directions: a recorded triple read not-held on a triple, and a
recorded double read held on one. It now accepts the class as a third
argument and the rig advisory asks about the class the work needs. The default
is unchanged on purpose, because gateBlockers asks about a
requirement and its wording is frozen verbatim into permanent override
records. Whether a double and a triple are one competency or two is still
OQ-601-A and is not decided here.NF.QUALS entries carry no
requirement — is dimmed and dashed, because that one we
know exactly and chose not to keep; a claim nobody has checked is neither, and
never green. tests/manual/compliance.js reads the computed
background-image for both, so a hatch that shipped flat would be
caught. A negative derived from splitting a free-text licence field on
punctuation is reported as an unknown and not as a no — measured on
8 of the 11, where NF.Pools.qualified() answers a firm
not-held and capabilityFor will not.rateSource (WA benchmark / internal estimate / Northfleet's
accounts), who set it and when, and an empty-until-checked rateConfirmedBy /
rateConfirmedAt. Saying where a figure came from and confirming it are two separate
controls on the Types editor, because they are two acts by (possibly) two people — and editing
either rate clears both, since the provenance described the previous number. Nothing marks an
existing rate as confirmed: all ten shipped rows seed as benchmark, which is a true
statement about them, and zero of them arrive confirmed.costPerDay, costPerKm or litresPer100km, ever — but the
sentence could never be retracted, so the day Northfleet's own rates are typed in the same
captions would go on calling the business's own accounts a WA benchmark, over a leadership-facing
figure of $2,356,680. Each caption now comes from the rows the figure was actually built
from, names the types it is unsure about rather than quoting a percentage, and retracts itself
when the rates are confirmed. Reverting #/lostrevenue's and the overview's fixed
sentences reddens 2 assertions in tests/run.js and 3 checks in
tests/manual/asset-register.js.tests/run.js;
drawing an unconfirmed rate flat reddens 2 there and 3 computed-style checks in
tests/manual/asset-register.js; pre-ticking the seed as confirmed reddens 11.GET /fleet/gpswox
answered {at, count:0, devices:[]}, byte-identical, for four
different facts: a deployment that has never had GPSWOX set up, an instance that is
down or timing out, a credential the instance would not take, and a fleet that
genuinely carries no tracker. So no screen could draw more than one sentence over
them, and diagnosing a quiet second GPS source meant guessing, checking SSM by hand,
then re-deploying to rule causes out one at a time. Every answer now carries the same
ten keys and one of nine named reasons — not_configured,
credential_unavailable, misconfigured, login_failed,
timed_out, unreachable, instance_error,
unreadable_response, unknown_error, plus a completed poll
— following odoo.js's census()/diagnose()
rather than a per-row tally, because one unpaginated call has one whole-read verdict.
count is null, never 0, for a read that did not happen, since a
zero asserts a fleet size nobody measured; configured is three-state
(false / true / null for a credential store that
itself could not be read); and devices stays an always-present array, so
an older cached browser bundle is unaffected.verdict a screen can switch on, so a tenth failure cannot
read as a working feed. Beside the nine reasons, each answer carries
verdict: ok / not_configured /
unavailable — the vocabulary odoo.js already uses. It is
derived in the module rather than in the screen deliberately: a screen classifying nine
codes itself would fold a tenth, added later, into whichever branch its
else happened to be, so an unrecognised reason answers
unavailable by construction. not_configured is its own
verdict and is never folded into unavailable — a deployment
nobody ever set GPSWOX up on is not an outage, and sending an operator to check an
instance we were never pointed at is the wrong phone call.get_devices
query string, so a transport error's own message and an instance's error body can both
echo it back — and nothing derived from either is ever put in a message. Every
sentence is written in the module; the only amplifications that travel are an HTTP
status and an error class name. backend/tests-gpswox-reason.js
(568 checks, over 16 scenarios, re-run under three timezones and compared
byte-for-byte) drives two fixture payloads that carry a self-naming sentinel and
asserts it survives into neither the answer nor a captured log line. Verify-to-fail,
measured: collapsing not_configured back into the empty success shape
reddens 14; emitting count: 0 on a refusal reddens 44;
folding not_configured into unavailable reddens 4.deploy.yml's first step is
node tests/run.js, so nothing was published between runs 389
and 393 — the first of them 134e566, the very commit
that added the guard. The cause was not the guard's rule but who ran
it: three workflows run that suite at two different checkout depths.
deploy.yml sets fetch-depth: 0 deliberately (its
build-version step counts merges since the last release), while
ci.yml and deploy-backend.yml take the default of
1. The history scan therefore read one commit in CI and
1,264 on a deploy, and found three historical commit messages CI was
structurally incapable of seeing — so the suite could never have been
green on a full-history checkout. The verdict is now depth-independent by
construction and the coverage is printed: measured on one tree at three
depths — 1,264 commits / ledger 5 of 5, 310 /
2 of 5, 1 / 0 of 5 — all three pass.depth <= 1) rather than measured, so a shallow clone holding
273 commits reported itself as a full read. That is not
hypothetical: a working clone in the session that fixed this carried a
.git/shallow with 6 grafts, and the suite was green there
and told nobody anything. It now asks
git rev-parse --is-shallow-repository, and a git too old to answer
leaves the fact unknown rather than assumed either way. The ledger of
grandfathered commits is also validated per entry against what was actually
scanned, instead of all-or-nothing behind a depth threshold: the three
commits added here sit 411, 632 and 769 commits behind
HEAD, so the old form demanded shas a shallow clone cannot hold and reddened
3 assertions of honest work on every shallow worktree, while at depth 1
it skipped silently.tests/run.js checks out at full depth — today
deploy.yml — stated as containment, never a count, so
adding a workflow cannot redden it. Deleting the setting reddens 1. It
reads the key at the start of a line, not the string anywhere in the
file: deploy.yml explains its own fetch-depth: 0 in a
comment two lines above the real one, so the first draft was satisfied by
prose that survives deleting the setting and measured green against a
deploy.yml with it removed. A guard a comment can satisfy is the
provenance check a hardcoded answer passes.f1e14f2 (Closes #353, #387, #388) and
0a5c662 (Fix #297, #298, #299 and #300) both merged
under PR bodies that listed every issue one per line, so GitHub's linker
bound all of them — #387 and #388 closed 1–2 seconds after
their merge, and #297–#300 all carry closed_by_pull_requests.
The third is the real defect #638 exists to catch: c1baf4a
(Closes #213, #215, PR #221) — #213 closed 1 second
after the merge, while #215 carries no closing PR at all and was closed
by hand 9h55m later, having sat open overnight with its work already
merged. None of the six candidate issues is still open today. Merged
history is not rewritten to fix a commit message.%h abbreviates to a length the repository's object count
chooses. The depth fix merged as b3bb71f and deploy run
403 failed on 2 assertions while the identical commit ran green at
full depth on two other machines. git log --format=%h picks its
width from how many objects the repository holds —
core.abbrev is unset, so auto — and
fetch-depth: 0 fetches every branch and tag (257+ here), so
the deploy checkout crossed the threshold and emitted 8 characters where
an ordinary clone emits 7. All five 7-character ledger keys stopped
matching at once. This is the same defect as the one just fixed one layer
down: a verdict that depended on the checkout — object count this time
rather than depth. The scan now reads %H and the ledger matches a
prefix of the full sha, any short form printed is sliced from that
sha rather than asked of git, and a key that prefixes two commits is refused
as ambiguous. Verified in four checkouts on one tree — %h=8
full, %h=7 full, %h=7 shallow at 272 commits, and
depth 1 — identical verdict in all four.--format=%H…, a string that appears in the assertion
doing the reading: it was reading itself, and reverting the defect
reddened 0 in both environments. The repair was to stop reading source
text at all — the log format is a named constant that the scan and the pin
both hand to git, and the pin asserts on what git returns. Reinstating
%h now reddens 1 on any clone; restoring the whole
shipped-and-broken form reddens 27 where %h is 8 and
21 where it is 7 — caught in the environment where it previously
hid. The pin drives the same matcher the scan uses, hoisted to file scope
for that reason, because a re-implemented copy can be wrong in step with the
original.run-backend-suites.sh — which read the exit code and nothing else —
counted it in the N of N headline. That was measured while #644 was
being verified: a deliberate hang in the Postgres telemetry suite read 0
assertions red and reported green, where the same break, once the suite could
notice, reads 20. A wrong verdict rather than a wrong number, and
unreadable from the output too, because there was no assertion count, no total
and no reached-the-end marker anywhere in it. Reproduced here on a real suite:
tests-alerts.js hung a third of the way in exits 0 after
67 of its 184 assertions, and the old runner passed it. Every backend
suite now says, in its own words, that it reached its end
(backend/suite-guard.js, two lines), the runner refuses a
suite that exits 0 having said no such thing — so forgetting the pattern is red
on the first run rather than silent for months — and the same hang is now scored
a failure that names itself. The obvious mechanical detector was measured and
rejected: 30 of the 49 suites exit with an unsettled promise while
completing perfectly normally, so an async_hooks check would redden
thirty green suites.npm run backend now prints what each suite actually asserted, and
a total. 7,260 assertions across 48 suites today, per suite, on
its own line — so a suite that quietly halves its own coverage shows up as a
changed number in the diff of an ordinary run, which is the one thing
49 of 49 could never say. A suite spread over several processes has
its markers summed rather than the last one taken, so
tests-backup.js reports 1,568 (392 checks × 4 timezones) with nobody
doing that arithmetic by hand. Two suites report UNKNOWN rather than zero
and are named in the summary: they are waived by name with their reason, because
a #644 branch holds them, and we did not ask is not the answer is
none.backend/tests-runner-early-exit.js copies the shipped
run-backend-suites.sh byte for byte into a throwaway tree and runs
it — not a reimplementation — over five broken fixtures, asserting that a
hang with the guard fails on its exit code, a hang without one fails on the
missing marker, an ordinary red suite is still told apart from both, a waiver
covering a file that has gone fails the run, and an unknown count is never folded
into the total as zero. It also pins the premise, so the test cannot go vacuous:
the unguarded fixture really does exit 0. Reverting the runner's refusal
reddens 6 of its 37 assertions, removing the guard's sentinel reddens
2, and both together 9.docs/decisions/absence-as-success.md so the next person knows which
screens have been checked. The distribution is the surprise: the screens with the
most empty-state cards are the best built — 15-compliance.js
carries 24 of the 96 and every one is correct — and every defect found was one
line inside a bigger render, a chip or a badge or a tile, where nobody was thinking
about the card as a card.NF.Geofence.subscribe(), which calls back
synchronously with whatever the adapter is already holding — on a live
deployment's first paint, nothing. So the first frame a yard monitor drew, every
time it was opened, was "No tracked assets in the Port Hedland yard right
now", before GET /fleet/live had answered once; and it drew the
same sentence again after every failed poll, because a failure leaves
connected:false and the subscribers are called regardless. The
in-yard count is worse: the markup ships a deliberate em dash and the paint
overwrote it with a 0 nobody had counted. Keyed now on the read
— NF.FleetDynamics.connected, with lastError
separating we have not asked yet from we asked and were refused,
both hatched and the second naming the error. Trucks the adapter is still holding
are still drawn, with the caveat above them rather than instead of them.GET /state, so on the deployed shape all five printed at once, in the
green box, on a page a general manager reads. The file's own comment already
named this defect and guarded the wrong half of it: #655 refused to write
NF.assetDocOutlook?…:[] because "an empty list here would print a
reassuring 'all current' for a read that never happened", and left the case
where the reader is present and the register is empty — the identical
sentence by the identical route. Each row now carries the population its own
filter ran over, three states (unread hatched, nothing-to-check dimmed and named,
the original claim with its count appended), and a signature that requires
the denominator so a row added later cannot fall back to the old green. Reverting
it reddens 21 assertions.GET /odoo/assets currently returns 0 service records; and the
POD ageing card printed a green 0 in two tiles directly above its own
sentence saying "That is an empty system, not a fleet with every POD
captured". Each now names which empty it is, and the healthy wording is
unchanged to the byte in all four. Reverting them reddens 8, 16,
13 and 4 assertions respectively.NF.ldConcerns() wraps its
unanswered-question test in if(tpl), so a submission whose form
definition cannot be resolved runs no validation at all and returns the
same empty array a fully-answered check returns — and the sign-off panel
printed "Nothing flagged — declarations complete, no STOP answers, photos
attached" in green, on the screen where somebody in the office puts their name
to it. The claim is now withdrawn by name and hatched when the definition is not
on file, naming the id it could not find; the photo half, which can be
derived, is still counted and reported, so the refusal is scoped to the half that
could not run. Reverting it reddens 7 assertions.overpass-api.de — a volunteer service that limits requests per IP address,
and build machines share IP addresses — so a real answer arriving late overwrote the
fixed limit the check had set, and the failure text was the cab's own honest "No posted
limit mapped here." The app was right every time; the check was measuring the network.
Reproduced deliberately rather than argued: with the real answer delayed to 2,400 ms
the old check fails, and at 600 ms and 2,000 ms it passes. No browser check now
reaches that host at all, and the limit each one tests is set by the test.CLAUDE.md line summarising #336 claimed a bare
0 hits for loadplan/deckPos/trailerPos
and named driver/ in its scope, while
frTrailerPos sits in driver/driver.js on 10 lines
— so the spine and the brief it summarises disagreed once the brief was
corrected. The zero is right and always was: run as printed, case-sensitively
across js/, css/, driver/,
portal/, backend/src/ and every .html, it
returns 0 at origin/master and at this branch's head, and the
brief established the same zero at its own declared base. Add -i and
the identical scope returns 13 lines, every one of them in
driver/driver.js and every one frTrailerPos (10
lines) or frLoadTrailerPositions (3): a trailer's last-reported
GPS position folded out of GET /fleet/live, declared at
driver/driver.js:2493, so a latitude and a longitude on a public road
rather than a deck position on a trailer. Anyone who normalised the spine's
command would therefore have found 13 hits under a line claiming zero and could
reasonably have concluded the finding had collapsed. It has not, and the entry now
says so in one clause: the case sensitivity named as deliberate,
frTrailerPos named as the near miss so it is recognised before it is
found, and deckPos/onDeck recorded as 0
case-insensitively across every file type — which is a stronger
support for the recommendation (two artefacts, because a deck position exists
nowhere) than the wording it replaces. The old number is kept rather than swapped
out, because the previous unqualified sentence is the reason a reader would be
misled and removing it hides that. No code changed and no assertion moved:
node tests/run.js is unchanged at 16,138, and
tests/doc-links-check.js, npm run checks and
npm run wiring each exit 0.GET /state now takes a declared scope and
?scope=portal answers a projection: 15 collections of the
49, built key by key from a table rather than filtered out of the document, so a
field added to the driver register next month is withheld by default instead of
published by default. Measured on the app's own generated document: 50 keys /
232,588 raw / 28,636 gzipped becomes 17 / 84,290 / 13,442. What remains is
88.7% three collections — tmsJobs, consignments
and quotes — holding every account's rows rather than this
account's, and narrowing those by row needs a per-customer credential
this system has not got, which is named as the next slice rather than claimed.id + name — never phone, never
licence — and a vehicle as id +
type + colour, never the rego, which is the
same field #648 refused to project and #654 measured as the thing a kept feed
id would have leaked. The archive flag rides where the stored row
carries it and is invented nowhere, because active !== false is the
test everywhere and a row given true is a claim nobody made. The
tracking screen still names the driver and draws the truck's marker off those
fields, which is asserted in the browser rather than assumed — a
projection that broke the page the link exists for would have sent the field
list back to be re-argued.drivers, vehicles and customers are
merged by id with Object.assign, so a projected row landing over a
row cached before the change would have left the phone and the licence
behind for as long as the localStorage copy lasted — which is
the whole at-rest half of the defect, arriving by the back door.
apply() now does a field-level replace and a row-level merge
for a field-projected collection: a key the answer does not carry is deleted
from the local copy, the row object is the same object (open views hold
references) and no row is ever dropped, so a short read must never blank a
collection this browser already knew about holds exactly as before.portal read the other way means everything, so an
unrecognised name is a 403 rather than a widening; that is the branch that
actually protects a page. The undeclared read is the honest part: five operator
scripts in backend/ read this route with the shared token, no scope
and a GET→edit→PUT shape, so refusing them is a script that writes
back a truncation. The refusal is therefore built and switched off behind
STATE_SCOPE_REQUIRED, with the suite driving it on so it is tested
rather than described, and the follow-up is one variable rather than one
rewrite. It also makes the deploy safe in both orders, which the two GPS
scopes were not: the route only narrows for a caller that asked to be narrowed,
and the asking and the write-side omission ship in the same file.backend/src/authorizer.js itself says means only
"this request came from the deployed site" — so anyone holding it
can still ask for scope=full with curl. What is fixed
is that a page declaring the portal scope can no longer be handed the
other 34 collections, and the portal is the only app that declares it. The
shared-token weakness is unchanged, known and separately tracked. The
dispatcher and the cab declare nothing and their answer is byte-identical,
because a driver in the Pilbara works off that cache.movement unknown · no readable fix time, and hatches the figure in
the 135° hatch this app uses everywhere for we do not know. It is
deliberately not struck through the way a frozen fix's number is: a strike
says read this as an echo of an older reading, which is a positive claim
about the reading we cannot make either. And the two states keep their own
sentences — the row's tooltip says the fix cannot be dated, and never
"from a position that has not changed", because that is a specific
observation nobody made.<datetime> it cannot read rather than stamping the fix with
now — that refusal is right, and it is what stopped a guard being fed
a manufactured input. But the dispatcher's shared status classifier then fell
through to the plain speed reading, so an undateable fix rendered
identically to one dated four minutes ago: a bare Moving or
Stationary, with a heading beside it, on the Live Fleet list and every map
popup — and #160 then added a "last moved …" line under
the stationary ones, which is the branch written to stand down for exactly this
case. Measured on the live feed on 4 September 2026, backend deploy run
#127: 377 of 705 rows carried no usable fix timestamp. Two states
where this app's rule is three. An undated fix is now its own answer
— movement unknown · no readable fix time, drawn in the
135° hatch this app uses everywhere for we do not know, the same mark
#/assets's Age unknown badge already carried for this exact
fact — with one sentence naming what is and is not known. It is folded into
neither word, and it is not dressed up as stale either: there is no
age to age it by, and inventing one is what #159 removed. The speed figure is
deliberately not struck through the way a frozen fix's is, because
"this is an echo of an older reading" is a positive claim about the reading
we cannot make either — we do not know it is old, we know we cannot date
it. Three inputs now give three renderings, each asserted with the other two
absent, and the hatch is asserted off the pill's own computed
background-image rather than its class name: a class no loaded stylesheet defines
ships a flat pill and passes a class-name check.KAR/KTA, Newman NEW/ZNE,
Perth (Hazelmere) PER/HAZ — and the depot form
that maintains them rewrites code on edit while leaving
id alone, so two rows can end up sharing a code that no row holds
as an id. The classifier behind both the skills-matrix depot filter and the
group editor’s scope boxes took the first row whose code matched.
Measured end to end in a browser: a group scoped to Karratha under the code
KTA ticked Port Hedland’s box and the next Save wrote
the scope back as ["PHE"] — a different yard, silently, with
nothing on screen saying so. A silent wrong write is worse than the nought this
issue was filed about, because a nought is at least visible. A value that is the
code of two or more yards and no yard’s id is now its own answer:
refused, drawn hatched, with every candidate named and the id each is filed
under, and in the group form kept exactly as stored and ticked so a
save preserves it rather than repairing it to a yard nobody chose. Tick the yard
you meant and untick the hatched box — the form will not choose for you.id; the code is a label, read but
never written and never guessed between. Stated once, in the shared
classifier both screens go through, because the repo had already been bitten by
treating the two as interchangeable. Every matcher keys on the id
(NF.get.depot, personDepots, the escalation scope match,
every route label, every seeded scope), so a stored scope value is resolved
by id first, then by code — which is what makes a migration
unnecessary, there being no migration runner in this repository — is
written back as an id, and is refused where a code names more than one yard. A
value that is one yard’s id and also another yard’s
code is not refused, because the id decides it the way every matcher already
does; it is disclosed in the group form instead, naming both yards and
which rule won. Whether a depot should carry two fields at all remains
#603’s question and is not answered here.TOM and BUN). The scope boxes labelled a yard
name plus an optional code and omitted the id entirely, so those two drew
labels a supervisor could not choose between — on the one control whose
tick decides who a course reaches. The form now uses the same single label
builder the skills-matrix dropdown does, so every box carries the id it writes
and the two screens cannot name a yard two different ways.GET /audit now resolves a requested date range against each
row's own ts — epoch milliseconds, present on every row and
free of any timezone convention — so one convention applies at the read
edge and the day shown beside a change is the day the filter selected it on. Not
a single stored row was edited: the audit log is the one record in this system
that exists to be unedited, and the stored day is now a convenience field beside
the timestamp that decides.undated individually, and counted in the read's own reply
alongside a sentence saying what the count means. The count is always a
number, so "we looked and every row on this page is datable" is a
real answer rather than an absent field somebody has to guess about. A malformed
date in the request still degrades to the older string comparison rather than
failing the route, and the storage layer still refuses outright to run an
audit query whose filter it does not fully understand, rather than quietly
reading more rows than were asked for.SessionStart hook prints can no longer lose its
“as last fetched” qualifier quietly. The new block was guarded against
crashing — tests/session-start-check.js ran the hook and asserted valid
SessionStart JSON over 200 characters — and not against the change that would
make it harmful, which is dropping the qualifier off a distance claim: a session told
“level with origin/master” against a ref somebody fetched seven
hours ago is worse off than one told nothing, and the hook never fetches. Measured before this
change, grep -c "AS LAST FETCHED" tests/session-start-check.js answered 0. The
check now requires the brief to name a tree kind (its own linked worktree, or the shared
main checkout), to carry a HEAD that is verifiably this tree's commit rather than a
constant, and it holds that no line anywhere in the brief may claim a distance from
origin/master without AS LAST FETCHED and either a fetch age or the
stated unknown. It also runs the real hook in two hostile environments: with git
absent from PATH (a symlink farm of everything else on PATH), where the
hook must exit 0 stating PROVENANCE UNAVAILABLE and must claim no tree, no HEAD and no
distance — unavailable must never read as clean, level or ours; and with a stat
that answers with filesystem information and no mtime, where it must claim no fetch age at
all. Verified to fail, measured: deleting the qualifier from the hook reddens 8
assertions; keeping the words and dropping the age also reddens 8; removing the tree-kind
clause 3; degrading to unavailable while git answers 4; removing the HEAD, or
hard-coding it, 3 each; and an unavailable block reworded to read as clean 4. Every
assertion demands a positive statement, so a reworded hook fails this check rather than quietly
ceasing to match it. Day-independent by construction: the fetch age is asserted by shape
(12m ago) or as the stated unknown, never as a value (#657 R-2).origin/master
remote-tracking ref, the hook then correctly states the distance unknown, and the qualifier
code is never reached — measured, deleting AS LAST FETCHED from the hook goes
uncaught in that environment while reddening 8 assertions in a tree that has the ref. So the
check builds a throwaway repository, sets refs/remotes/origin/master to make the tree
level and then one commit behind, and holds both sentences to the qualifier. A plain
git init tree is also not a linked worktree, so this is the only place the shared
main checkout wording gets exercised when the check itself runs from inside a worktree —
which, per #657, it should. The GNU stat -f hazard is assertable and asserted, with
what it does and does not catch written down rather than implied: a per-number sanitiser on the
BSD fallback (grep -o '[0-9][0-9]*') reddens 2 and prints a fetch age of
19,936 days ago read straight out of Blocks: Total: 66053021, while a
whole-line tr -dc sanitiser reddens 0 — not because of the digits-only
guard but because the NOW -ge NEWEST sanity test drops it, so two guards hold that
line and neither is separately provable (#657 R-2).CLAUDE.md's #657 entry were falsified by #657's own change, and now
read as corrections. The entry stated in the present tense that
.claude/agents/developer.md:136 “says you may be in your own git
worktree” and that the worktree rule “is conditioned on more than 4
agents”. Both stopped being true in the commit that closed them: that line is now
imperative and unconditional, and the threshold was deleted rather than raised, because it
was met twice over on the day it failed — the waves were 10 and 12, so the rule
was satisfied and two commits still landed on other agents' branches. The measurement and the
reasoning are kept and only the tense and the disposition move, because the value of that entry is
the numbers under it (#657).SessionStart hook printed the branch name and
nothing else about git state, so a session could not tell its own worktree from the shared main
checkout that ten and twelve agents had been sharing. It now prints the HEAD, whether
this is a linked worktree or the shared main checkout, and the distance from
origin/master. The distance is the part that had to be built carefully: the hook does
not fetch, and origin/master is a local remote-tracking ref shared by every
worktree, so the count is printed AS LAST FETCHED with the fetch age beside it and never
bare — a session told “you are level” against a ref fetched six hours ago
is worse off than one told nothing, which is the exact confident-wrong-answer the line exists to
prevent. Where the age cannot be dated it says FETCH AGE UNKNOWN, because an absent
qualifier reads as a fresh one. FETCH_HEAD turned out to be per-worktree while
the refs it moves are repo-wide, so the age is the newest across the common dir and all 73
linked worktrees — one stat. Measured on this container, best of fifteen
interleaved against master's version: 22 ms → 35 ms, of which 9 ms is the three
reads (3/3/3) and the rest process spawn; date was dropped for bash's
EPOCHSECONDS to save one. It can never fail a session: every git read is wrapped,
every value is checked to be digits before it can become a timestamp, and a total failure degrades
to a stated PROVENANCE UNAVAILABLE rather than a silent omission. Verified by running it in
a linked worktree, in the shared root, in a non-repository directory and with git
removed from PATH — exit 0 and no stderr in all four (#657 R-2).git commit no longer says
“may”. .claude/agents/developer.md read “You
may be in your own git worktree” while three dispatcher-side documents said
must — and those three are read by the coordinating session, not by the agent doing
the committing. It is now imperative and carries the command. Recorded as a correction with what
it replaced, because the measurement is the argument: the strongest written statement of the rule
landed on master at 01:23:29 UTC and 13 h 52 min later two commits went onto other
agents' branches in the shared checkout. Two habits were promoted from good practice to the reason
recovery was possible — explicit pathspec staging (both mis-branched commits were staged
perfectly and still landed wrong, so staging does not save you from the branch pointer) and
committing early (an unstaged edit writes no object, so destroyed and
reverted leave identical evidence, which is none) (#657 R-3)..claude/agents/README.md and
.claude/agents/product-manager.md. A dispatched agent cannot count its siblings, so
it could never evaluate its own trigger — but the sharper reason is that the condition was
met, twice over, on the day it failed: the two waves were 10 and 12 agents,
so the rule was satisfied and the shared root's HEAD still moved 12 times across
8 branches in 29 min 42 s. Two agents in one tree is already the hazard; there is no count at
which sharing is safe. The PM is also asked to create the worktree itself and make the
cd into it the brief's first block — the half of the remedy available without a
harness change — and to read a blank Worktree column as a dispatch it has not
given a tree to (#657 R-3).docs/agent-dispatch.md had a full section on the tree, the index and the branch
pointer, and every word of it was about writing; the word “measure” appeared in
a worktree context nowhere in the file. A reported number is a claim about a tree, so it now has
to name one, and a return must say which tree and which commit it measured in — the
house style already demands the code version (14 of 28 briefs record a base commit) and the
working tree is a different fact that 1 of 28 records. Three measured consequences are
recorded with it: a grep at a root one commit behind answered 0 files
for a file that had existed for 26 minutes, a brief's line numbers were first derived off a file
carrying another agent's uncommitted edits (real numbers, of a real file, that no reviewer can
reproduce), and preflight --actual in that tree reported six changed files
belonging to two other issues. It is a rule rather than advice because it is unfalsifiable
afterwards: three of four wrong suite numbers from one afternoon cannot be measured today,
so the tree named in the return is the only provenance there is (#657 R-3).#657 reports that a grep at the repo root
answers about code that no longer exists. The brief measures that (its figures re-verify: 0
matches against 6) and finds it is one third of the cost. The other two thirds are on the
write side, and the rule against them was already on master —
docs/agent-dispatch.md carries the git worktree add command, the costs
and a table of which incidents a worktree prevents — landing 13 h 52 min
before two commits went onto the wrong branches in the same tree. Three checkable reasons it did
not hold: the imperative sits in the dispatcher's documents while the agent's own says
“you may be in your own git worktree”; the rule is conditioned on
“more than 4 agents”, a number a dispatched agent cannot see (the two waves
were 10 and 12); and the permission allow-list pre-approves
git checkout -b and git switch while git worktree appears
0 times in it, so parking a shared HEAD on somebody's branch is frictionless
and the remedy prompts. Recommendation is ordered tooling, hook, then the free
documentation, with the harness option named as not in this repository's gift. Decider named.rebase --onto. Nothing was lost, and the brief names why rather than
calling it luck — explicit pathspec staging kept both mis-branched commits
lease-clean, so recovery was mechanical. One alarm that read as a data loss was neither provable
nor real, and the asymmetry under it is the durable lesson: an unstaged edit writes
no object at all, so destroyed by another agent and reverted by its owner
leave identical evidence, which is none, while the same tree holds 2 dangling blobs of
staged-then-abandoned work that are still recoverable by hash. A commit, not isolation, is what
gives an edit an owner.tests/run.js writes no
artefact, so a count quoted in a return is unverifiable once the session ends, and only one of the
four survives anywhere (16,105 / 3 FAILED against a clean 16,108, recorded in
#594's brief, its three reds belonging to a suite neither agent owned). Which agent
saw preflight --actual report six foreign files is equally unattributable, because
that mode records nothing about its own invocation — though the six reproduce exactly. Total
time lost is not estimated in either direction. And the staleness is not a fixed quantity:
the hazard is a step function at one merge, not five, and the root moved under this
brief, reading level four minutes after it measured one-behind. One counter-incident is kept
in deliberately: an agent in its own worktree ran git checkout -- . and
discarded its own work — a phrase appearing 0 times in docs/ or
.claude/, where every destructive-checkout warning is framed as a courtesy to
somebody else. A worktree removes the cross-agent hazard, not the self-inflicted one.NF.Credentials.file({person:'', requirement:'REQ-MEDICAL'})
answered {ok:true} and put a row in it — the guard read
person == null, and '' == null is false. A row filed
against nobody belongs to no person, so it never expires against one, never
appears on their record and is never found by the overdue, TNA, site-readiness
or evidence-pack reports, every one of which reads the register by person — and
it still counts in any count of credentials held. It cannot be retired by the
normal path either, because archive-never-delete retires a record against a
subject and there is no subject. It also reached the wire, since
PUT /state pushes credentials like any other
collection. Now refused, with the writer's own sentence.file(), waive() and
GateOverride.record() file against the same two fields and each
carried its own copy of the check, which is how they came to give two answers to
one input. The test is extracted to NF.blankRef and is wider than
the === '' it replaces, because measuring the siblings turned up two
holes all three shared: whitespace-only passed everywhere, and so did an object
or an array — String({}) is the literal
[object Object], a credential filed against a person of that name.
A finite number is still an id, 0 included, and nothing is trimmed on the
way in: a blank is refused, and normalising an id somebody typed stays a data
decision nobody has taken. Measured: restoring the original
file() guard reddens 34 assertions, undoing the extraction
across all three writers 55, dropping the whitespace trim 12, and
refusing a numeric id — the over-tight version of the same fix — 9.
Whether any such row exists in the deployed register cannot be measured
today: GET /state answers 403 to an anonymous caller, the shared
token is deliberately not in the repository, and POST /db/query is
sysAdmin-only.NaN, every comparison against
NaN is false, so the expired / due soon / current cascade
fell through to its healthy last branch. The one input that means we cannot
read this produced the most positive answer the function had. It is now its
own state: hatched, worded Expiry unreadable in the cab and on all three
dispatcher surfaces in the same words, with the value printed as recorded so it
can be corrected, and counted in its own bucket on the expiry-outlook card
rather than in any of the four bands. It is deliberately not read as
expired — that would ground a truck on a typing error, and it is as much a
claim as the green pill was. The entry form's own refusal is unchanged; this is
a floor under it for the rows that never went through a form, which is a
document synced from an older client, one predating the form, or one from any
other source. A release has to be cut for the cab half to reach a tablet:
the driver app's offline shell is precached under a cache name stamped from
APP_VERSION at release, so merging alone leaves installed cabs
drawing the old pill.#/asset/<id>
was the surface left behind, because it gated the tile’s status words, the
tile’s mark and the whole Fix row on frozen and
then on undated, and a stale fix is neither. Measured on the
OLD8 fixture — 80 km/h, last seen three hours ago —
the page read Speed & position 80 km/h · -31.870, 115.940
with no words, no mark and no caveat anywhere on it, rendering identically
to a 60 km/h fix taken a moment earlier but for the digits. The tile now
prints the shared sentence (stale · last seen 3h ago —
fixStatus()’s own words, asserted equal to the
dashboard’s, Live Fleet’s and the popup’s), and the
Fix row — the one row on this screen that carries a caveat
— renders and says the unit has not reported since, so the figure above is
the last reading it sent rather than what it is doing now. It says the
truck may have parked or may still be running with the unit off the air, rather
than picking one, because this screen cannot tell which..spd--expired is defined
in css/core.css beside .spd--echo, so the next surface
finds it while looking at the mark it has to choose against — #670 had to
apply the same treatment inline because its lease did not reach that file.
Dimmed and dashed is right here for the same reason it was right there: the
hatch would claim we cannot tell what the unit said (we can — it said 80)
and the strike would claim somebody watched the position stand still (nobody
did; we have heard nothing at all). This screen has a standing rule that the
mark goes on the note and never on the figure, and both halves of that
rule were re-measured rather than carried over, because both were argued
about .badge--hatch: .badge’s caption font-size
collapses a 20px tile figure, and .spd--expired sets no font-size at
all — measured, the figure stays at 20px with the mark on it —
while hatching a number would be a claim about the reading, and dimming it
is a claim about its currency, which is the one thing genuinely in doubt.
The live-status table keeps its own rule and is untouched: it prints what the
unit sent, and the Fix row beneath carries the caveat.
⚠ The dashboard still holds #670’s inline copy of the same
declarations — a duplicate definition, which can drift while both
look authoritative — so until it is moved onto the class,
tests/manual/since-moved.js asserts the two surfaces’
computed colour and border equal, and a drift is red rather than invisible.st.frozen || st.undated, and a stale fix is neither, so it
fell through to the plain reading. Measured on the fixture in
tests/manual/since-moved.js, with 80 km/h on a fix last seen
three hours ago: an 80% bar in the asset's own colour, an unmarked number
in the live blue, and no words whatsoever — while Live Fleet's list
and every map popup said stale · last seen 3h ago about the same
asset. This is the third state to be fixed at the same three lines (after the
frozen fix, then the undated one) and the worst of them, because a stale fix
carries a plausible speed: an undated one carries an undateable number and a
frozen one an echo, so this row was not merely silent, it was confidently wrong.
The row now draws no bar and prints the words — and the words are
fixStatus()'s own, the same function Live Fleet and the popup read,
asserted equal across all three surfaces rather than matched separately on
each, so none of the three can drift alone..spd--echo) because
read this as an echo of an older reading is exactly what was observed, and
an undated one is hatched (.badge--hatch) because we cannot
date the reading at all. Neither fits a stale fix, and borrowing one would be
worse than adding a third: the hatch would claim we cannot tell what the unit
said (we can, exactly), and the strike would claim somebody watched the position
stand still (nobody did — we have heard nothing at all). Leaving it
unmarked is not neutral either, which is what shipped: in
.veh__spd's confident blue an 80 beside a 60 reads as the faster
truck to anybody scanning the numbers column before the words. So it is this
app's dimmed and dashed — we hold the reading, we know its age
exactly, and the only thing we decline to present is its currency. The
treatment is not new to the app, only to this surface:
.ftg-fig--stale states it in almost these words, and
.fleet-swms__item--retired and .terms-none draw the
same language. It is applied inline because every one of those is
prefix-owned by another screen and this list's own marks live in
css/core.css; a side effect worth having is that the trap behind
this issue's fourth criterion — a class no loaded stylesheet defines, which
ships a flat pill and passes CI — cannot apply to an inline rule at all.
The mark is asserted off the computed border style, border width and
colour, and the three marks are separated from each other by what actually
paints rather than by class name.moving: false for a stale
fix while this sort read v.speed directly and disagreed with it.
Inside the band the order is the asset number and never the expired
figure, for the reason the undated band was given: ranking two speeds neither of
which describes now is the same claim in smaller print — so a stale 80 does
not outrank a stale 0, and a stale row's band is not read off its own speed
either, or an expired 0 would be filed as measured as not moving on the
strength of a reading that expired hours ago.backend/tests-store-postgres.js holds an
in-memory PostgreSQL that the real adapter runs against, and its whole reason
for existing is that a stub more permissive than the service is this repo's
most-repeated bug. Its audit WHERE reader split on
' AND ' blindly and returned true for any fragment the split
could not parse — so a clause it did not understand was a clause
dropped, and a dropped WHERE clause is an over-broad read:
the same silent widening the adapter's own translateAuditFilter
throws rather than emit. Measured on a four-row fixture, a
day BETWEEN :a AND :b predicate covering two of them returned
4 of 4. The reader is now paren-aware — it splits on top-level
operators only, evaluates OR/AND recursively, models
SQL NULL semantics and the DDL's GENERATED ALWAYS AS
guards on day and ts, and throws on any atom
shape or column it does not know. The keyset seek is now read by that same
evaluator instead of being matched by literal regex and re-modelled in
hand-written JS beside it, so changing its shape can no longer make it silently
vanish from the model. Two shapes had been unreadable all along: the seek, and
— since #651 — the audit read's composite
(ts >= :t OR (ts IS NULL AND day >= :d)), on which the blind
split read an OR as an AND and dropped precisely the
undatable rows that fix exists to keep visible. Verify-to-fail: restoring the
blind split reddens 4 of the suite's 157 assertions;
0 existing assertion changed either way, so nothing had been passing on
an unparsed clause. backend/tests-audit-ts.js deliberately keeps
its own model rather than sharing a helper — so the two cannot be wrong
together — and the reasoning is written into the suite beside the tests
that assert the shared rule.docs/decisions/591-audit-day-timezone.md §6 and
docs/modules/audit-trail.md both still said the audit trail filtered
on the stored UTC day. D1 (the writer stamping AWST in code, via an
explicit offset rather than TZ) shipped in 3c8a17a and
D2 (the read resolving a requested AWST range against each row's own
convention-free ts) in bc1d487, both on 4 Sep 2026.
D2's own argument — that a ts filter makes new rows right and
every row already written read right with none rewritten, which is why it
was chosen over a migration — is verified against the shipped code and
stated as verified: no UPDATE against audit_log, no
backfill, and zero client files changed. D3 and D4 remain, and both
documents now say so in those words: no stored row was touched, so
audit_log.day carries two conventions permanently and any reader
going around readAudit gets the old one on old rows; and
keepUntil was not backfilled, so it is still one day early on
rows written in the [00:00, 08:00) AWST window before
3c8a17a — consequence-free only because there is no
reaper. Written as a dated correction that keeps what was originally
measured, because a sentence swapped silently leaves the next reader unable to
tell a decision from a description.movement unknown · no readable fix time beside a hatched
No fix time pill, and the Fix row renders the same shared sentence
the map popup uses, so the two surfaces cannot drift apart.
The figure itself is deliberately left alone, and that is the judgement rather
than an omission: an unreadable timestamp does not make the speed
wrong, so the mark goes on the currency claim — the note and the words
— and never on the number, which is also what this screen's own hatch
convention already said. It is emphatically not struck through the way a frozen
fix's number is, because read this as an echo of an older reading is a
positive claim about the reading that nobody is in a position to make.0 km/h had been read as stopped, which dressed an
unknown up as a park. Both are withheld on an undateable fix and both absences
are named in place — the projection in the note under the map, the
readout in the Fix row — because a silently partial answer reads as
a complete one. Frozen and stale fixes still project, unchanged. One
pre‑existing gap went with it: the trail note only ever explained the
projection when the asset also had recorded history, so an asset with a live fix
and no history got the orange line drawn with nothing saying what it was.position:fixed;bottom:0 with nothing reserving the space it
occupies, so whatever landed in the bottom of the viewport was drawn underneath
it. Measured on an iPad in landscape (1024×768, environment
unset — what the deployed build says today), the group editor's Save
changes button sat at 704–740 under a banner occupying 692–768,
with a scroll extent of zero on both the document and the content pane:
100% covered and impossible to bring clear. Because the banner passes taps
through, the button was still hittable where nobody could see it —
an invisible live control. It was never one screen's problem: across twelve
screens at that viewport, eleven had content in the band, up to 34
elements of it on the enquiry desk, and a tap there landed on a table cell, a
status badge or the map. The page is now a column whose last row is the banner's
own box, so the band is reserved by the banner itself rather than by a pixel
figure that would be wrong somewhere: the banner renders 45, 61, 76, 79 and
92px tall across the four viewports and two environment declarations
measured, and the app's bottom edge equals the banner's top edge in all eight.
Nothing of the app is under it on any screen checked, and the Save button is
reachable and lands fully clear. The banner is unchanged in every other respect
— same words, same hatch when nothing is declared, same 9.30:1 contrast,
still above the modals and the sign-on gate, still not dismissible, and still
absent from production, where this changes no geometry at all.grep commands never produced the
numbers they claimed. #327's brief said
grep -rniE '\.pdf|jspdf' js/ “returns two hits”;
it returns 1, and it returned 1 at 1f22d92 (the base the
brief declares it measured against), at b945725 (the commit that
added it) and today — so this was never reproducible rather than
drifted, and no variant reaches 2 either (escaped 1, unescaped
-E 12, unescaped -Ei 31). The cause is
the pattern, not the finding: the con note's button reads
Print / Save as PDF and carries no literal .pdf, so an
escaped \.pdf cannot reach it. Both sites the brief names are
real — the MIME sniffer at js/data.js:172 that classifies an
incoming attachment, and the window.print() now at
js/screens/46-connote.js:199 — so the count is replaced by the
two named sites plus the check that does reproduce,
grep -rni jspdf js/ = 0. The conclusion is unchanged:
nothing in js/ produces a PDF, and FR-QT-06's gap
holds.-i. #336's brief supports
“there is not even a field for a load plan” with
grep -rn 'deckPos|trailerPos|onDeck' --include=*.js . = 0,
which is correct and always was. Add -i and it returns 13 lines,
all in driver/driver.js, every one frTrailerPos or
frLoadTrailerPositions — a trailer's last-reported GPS
position off GET /fleet/live, so the freight picker can order
trailers by how near they are, which is not a deck position on a trailer.
Those 13 were already there at 1f22d92, at 20e04ed and
at HEAD: the qualification was missing from the start, and the
adjacent loadplan command does carry -i, so the
omission reads as an oversight and the next reader to normalise them would find
13 hits under a line claiming zero. The case sensitivity is now stated as
deliberate, the 13 are named as the near miss they are, and
deckPos/onDeck are recorded as 0
case-insensitively across every file type. The recommendation — two
artefacts, because a deck position exists nowhere — is unchanged.CLAUDE.md's
#327 entry carried the same “.pdf|jspdf across
js/ returns two hits”. Corrected in the same change, in
the same terms, with the old wording quoted rather than swapped out silently
— a correction that hides what it replaced is the defect this branch is
fixing one level up. Prose only in three files: no assertion moved
(tests/run.js unmoved at ALL PASS, 16,138), and every command
now printed in either brief was re-run and its real output recorded.tests/*-check.js guards now refuses an
argument by name instead of discarding it — measured, all thirteen exited
0 on a flag they had never heard of. The instance that started this
was tests/infra-template-check.js: an agent deliberately passing
--bogus-flag to prove the guard could fail got a false green
back from it, in a file whose own text preaches that a stale matcher must be
loud. Swept across the family, the answer was the same in all thirteen —
none of them read process.argv at all. Each now prints its own name,
the argument it was given and the invocation that works, and exits 2
rather than 1, because the caller made a mistake and the tree did not. The
wording is identical in all thirteen on purpose, so
grep 'takes no arguments' answers whether a fourteenth has it.ae11bc0, each of these exited 0. The CSS split check with
every module stylesheet removed from disk and unlinked from
index.html printed “✓ all 0 module stylesheets are
linked” — the whole of the dispatcher's per-screen CSS could go and
its one guard reported a clean bill of health — and with a
selectors() regex that matched nothing, the rule its own header
calls the load-bearing one passed as “0 distinct selectors across 56
files”. It now fails on an empty css/modules/, and asserts
per file that every stylesheet yields at least one selector (a relationship, not
a total: the smallest on disk yields 8). The JS split check with a
decl regex that matched nothing passed as “0 top-level names
across those files, every one declared exactly once” — that is rule
4, which its header calls the entire reason for doing the split. It is now
pinned to js/app.js yielding at least one top-level declaration (87
today), rather than per file, because js/screens/64-conflicts.js is
legitimately one IIFE with none and a per-file floor would be a false red.git was absent,
and one of them let a half-finished merge through because of it. Measured: a
tree carrying <<<<<<< HEAD in
config.js fails tests/version-check.js with git
on PATH and passes with git off it — the merge-marker
sweep is the only thing in that file which can catch a marker at all (markers do
not unbalance the <div> counts, and the release blocks either
side of them still parse), and it printed no tick, no note and no warning when
it had not run. tests/js-split-check.js dropped its
.gitignore probe — rule 5, the one that catches
/screens/ losing its leading slash and swallowing the whole
dispatcher app — the same way. Both now fail and name what did not run.
tests/nul-byte-check.js already refused this correctly and
tests/preflight-check.js already had the words for it: a not-run,
not a pass.infra-template-check.js's “otherwise
this guard reads nothing and passes, which is the worst possible green”,
doc-links-check.js's floor of 20 relative links,
nul-byte-check.js's refusal of a listing under 100 files,
precache-check.js's four separate “the matcher is stale, fix
it” refusals. Nothing was touched beyond the argument guard and the
three fixes above, and no check's subject matter was changed.403 whose body is an HTML
CloudFront or WAF error page classifies as carrying no scope refusal for exactly
the same reason a JSON body with no error field does — so it fell through
to "the API REJECTED the token in the API_TOKEN secret" with full
confidence and no caveat. There are three reasons to trust that sentence and
only one of them is yes: the reader never ran, the reader ran and the body did
not parse (so nothing in it could have named a refusal), or the body was
genuinely read and names none. Only the third earns the sentence unqualified.
The wording is byte-identical in all three — measured by hashing that line
across four different 403 bodies and against the previous release — and
the caveats are added lines beneath it, naming a WAF or a stale distribution as
likelier than the authorizer, because a real token rejection arrives as API
Gateway's own JSON, which parses. That is also why the unparsed case no longer
records unverified: that value makes the deploy summary state the
token cause as fact.bash -e, from
a scratch directory, with a stub for the one network call — against 19
saved response bodies, and reads back the exit code, the log and the state the
deploy summary reports: 96 assertions in about a second, wired into CI by the
existing runner with no workflow change. Restoring the original one-line bug
reddens 15 of them and nothing else in the tree.npm run checks now runs every check CI runs as a
node step, and something watches that it keeps doing so. It was
twelve of thirteen; the missing one was found by hand. A check now derives the
list from the workflow itself and asserts each one is in the local chain, so the
gap cannot silently reopen — and the chain reddens if the run-block guard,
the link check or the infra-template check is dropped from it. Neither of the two
gaps behind this and the previous entry was ever owned by an issue, which is why
each had been reported three times.200 branch decided between "no positions yet"
and Live GPS returning vehicles. by grepping the raw response for
"count":0 — so on a body that did not parse at all the grep
simply missed, the else fired, and the deploy printed a claim about
bytes nobody had read and recorded verified as it went. Measured
across 18 saved fixture bodies: the empty, malformed and top-level-array cases
each printed Live GPS returning vehicles. Two states for a
three-state fact, in the instrument whose whole job is the three-state rule.
The projection reader already writes a classification file the shell reads, so
it now writes the vehicle count's state there too, from an allow-list —
parsed and reports vehicles, parsed and reports none, parsed
but carries no readable count, did not parse, so no claim is made
— rather than a second grep of the same bytes, which would be free to
disagree with the projection printed directly above it. The two existing answers
keep their exact wording; the third says it makes no claim and records
unreadable, which lands in the deploy summary's "NOT
verified" arm instead of falsely blaming the CI token. Exit codes are
identical on 18 of 18 fixtures, and 12 are byte-identical to before.npm run checks now runs the guard that keeps a workflow
dispatchable, and the CLAUDE.md link check.
check-run-block-length.py exists because a run: block
over 21,000 characters parses perfectly and makes the workflow undispatchable
— CI caught that only after master was already broken — but it ran
only in CI and by hand, so it was a guard a contributor met in CI rather than
locally. Both are now links in the chain, and the wiring is measured rather than
assumed: pointed at the commit that shipped the 22,887-character block,
npm run checks exits 1 after 184 passing ticks. The chain now needs
python3 with PyYAML for its last link (npm run wiring has needed
python3 all along), and it is still one CI step short of complete —
tests/infra-template-check.js — which
package.json's own description now names.CLAUDE.md's findings list were true when
taken and are false now, so each one says so beside the number rather than
losing it. That list records what somebody measured on a given day, and this
week's work moved the code under eight of the entries: the Odoo screen now reads
meta.verdict and hatches every state whose figure is not a
measurement (#641), the fleet overview derives one coaching window per
draw instead of two that could disagree on a wall board (#642), the tracking
link is minted by one CSPRNG that refuses rather than falling back
(#643), the Postgres adapter's retry window can no longer outlive the function
running it (#644), an unresolved depot filter is drawn hatched instead of
printing the same 0 as an empty yard (#647), the portal declares a
15-collection state scope instead of holding the whole database (#569),
the Lambda stamps its day key in AWST (#591), ENVIRONMENT reaches
all six functions (#646), the cab's posted-limit lookup no longer gets thirteen
times noisier when it fails (#598), NF.Pools.qualified() has a
caller (#592), and assetDocs is in both the snapshot and the
backend whitelist. Every original figure is kept verbatim and annotated with
what is now false, what still binds and the file:line that proves
it — a measurement quietly swapped for a newer one loses the reason the
fix was sized the way it was. Also corrected: three module docs said
tests/run.js loads js/wethire.js, a file #390 deleted
with the Wet Hire board on 22 August, where the real list is
tests/lib/sharedlayer.js:48-51; and
NF.splitManifest's citation had drifted from
js/data.js:994 to :1018. No code changed and no
assertion moved — node tests/run.js is unchanged at
16,138.style= attribute
because css/core.css was leased to another branch at the time, and
the asset page later declared the same four values there as
.spd--expired. The dashboard now sets that class and its copy is
deleted: a duplicate definition is worse than a duplicate use, because the two
can drift while both look authoritative. Nothing looks different — every
computed property was measured in a browser before and after and is identical.NF.SessionRecords.open({attendees:[…]}) and
amend — which went through dedupeRoll and its one line
if(!row.person || seen.has(row.person)) return;. That is two
defects at once, measured on the base commit:
open({attendees:['D1','',' ',{},[],false,NaN,0,null]})
answered {ok:true} and filed
["D1"," ","false","NaN","0"] — so
' ', false and NaN landed on an
evidentiary roll, the same three forms the sibling writers had just closed one
door away, while '', {}, [] and
null were dropped with nothing said. A roll is what answers
who was in the room on the 14th for a client's HSE team and for DEMIRS,
and it reached the wire, because sessionRecords is in
snapshot() and PUT /state pushes it. Both paths now
use the shared NF.blankRef. !row.person was also a
truthiness test — the active !== false family — that only failed to
drop the legitimate id 0 by luck, since str(0) is the
truthy string '0'; 0 now survives by rule, and
' D8 ' is still filed as typed, because normalising an id somebody
typed would rekey rows that already exist.open() still refuses a missing topic, presenter or date outright —
one value each, and there is nothing to file without them — while a roll is a
list, and a list can be partly right. That is also what
signOff() already does one function away, crediting attendee by
attendee and naming the ones it skipped, so refusing here would have made one
record speak two philosophies and would have lost eleven signatures in a crib
room at 06:30 to one stray row. Since ok:true with a caveat nobody
reads is a guard that passes while asserting nothing, the caveat is in three
places: on the return (rollDropped, one entry per row with its own
printable sentence, plus rollNote as the one sentence a form
prints — both always present, [] and '' when clean,
exactly as signOff()'s skipped/unverified
are); on the record, so a caller that throws the return away cannot make
the omission silent, printed under the roll on the session record page every
time it is opened and riding out in the same document
PUT /state pushes; and in the arithmetic, which closes —
declared === filed + dropped. That last is why a de-duplicated row is reported
too, under its own reason: a duplicate loses no name and is not the defect, but
a roll of twelve that files ten and explains one of the two is the silently
partial answer this change exists to stop. An absent
rollDropped means no row was dropped, for a record written before
this guard as well, so there is no third state to draw and no migration.
Measured, off the runner's own summary line: dropping a blank row silently again
reddens 50 assertions (a further 28 stop running, because there is
nothing left to name); swapping NF.blankRef for a truthiness test,
which drops the id 0 and files ' ', reddens
17; restoring the base commit's line verbatim reddens 64 (again
plus 28 unrun); and taking the print site off the record page reddens 3.
Green at 16,553, up from the base's 16,414 by the 139 added; the browser
suites are unmoved at 94 (sessions) and 39 (compliance).grep -rni rehome js/ driver/ portal/ --include=*.js
as “js/data.js only”; it returns 15 lines
against 9 at 1f22d92 (the base the brief declares), and one is
outside js/data.js — js/screens/10-pools.js:546, a
display label keyed on the source string rather than a caller. So the
substantive claim was re-checked directly instead of by subtraction and
holds: grep -rn recordRehome js/ driver/ portal/ backend/src/
is two lines, both in js/data.js (the definition at
:15202, a comment at :15984), nothing writes a re-home
and FR-MF-03 still has no writer with a caller. One half of §1.5 has
moved and it sharpens the finding rather than softening it: “no screen
reads lc.rehomes” is no longer true, because #282's
NF.Trailers reads them at js/data.js:16070 and
js/screens/10-pools.js:568 draws that derivation — the reader
now exists and the writer still has no caller, so the screen that would show a
re-home is built and will never show one. §1.1's manifest survey has
grown from 8 hits / 6 transport to 10 / 8 in js/*.js and
28 / 22 to 30 / 24 across js/, and the two new lines are
the reinforcement: js/data.js:15824 and :16081 are #282's
own comments naming #283 as the owner of the Manifest, so a second place in
the tree now treats it as trailer-keyed, written by somebody who was not writing
the brief. §3's recommendation — two artefacts — is
strengthened. And §1.2's “0 case-insensitively across every file
type, this brief and the CLAUDE.md line excepted” was
already false, the two changelog fragments recording the earlier correction
carrying the tokens too; it is replaced by no repository-wide line count at
all, because the correcting paragraph is inside the scope of its own command
and any total invalidates itself as it is written. The code-scoped figures are
exact and unmoved at both commits: 13 for
deckPos/trailerPos/onDeck, all
frTrailerPos/frLoadTrailerPositions in
driver/driver.js, and 0 for
deckPos/onDeck.1f22d92 from a detached
checkout of it and at this branch's head, and the results are tabled in the
appendix with the originals left in place, because an annotation somebody could
not reproduce is the reason the block needed correcting and swapping the number
out silently hides it. Drifted: manifest in js/*.js
(8→10) and across js/ (28→30), rehome
(9→15), and print in driver/ (8→18) —
that last one now 18 comments about printing a figure on a screen, with the finding
resting on two zeros that are exact instead: window.print and
@media print are both 0 in driver/, and the four
files carrying @media print are all ones
driver/index.html does not load. Never reproducible: the
restrain line is annotated “5 production sites” and
the command as printed returns 24 lines, identically at both commits, while
§6.2c says “appears five times” above six
file:line citations — four facts across six sites, all six
opened and real at both commits, drifted by 101 lines. The finding is unaffected:
not one of the six is a customer instruction about method. The §1.3 sample-dataset
measurement was re-run in an equivalent vm rig through
tests/lib/sharedlayer.js: 0 of 55 movements with a rig
reproduces exactly at both commits and is what the section rests on, while its job
denominator does not reproduce — 1 of 74, not 1 of 84, and 74 at
1f22d92 as well as today, with and without
NF.reconcileJobs(). The numerator and the identity are exact
(JOB-7001, ref still a dash). Prose only: no runtime file
changed and tests/run.js is unmoved at ALL PASS, 16,735 (identical at the base commit and with these three files added).tests/*-check.js family's argument contract, written down in
docs/modules/ci-cd.md as a rule for the fourteenth check rather than
as a note about the thirteen that exist. All 13 once accepted an
argument they had never heard of, said nothing and exited 0 —
reproduced at ae11bc0, exit 0 on 13 of 13 — which is the
family's own subject matter turned back on itself, since the false green lands on
exactly the run somebody is using to prove a guard can go red. The section
states the four rules that now bind: read process.argv before the
checkout, name yourself and the argument back, exit 2 rather than 1 (the
caller's mistake, not the tree's; both non-zero, so CI is untouched), and use the
family's byte-identical wording so grep -rn 'takes no arguments' tests/
— 26 lines across 13 files — answers for a fourteenth without
opening one. It also records the three load-bearing vacuous passes and their
measurements, each reproduced against ae11bc0 and re-run with the
shipped file dropped into the same broken tree: every module stylesheet deleted
and unlinked printed ✓ all 0 module stylesheets are linked
at exit 0; a selector matcher matching nothing printed ✓ no
selector appears in two stylesheets (0 distinct selectors across 56 files)
at exit 0 for the rule its own header calls THE LOAD-BEARING ONE; a dead
decl regex passed js-split's rule 4 as ✓ 0
top-level names; and a tree carrying a conflict marker in
config.js exits 1 with git on PATH and
0 with it off, in the file whose marker sweep is the only thing that can see
one. The distinction the section exists to settle: npm run checks
runs all thirteen and so does ci.yml, the same 13 measured off
both files, with tests/suites/82-ci-smoke-step.js deriving the pairing
rather than listing it — a brief in this wave stated it as 11 of the
13, which reproduces on no commit in this tree, and the stale
“except one” sentence earlier in the same document is corrected
in place with its date rather than deleted.tests/suites/54-compliance-escalation.js
reddened two assertions under TZ=America/New_York — “expected
60, got 59” and “expected 0, got 1” — while passing under
TZ=UTC (what CI runs) and TZ=Australia/Perth. The cause
was the suite’s own fixture, not NF.Compliance: it built
“n days from today” as midnight + n × 86,400,000,
which is a second, disagreeing copy of the derivation under test. 1 November 2026
is 25 hours long in New York, so 60 × 86.4e6 landed at 23:00 on 2
November rather than midnight on the 4th — one calendar day nearer than
intended — and NF.daysBetween, which counts between local midnights,
correctly reported 59. The fixture now steps calendar days through
setDate; not one assertion changed, and no application code
did either.UTC, Australia/Perth
and America/New_York, asserting the three fingerprints are identical.
Never process.env.TZ in-process: V8 caches the zone at first
Date use, so that would make the guard’s own result a function
of statement order. Four assertions were also added directly on
NF.daysBetween’s local-midnight contract, because a
zone-against-zone comparison cannot catch a derivation that is wrong the same way
everywhere — measured, replacing its two midnight reads with raw milliseconds
left every other assertion in the file green in all three zones. Restoring the
old fixture arithmetic now reddens 3 assertions under UTC, where the
original defect reddened none.devices() could spend the per-request timeout on four
separate requests — login, poll, self-heal re-login, re-poll —
measured at 3.90× that timeout end to end, i.e. about 31 s
at the shipped 8 s, against the API Lambda's 15 s limit. Killed
mid-wait, the browser got API Gateway's own HTTP 500
{"message":"Internal Server Error"} and none of the nine keys the
module had just been rebuilt to speak, so the whole vocabulary was unreachable
on the one path that most needed it. There is now one budget over the whole
call (12 s standalone, or the invocation's own remaining time minus a
3 s reserve once the route passes it in), and the timeout itself was
deliberately not lowered — that would convert a slow success into a
fast failure. A request with room gets its full timeout unchanged; a request with
less gets what is left; a request that cannot be afforded is not started at all.budget_exhausted, which is not
timed_out. timed_out is a fact about the GPSWOX
instance — it was asked, given the full timeout, and did not answer.
budget_exhausted is a fact about us — we stopped with the
invocation about to end, and whether the instance would have answered is not
established and is not claimed. Different causes are different phone calls, so
they are different answers. A budget refusal also never overwrites a cause we
did establish: when the instance has already given a verdict and there is not
enough invocation left to afford the self-heal, that verdict is what is
reported. count stays null with
measured:false on every path that did not complete, and the answer
still carries the same nine keys, so an older cached browser bundle is
unaffected — verified byte-for-byte against all sixteen pre-existing
answers.#647's
ambiguous-shared-code section was written on a page sitting on
#/skillsmatrix; the environment-banner section that landed beside it
drives eight viewports and leaves the page on #/groups with the
group editor open. Measured on the merged file: the next
page.evaluate found a null #sm-depot and the run died
with Cannot read properties of null (reading 'appendChild') —
zero counted failures and no summary line at all, which is the failure
mode this repo counts assertions to avoid. grpEdit is module state
in js/screens/22-groups.js and survives a navigation, so re-entering
#/groups re-renders the editor rather than the list: #view
held 0 rows with #grp-scope present, a second inherited-state
dependency that would have failed seven assertions once the crash was cleared.
No section now inherits a route or an editor state from whatever ran before it —
each enters the screen it needs through one shared helper that closes any open
editor, re-renders from scratch and reports whether the marker element
arrived, with a false always turned into a named failure rather than an early
return. Every assertion both sections carried survives (banner 33, #647's
ambiguous section 15), and four preconditions were added where a null could
previously pass unlooked-at: 101 checks, up from 97 unrunnable. Nothing the
suite tests changed.NF.Odoo.recordPOD, closed itself and fired a green
toast reading "Delivery confirmed — POD saved", with no check of
anything. Those writers are deliberately unrefusable for a consignment
that exists — a driver in a dead spot must be able to complete a delivery,
so NF.Media.put() swallows every upload failure and
NF.Store.save() is fire-and-forget — so saved was being
read off a call that cannot fail. The dispatcher app has no sync indicator of
any kind (the driver PWA has a pill; this app has nothing), so a POD the API
had refused as too large — a 413, which is terminal, because
retrying cannot shrink a record, and a POD carrying a signature and photos is
the record most likely to hit it — was announced in green and then never
mentioned again, 2.6 seconds later. The form now stays open and answers
in place: saved to the database, recorded here and not yet
acknowledged (drawn hatched — we do not know the office has it), or
NOT saved, naming the record and saying to call the office (dimmed and
dashed, a known negative rather than an unknown). It repaints on
nf-sync-status, so a queued capture becomes a confirmed one without
a reload, and it never auto-hides. Reverting the screen reddens 8
assertions in tests/suites/84-pod-write-outcome.js and 15 of
67 checks in tests/manual/board-conflict.js.recordPOD, recordPickup and recordFailed
returned true for a consignment that is not on this device, having
written nothing anywhere. The capture fell into an if(c) that
never ran and the caller could not tell it from a completed one — while
their own siblings ten lines below (attachDoc,
recordJobForm, removeDoc) have always answered
false for exactly that. All three now refuse it, before the media
offload rather than after, so a signature is not uploaded for a record that is
not there; the screen prints the refusal in the form and marks nothing.
Reverting reddens 3 assertions.NF.Odoo.writeOutcome() — one answer to "has this reached
the office", shared rather than copied. A pure function of what
NF.Store reports, so the desk and (once it is wired) the cab cannot
hold two opinions about whether a delivery got out: a second implementation of a
delivery decision is worse than the bug. Four answers, because saved is
not two things: written, queued,
not-sent and local (no database configured for this
build, which is not the same as a write that landed). It also reads the
refused-demo sync event, because NF.Store clears
pending for a write it will never attempt and
syncState() then answers synced for it.GET
/state?scope=portal projected drivers to
{id, name, active} because the tracking screen printed the name.
That screen stopped printing it — a named employee sat three inches under a
live pin on a link anybody can forward — so the projection was shipping a name
to an anonymous page for a reader that no longer exists. Measured
comment-stripped across the portal's own files and the two shared modules it
loads: NF.drivers 0, NF.get.driver 0 — nothing in
the portal reads a driver record at all. The projection is now
{id}. On the generated sample document the answer goes 84,290 to
83,878 bytes and 20 of 20 driver names leave the body; the point is the names
rather than the 412 bytes. The customer still learns what they actually asked —
the Details card reads Assigned or To be assigned, which stay two
distinguishable answers.id is deliberately kept, and it is not data. The shared
layer's merge does a field-level replace for exactly the collections the
route projects — a key the answer does not carry is deleted from the local row —
and it skips any row arriving without an id. That pass is the only thing that
scrubs a name already sitting in a returning customer's browser storage from
before this shipped. Withhold the collection entirely and the pass never runs on
a single row, so narrowing further would leave more behind, not less.
vehicles is unchanged at {id, type, colour, active}:
the truck's type and colour are still drawn, and that is measured rather than
assumed.backend/tests-state-scope.js and 5 in
tests/manual/accept-link-presync.js. The browser suite's stub is
also no longer a hand-copy of the route's tables — it reads them off the handler
and fails a fixture check if the two disagree, which is how the stale assertion
survived to CI in the first place.Driver — Wayne Carter in the Details card,
three inches under a live pin. #654 had just removed the driver's name from the
/fleet/live response and this render was left as the last publisher
of it, off the local record instead; no #654 assertion could see it, because
every one of them is about the response and this was in the render. The same
renderer already knew better — its split branch withholds the name citing
FR-SP-05, the rule this code keeps and cites in fifteen places —
so the single-shipment branch now agrees with it. The row stays, because the
customer's actual question is is anybody on this load yet: it reads
Assigned or To be assigned, and the withheld case says which fact
is withheld. Drawn dimmed and dashed, never hatched — we know exactly who
is driving and are declining to publish it, and hatching would claim the
opposite about our own records — and never a blank, which reads as no driver
assigned. The register is not consulted at all now, so there is no name in
scope to leak: an unresolvable driver id still answers Assigned. Checked
across the whole anonymous page, not just the card. Reintroducing the leak
reddens 3 assertions in tests/run.js and 6 browser checks in
tests/manual/portal-selfservice.js.#tmap-note empty and lastScope.error set — the
sentence appeared only when the feed was down from the very first poll, which is
the rare case. The cause is not the page's own ok flag, which is
recomputed every poll: the adapter keeps the last known positions and re-emits
them flagged stale, so the page had a drawable coordinate again and ran
trackMapNote(''), wiping whatever was showing. A position we cannot
refresh is not a position we know is current. The nine answers are now decided in
one pure place and every one carries its own sentence and its own
keepPin, so the pin and the sentence can never disagree: we could
not look keeps the pin and is hatched (an unknown), while the
answer came back and says it is past its refresh window keeps the pin and is
dimmed and dashed (a known negative) — two treatments, not two wordings of
one. Two states that used to be silent now speak, including the moment before the
first poll returns, which is real rather than theoretical because the live
adapter's subscribe() fires once with an empty list before it asks
at all. Recovery still clears the note. Reverting the fix reddens 4 assertions in
tests/run.js and 6 browser checks in
tests/manual/portal-selfservice.js.devices() makes up to four requests — login,
poll, self-heal re-login, re-poll — each free to spend TIMEOUT_MS,
measured at 31,240 ms end to end against ApiFunction's
15 s Timeout. Killed there, the caller gets a bodiless platform error
carrying none of the vocabulary the route exists to speak. The whole call is now
bounded: a request with more than TIMEOUT_MS left gets its full
timeout exactly as before, one with less is granted what remains, and one with
less than MIN_SPEND_MS is not started at all.
budget_exhausted is its own reason and not a flavour of
timed_out — a timeout is a claim that GPSWOX was asked and did
not answer, and giving up is not evidence about GPSWOX. It is declared in the
verdict map rather than left to the unknown-reason fallback, because a reason
this module raises itself is not one it has never heard of. Measured: removing
the cap reddens 26 checks in
backend/tests-gpswox-reason.js.… : 'no_devices', so a feed
that could not be read at all — no credential, instance down, or GPSWOX simply
never set up here — was reported with the same word as a poll that ran and held
nothing. #673 made the feed able to tell those apart
(available:false); this reads it, and answers
feed_unavailable. Keyed on available === false, not
on verdict === 'unavailable': #673 deliberately keeps
not_configured out of unavailable, which is right for
the staff screen and would have silently lost one cause of three here. Measured:
reverting the fix reddens 3 assertions in
backend/tests-gpswox-scope.js, and switching it to the verdict
reddens 2 — the two that are the whole point.CLAUDE.md's cross-cutting rule — every writer returns
{ok, error, message} and the screen renders that sentence
in place, "never a toast, which slides away while somebody is still
reading which field it meant" — was kept by the writers and broken by the
screens. Measured over all 36 dispatcher screens: 237 toast()
call sites in total, of which 21 carried a writer's {ok:false}
refusal, spread over 10 files. Five are repaired here, chosen for
consequence rather than count: verifying or rejecting a driver's uploaded
certificate and revoking a waiver (#/compliance), the rate card's
"a cleared box is not an agreed nil" (#/rates), adding a site
contact and the two address-book dialogs (#/locations), the
signed-in account adding or linking itself to the register
(#/people), and a session sign-off's caveats
(#/learning → Sessions). Each now renders the writer's own sentence
in an existing refusal box (.reg-err / .ses-err) beside
the control that refused, and it stays until the person acts — a toast clears
itself after 2.6 s and holds exactly one message, so a second click
wiped the first.NF.Sessions.signOff() returns every attendee it could not
credential — recorded competent but never confirmed they were in the room — and
the screen printed notes[0] as a delayed toast 1.4 s
after the confirmation, discarding the others. On the artefact a client's HSE
team and DEMIRS read, that is a silently partial answer reading as a complete
one. Every caveat is now printed on the record itself, survives the repaint the
sign-off triggers and every repaint after it, and is still legible long after a
toast would have gone.toast(r.message || 'That could not be done') means a
writer that refused without a sentence produces a wording the writer never
wrote, and one the reader cannot act on. The compliance and address-book helpers
now name the absence in the repo's own existing words — "That was refused, and
no reason was given" — rather than inventing a reason. No writer reached in this
change was found to refuse without a message.node
tests/run.js prints, beside its own ALL PASS — N
assertions line, the HEAD it ran against, the branch, whether
it is a linked worktree or the shared main checkout, and how many
tracked files were dirty — naming up to six of them and counting the
rest. That last figure is the one that matters: four numbers reported in a single
day (16,100/8, 16,104/4, 16,107/1,
16,105/3) turned out to be another agent's uncommitted files
reddening a third party's suite, one agent came close to reporting somebody
else's failure as its own regression, and three of the four cannot be
re-established today, because the runner wrote no artefact and recorded
nothing about its own inputs. A dirty count would have caught all four.
Three things it deliberately does not do. It never fails the run:
every git read is wrapped, and a read that could not happen prints
provenance UNAVAILABLE in place, because an absent line reads as a
clean tree — the three-states rule applied to the runner itself, since
we could not look and nothing is dirty are different facts. It
adds no assertion, so the total is unmoved at 16,138 and no agent's
baseline shifts. And it is two git reads, measured best of
five at 11.0 ms for the pair in a linked worktree and
11.6 ms in the main checkout — 0.014% of an 80-second
run, rising to 61 ms for the very first read in a freshly created
worktree, where the index's stat cache is cold. So there is no opt-out flag to
get wrong, and no provenance line anybody can quietly switch off. The snapshot is taken before the suites are read and
again at the end, so a tree that moved mid-run — the shared-checkout
case — is reported rather than averaged.
The warning is deliberately narrower than the count. The count and the file names
print on every run; the re-measure this somewhere else warning fires
only on a dirty shared checkout, because in your own worktree the dirty
files can only be yours, and a warning about your own work in progress on every
run is the noise that teaches people to ignore warnings — this repo already
regrets one of those. All four wrong numbers were measured in the shared tree.NF.SessionRecords.addAttendee / .attest guarded the
person with person == null || person === ''. That caught the empty
string, so neither ever had #660's file() defect — but measured on
the base commit, five of the eight blank forms still went on the roll:
' ', {} (stored as the literal
[object Object]), [] (stored as ''),
false and NaN. The [] case compounds,
because attendeeOf then matches that row: attest(ref, [])
stamped an attestation on it, and immutable once signed closed both exits —
removeAttendee answered attested and
amend answered drops_attestation, each in a sentence
that names nobody (" has already confirmed their attendance"), so
the only remaining route was withdrawing the whole evidentiary record. It reached
the wire too, since sessionRecords is in snapshot() and
PUT /state pushes it. Both writers now use the shared
NF.blankRef, and a blank attest is diagnosed as
no_person — who is confirming? — rather than as
not_on_roll, which told a dispatcher with an empty box to add
somebody to the roll first.NF.blankRef and routed file(), waive() and
GateOverride.record() through it; addAttendee() and
attest() were the remainder, and the suite now puts every blank form
through all five and compares the answers, because a test that only called the
helper would pass while any one writer quietly stopped calling it — which is how
the first three came to disagree. Each writer keeps its own sentence and its own
error code; only the test is shared. A finite number is still an id, 0
included, and nothing is trimmed on the way in: ' D8 ' goes on
the roll as typed, because normalising an id would rekey rows that already exist.
The pre-sign-off path is explicitly unchanged and now has its own group —
open() deliberately stores assessor: '' before
sign-off, so a VOC still opens with nobody named, is attested, and is signed off
afterwards, with the no_assessor refusal staying at
signOff() where it lives. Measured, off the runner's own summary
line: restoring the addAttendee guard reddens 24 assertions,
restoring the attest guard 8, restoring both — the base
commit's exact state, where the phantom row is reachable again — 37, and
the over-tight version that refuses every non-string, which would stop a numeric
imported person id going on a roll, 8. Green at 16,414, up from the
base's 16,289 by the 125 added.docs/modules/file-splits.md said of
js/wethire.js — the file #364 calls
“the existing pattern … the template” — that it
“is not in this repo and never was (no commit in history touches
it)”. The first clause is true and the parenthetical is false in the
strongest form available: the file was added by 684b28b
(v0.36.0, 29 Jul 2026) and deleted by ef26c98 on 22 Aug 2026, the
commit that removed the Wet Hire board and its supporting code (#390), and
git log --all --oneline -- js/wethire.js returns both. That is
worse than the error it replaced, because “never was” closes an
enquiry that one command answers, and anybody trying to read the template it
names would have concluded the issue invented a filename. Now written as the
correction it is: a real template once, not one to copy now, with the two
commits and the date named.tests/run.js brings up the shared layer in a vm, and
the four suite headers that explain why a screen bug cannot be caught there
(53-reports-performance.js, 58-byod-disclosure.js,
59-driver-sw-reload.js, 68-backups-panel.js) each said
that vm holds js/data.js + js/integrations.js + js/wethire.js. The
third has not existed for a fortnight and the list has grown a directory since:
the real one is tests/lib/sharedlayer.js:48-51 —
js/data.js, js/data/*.js, js/integrations.js,
js/integrations/*.js, two of the four read off disk rather than
written down. Also recorded, because the next reader will wonder: js/data/
does not exist yet, so that clause yields nothing today and the live list
is three files — it is written for four because js/data.js is
the next split and the harness must not need editing on the day it lands. A
header comment is what somebody reads before touching a suite, so a wrong load
list there costs real time. Comment text only: no assertion and no fixture was
touched, and tests/run.js is unmoved at 16,138.docs/modules/testing.md explained why
tests/precache-check.js asserts two links rather than the one-line
superset rule (PRECACHE ⊇ the page ⊇ js/*.js on disk) by
naming js/app.js and js/wethire.js as dispatcher-only.
Checked rather than swapped, because the obvious reading is that losing a
dispatcher-only file makes the superset rule satisfiable again: it does not. The
rule is unsatisfiable while any shared file is dispatcher-only, and the
check's own allowlist today names two files (js/app.js,
js/auth.js) plus two whole directories (js/screens/,
js/integrations/) — 70 files between them, measured
4 Sep 2026 — so the gap the rule cannot close is wider than when the
sentence was written, not one file narrower.peopleMatching(): show only
the people a site reaches. Measured, that selects 0 of 22. A site reaches
a person only through a geofence's client tag, which the geofence editor does
not set, so NF.geofences holds 0 tagged rows and the matrix
would have answered "No people match this filter" for every site, for
ever — an empty answered because it could not look, on the screen that
decides who may legally do tomorrow's run. Picking a site therefore narrows the
requirement columns to the rules that site imposes, the direction the
training-needs and site-readiness reports already take, and every selected
person stays on the table. The caveat saying so is printed above the table at
body-copy weight rather than under it as a footnote, it names how many of the
listed people have none of those rules on their record, and it points at
Site readiness for the other question (if you sent them, would they
hold what the gate requires). The CSV carries the same pivot and states it
in the file, so an export headed scoped to a site cannot quietly contain
every other rule.NF.Compliance.peopleMatching() has taken a client for
as long as the register has existed and nothing ever passed one, so
"who at Zeta Mining is not clear to work" could be asked only from a
console. It is now on the matrix, the escalation ladder, training needs and site
readiness — measured, it narrows the sample register from 22 people and 79
cells to 5 and 27. It is deliberately not offered on the evidence
pack, which records the scope it was built for and does not take a client: a
filter drawn beside a report that ignores it is worse than no filter. A client
here means the customers a person has actually worked for, read off the
con notes, so each option carries how many people it would select, and a
register where nobody can be placed with a client draws that hatched
with the reason — never an empty dropdown, which would read as "there
are no clients".tests/suites/90-compliance-matrix.js; rebuilding the
site pivot on the person axis reddens 12.REQ-MC-LICENCE
(short for 6 of 12) above REQ-BHP-NEWMAN, which is short for
every one of the 5 people it reaches. The new Requirement report
tab orders least-covered first over matrix()'s own cells, prints
every figure as "2 of 5" and never as a percentage, and says on the
screen that the two orders differ and why. A rule that reaches nobody has no
denominator, so it is named in its own card rather than ranked as though
everybody failed it; currency unknown stays hatched and inside the count
it rides on rather than being folded into either answer; and with nothing on
file the report says no certificate or completion has ever been filed
against this register before its own zeros can be read as a compliance
finding. Read-only over existing collections — no new collection, no
writer, no backend deploy.docs/decisions/236-requirement-report.md.
In progress is built, honestly: it is a count where the requirement
resolves to one of our own courses and reads "not ours to observe" where
it does not, because a 0 against an RTO's certificate is a figure
about a lookup nobody could run.delivered status with no signature
correctly produced nothing, but a con note carrying a proof of delivery and a
recorded failure produced a complete, sendable delivered milestone. That is
the record the one writer actually leaves behind —
NF.Odoo.recordFailed writes the status, the reason and the time and clears
none of the POD, and podImage/signedBy have one writer between
them and no eraser anywhere — so a POD taken against the wrong con note and
corrected, or a consignee who signs and then rejects the load, left both stories on one
record. The schedule board already rolled such a con note to red over a green PODed
phase; the derivation disagreed with it, in the direction of telling a customer their
freight had landed. It now refuses by name and reports both stamps so somebody can go and
look. The test is deliberately wider than the board's: a claim needs evidence, a refusal
needs only doubt.NF.poOf had already said, on the same record, that the order it inherits
could not be read. And a caller that bound only some of the reference rules got
complete:true with no gap at all, which is a claim about a lookup nobody ran,
on the exact flag the transport contract says a milestone may be sent on. The answer is
now nothing was asked / only half was asked / we asked and the record
holding the answer is not here / we asked and there is none, with only the last
being a fact about the customer. A record carrying no con note number is refused outright
rather than keyed on an empty string.docs/modules/coates-edi.md — still written to be sent as it stands,
because the technical specification is held by Coates IT in South Australia and has never
been obtained — asks two questions it could not have asked before: whether a
failed delivery is wanted as a milestone of its own, and what they want when our
record carries both a signature and a recorded failure (today: nothing goes, and somebody
telephones). Nothing is emitted anywhere, by any path; the module still contains no
require(, no fetch( and no URL, asserted over its own source.$0 for three different demurrage
facts. Nothing has waited, waits worth real money that nobody has
attributed yet and every wait reviewed and none of it the client's
are three different conversations, and the model has named which is which since
#315 — the tile read neither emptyKind nor the sentence
beside it and rendered a flat $0 for all three. The first two now
read as a dash carrying the model's own words ($0 decided is not $0
owed); the third keeps $0.00, because a measured nil is
something we do know and dashing it would under-claim it. The tile beside
it no longer says “every wait on file has been attributed” when there
are no waits on file at all.0 into the
rate box stored 165 over the operator's own answer. Both boxes now
keep a typed 0, record a cleared box as not set, and render blank
with a not set placeholder rather than value="null".FR-RP-01 asks for nine funnel measures and, until now, the reports
screen drew none of them, on the reasoning that seven need an event
nothing captures. The reasoning was right and is unchanged: nothing on the new
card is derived from a timestamp nobody wrote. What was wrong is that the
requirement was then drawn nowhere at all, and an absent requirement reads as
"no problem here". The card now states all nine and says which of five
things each one is: measured, a floor (with the ceiling named),
part of it only, nothing records it, or we could not look.
Seven can be stated from what the system holds — enquiry volume off the
desk (a floor: a phone enquiry that never became a quote has no record at all),
time to first response off NF.Desk's own arithmetic, time to quote
off the send stamp rather than the status, the information-request count,
quote volume, what became of those quotes, and the recorded outcome reasons
including the decided quotes that carry none. Two are recorded nowhere and say
so with the issue that has to capture them: time to acknowledge, because
no read, seen or acknowledged stamp exists anywhere and answering it with the
first response would put two names on one event; and the requote rate,
because nothing versions a quote, so a revised price and a re-send of an
unchanged one are the same event in this data — the send count is shown,
labelled as the send count and explicitly not as the measure.tests/suites/53-reports-performance.js. There is no percentage
anywhere on the card either: NF.Quotes.conversion()'s capture rate
is carried on the derivation and deliberately not printed, because a rate hides
which accounts, and the one enquiry nobody has answered is the only thing there
that is an action.css/core.css and all 55 module stylesheets: 21 of 153 scored
foreground/background pairs were below their contrast threshold, the worst being the
pipeline's step marker at 1.25:1 — a white step number on a light grey
disc, which is effectively invisible. All 21 are fixed and the dispatcher measures
0. Almost every repair was to use a role token this repo had already declared
and measured and simply was not using: --danger-fg, --warning-fg,
--success-fg and --accent-fg now sit on their own tints at
5.7–6.8:1 where the raw brand colour sat at 3.2–4.4:1. No BrandGuidelines V2.2
value was changed. Where a brand colour was the failing side the label moved
instead (white on brand orange, 2.46:1, is now near-black at 6.66:1), or white sits on
a new fill-only variant (--danger-fill, 5.80:1) that is used nowhere
except under a white label. Nothing was made to pass by making two states look alike:
the incident severities, the camera states, the run-editor phase chips and the four
traffic-light strips all still carry as many distinct colours as before.--yellow everywhere, which is excellent on the dark
sidebar (11.73:1) and close to invisible on every light surface in the app — 1.39:1 on
white, 1.21:1 on the page ground, and measured live at 1.23:1 on a modal's close
button. It is now the brand blue at 6.46–7.44:1, with the sidebar keeping the yellow,
because blue on the sidebar's near-black would be the same defect with the colours
swapped. The amber traffic-light strip, the other non-text failure in the audit, went
from 2.37:1 to 3.42:1.tools/contrast-check.js reads all three apps' stylesheets, resolves each
app's custom properties and reports every colour pair with its ratio and where it is
declared; tests/suites/92-contrast.js drives it over the real CSS on every
test run. The 26 pairs still failing — all of them in the client portal and the driver
app, which this change could not edit — are held as a named budget that reddens if it
grows or if a line on it quietly stops failing. The September audit's own
figures were re-measured with it: it reproduces that document's 511 declarations, 234
distinct pairs and 887 unresolvable declarations exactly, and corrects its headline
from 48 failures to 46 — two rows the audit's own footnotes had already
excused were counted anyway. This is not a conformance claim: four of the six criteria
that audit found failing are untouched.(res.error) ? 'failed' : 'nodata' — so "we asked and this road has no
posted limit" was the else, and a lookup that never left the phone (the adapter's
own in-flight guard, which returns a bare null) printed "No posted limit
mapped here": a positive claim about a road, on the strength of a request nobody made.
There are now three distinct answers behind the hatched panel — none mapped
here (something answered and had nothing), could not check (429, timeout, dead
zone) and still checking (nothing was sent) — and the cab reads the reason the
lookup itself reports rather than inferring one, so a plain throttle echo is no longer
reported to a driver as a failure. All three stay hatched, because all three are still
we do not know; only one of them is a claim about the road.NF.depots, and a depot was not an
NF.Registry kind at all — excluded on the recorded grounds
that depots are “keyed id === code”, which is
false for 3 of the 7 shipped rows (KAR/KTA,
NEW/ZNE, PER/HAZ). A depot
is a kind now, and a code already held by another live yard is refused by
name, in the {ok, error, message} shape, printed in the form rather
than as a toast. The refusal names the row it collides with by name and by
id, because two rows both reading Tom Price are one yard to every
screen in the app — naming only the name would have printed
“already Tom Price” on a row already called Tom Price. The id
stays guarded separately, because a depot has two keys and both are live: the
id is what a con note, an asset, a lane and a scope quote, and the
code is what the rate card, the fuel levy and the glossary resolve.NF.depots exactly one
applies active !== false, so an archived yard would still get a nav
entry, a page, a map pin and a place in every picker. Whether a depot's id and
its code are the same field is still an open question, and nothing here decides
it or touches an existing row.NF.Registry.depotConflicts() counts collisions, not rows
— the report of “eight rows with a duplicate code” reads
as anything from one collision to four, and the wider reading over-scopes the
repair. Three fields, because they fail three different ways: a duplicate
code mis-prices and mis-labels, a duplicate id is a join
collision, and a duplicate name is what makes either of the first two
invisible — so the name is reported and never refused, two yards in
one town being free to share one. Archived rows are counted and flagged, and a
list that has not been read answers we could not look rather than nought.
Measured on the shipped seven yards: 0 collisions of any kind, so the
guard is forward-looking and the seed needs no repair.✓ No empty legs detected — fleet
running full. printed in green whenever the backload scan came back empty,
so an empty board, an unreadable board and a genuinely full
fleet were the same tick. Measured on the live site on 3 Sep 2026: NF.schedule
held 0 lanes and GET /state carried no schedule key at
all, so the live rendering of that sentence was derived from no data whatsoever. The card
now has four answers, keyed on the read's own answer (NF.Store.lastSync())
and never on a count of legs returned — a count-shaped test would print a failure
notice on the ordinary case every day, which is the distinction #227 drew one screen over.
A board the database has not answered for is drawn hatched and names the read that
did not answer; an empty board says nothing is rostered and which empty it is (no rows at
all, or a depot filter), dimmed and dashed because that is a known negative; a board of
nothing but hired-in and projects rows says nothing of ours was checked, since a
subcontractor's empty return is not ours to sell. The genuinely-full wording is
unchanged to the byte, and the count beside the heading is a dash rather than 0
in the three states that measured nothing.utilisation-scope.js already refuses for the same reason.verified:false and
“Identity is not verified — there is no sign-in yet” into the
object every one of its seven answers extended, including the
Authorised to cancel a con note success path. Once Microsoft single
sign-on landed that sentence was false: the acting person is now set from the
verified identity rather than picked from a dropdown, so an authorised
operator who had genuinely signed in was told on screen that the cancellation
was a record of who said they did it. It now reports which of three
states it actually is — verified by single sign-on, a claimed name nothing
verifies, or no acting person at all — and the unverified wording is
NF.StaffIdentity.note()’s, so “not configured”,
“signed out” and “signed in and the API refused it” stay
three sentences with three different next actions instead of drifting into a
tenth copy.
tests/run.js; reading the
session’s own verified flag straight through, without checking it is the
same person, reddens 10. A fourth site the issue’s own
grep could not see — the “nobody currently holds the
role” refusal, whose claim about verification is a literal inside that
branch’s own sentence — moved with the other three, and still reads
exactly as it did on a build with no sign-on.APP_VERSION at release, so merging alone leaves installed cabs serving
the old screen.Closes #617, #628
closes #617 and does nothing whatever to #628 — the keyword
binds to the one reference that follows it and the rest is prose. The
fixed-but-open issue that leaves behind is indistinguishable from real
work: de-confliction by branch name and open PR cannot see the class at all,
so a wave on 3 Sep put agents on four issues that were already
fixed, and a sweep on 14 Aug found thirteen open issues that had
shipped. Measured over this repository on 4 Sep 2026 — 346 merged
PRs (#1–#675) and 264 commits on origin/master:
12 malformed occurrences in PR bodies plus 2 in commit messages;
the reference first after a keyword closed 12 of 12; a reference
after a comma closed 0 of 37; and 12 of the 37 orphaned
references are still open today. The one apparent exception measured out —
#183 was closed by the commit message Fix #183 — LMS:
… in c27d21b, not by the comma — which is why
tools/close-keyword-check.js reads commit messages as well as PR
bodies and carries --commits <range>. Verify-to-fail: making
the comma form pass reddens 13 assertions in
tests/suites/91-close-keyword.js, dropping the negation reader
10, and treating a code span as live text 6.Filed, not fixed:
#288, #289, #290 closed #288 three seconds after the merge and it
was reopened by hand 7h39m later; PR #318's Referenced as open decisions
rather than closed: #66, #242, … closed #66 two seconds after
the merge with state_reason: completed — from a
docs-only PR that changed no code — and #66 is still closed
today, marked priority:high. In that one line the two bugs
cancelled 19 times out of 20 by luck. A wrongly closed issue is worse than a
fixed-but-open one because nobody is looking at it, and the remedy is the
opposite of the comma case: remove the keyword, do not add more. The tool
reports that class separately and prints Refs #66 as the rewrite.docs/modules/backlog-process.md state the rule
with the broken form quoted verbatim without the guard eating its own
documentation. The pre-merge gate is not wired yet: it needs a
pull_request job reading
github.event.pull_request.body, and .github/workflows/
was outside this branch. ci.yml also checks out at the default
fetch-depth: 1, so the suite's history scan sees one commit
there — it says so out loud, with the depth in the assertion label, rather
than passing green on a scan that read nothing.complete, how many
days of the window were actually read, and the sentence naming what is missing.
The read now stops itself inside the function's lifetime and answers with
all of that intact, keeping every day partition that did come back.unfinished beside
unread, each with its own sentence, and calls itself incomplete on
either. Where nothing came back in time the route still answers 5xx rather
than an empty list, because an empty list is indistinguishable from a fleet that
recorded nothing.reads counts day partitions; each one is really one database round
trip per 500 rows, so at the fleet size recorded here a 90‑day report is
twice the round trips it appeared to be, and that multiplier was visible nowhere
— no field, no log line, no test. Responses now carry calls
beside reads. It is reported and never enforced: a limit on round
trips would mean stopping half way through a day, and a half‑read day
cannot say what it missed. A store that cannot count its own reports nothing
rather than zero.backend/deploy.sh wrote an empty
apiToken into config.js whenever
API_TOKEN was not exported, justified by a comment two lines above
the write saying “empty means the front end sends no Authorization
header and the fail-open authorizer allows it”. The authorizer
fails closed, and has since #64 — so that deploy published a site
its own API refuses on every route, and the symptom reads as the API
being down rather than as the deploy being wrong. The comment was not
decoration: it was the reason no guard existed. It is corrected in place as a
correction — was X, is Y, since #64 — and the authorizer, which is
right, is unchanged. The token is now resolved before sam build
the way the app resolves it (API_TOKEN if exported, else SSM
/fleetview/api-token, the same parameter the authorizer reads), so
nothing has to be typed.apiBase stopped
config.js parsing at all. The API URL was the one AWS CLI
capture in backend/deploy.sh not passed through nocr,
so on Git Bash a trailing \r landed in the published
apiBase. Measured 2026-09-05, the consequence is not an invisible
character in a URL: a \r inside a single-quoted JS string is a raw
line terminator, so node --check reports “Invalid or
unexpected token”, window.NF_CONFIG is never assigned,
and js/data.js falls back to apiBase: '' — a
deployed site quietly running local-only against localStorage, with
nothing on screen saying so. Every capture was audited by producer rather than
one line fixed: backend/deploy.sh had 1 AWS CLI capture and
0 through nocr (the file defined none),
backend/deploy-guided.sh 4 of 4 already clean and verified
rather than assumed. It strips rather than refuses, because a carriage
return cannot be seen in the message that would refuse it.AccessDenied, an
expired login or the wrong region establishes nothing about what is
stored, so it refuses with its own claim and says in as many words that this is
not “nothing stored”. An AWS error the script does not recognise is
classified as could not look rather than folded into nothing
stored. Both refusals happen before sam build, so nothing is
built, deployed or published — this script's convention is to die before
deploying and only report afterwards. API_TOKEN_EMPTY_OK=1 is the
one deliberate override, matched as exactly 1 rather than by
truthiness (=0 reads as no to a human and yes to
[ -n ]), and it answers only nothing is stored: it is
a statement about what is stored, and a run that could not look does not know
what it would be publishing. The token value is printed on no path, including
the SSM read and including the refusals — the parameter and the remedy are
named, never the value (#292).backend/tests-deploy-config.js runs backend/deploy.sh
end to end against stub aws and sam first on
PATH — no account, no credentials, no network, and no real
token anywhere in the suite — through all three token states, the
override and its two refusals, asserting exit codes, which claim was printed,
that nothing reached sam on a refusal, and that the value never
appears in stdout, stderr or the recorded argv. The carriage-return guard reads
the bytes of the written file, not the shell variable, because that is
where the defect becomes one. It also carries the nocr audit as a
standing assertion over both scripts, and bash -n over both, which
nothing in CI did before. Picked up by the backend suite glob with no workflow
edit. Verify-to-fail, measured off the runner's own summary line: restoring the
old token shape reddens 48, removing the one | nocr reddens
4, and backend/deploy.sh exactly as it was reddens
53.NF.vehicles[Math.floor(Math.random()*NF.vehicles.length)] and wrote
that random truck — and whatever driver was recorded against it —
onto the job and onto the consignment record, then put it on the roster:
a scheduling decision made by a shuffle, reading as fact on the run sheet, the
driver's app and the con note, with none of the engine room's conflict guards in
the loop. A column is a status, not a rig. The drop now moves the
status and leaves the allocation exactly as it found it; the card is drawn
hatched as No rig allocated and keeps saying so after the toast has gone.
It is not refused — this board warns, it does not block — and the
▸ Assign control now follows the unallocated job into whichever
column it is in, instead of only appearing in Unassigned.NF.vehicles and threw inside the drop handler, so
job.status was never set, no toast fired, and the card just sprang
back — with the browser console the only trace. That is the deployed
shape, not an edge case: the sample fleet is dropped unless config.js
says demoData:true. There is now no read of the fleet register in
that handler at all. Measured: restoring the original line reddens 24
assertions in tests/run.js (15 for the fabrication, 10 for the
empty-fleet throw, one shared) and 14 of the 27 new checks in
tests/manual/board-conflict.js (11 and 3, no overlap).askedFrom (fix or centre) and the coordinate
it was measured at, and the response counts the two, so "we found nothing" from a
real position and "we found nothing" from a point in the scrub are no longer the
same finding. A representative position that does not actually fall inside the
cell it is filed under is refused and the centre is used instead, so a wrong input
narrows to the old behaviour rather than caching an answer about the wrong ground.tests/requirements-count-check.js
matched a definition bullet only where the closing bold marker followed the digits
immediately, so the six ids carrying a letter suffix — TR-125a–d
and XP-902a/b, ordinary peer bullets cited by
99-invariants.md and by other requirements' own text — were invisible.
The register defines 629 and the check reported 623. It passed because
docs/requirements/README.md carried the same six-low figures in four
places (the headline, its 515 + 108 decomposition, the TR-
and XP- breakdown rows, and the total): both copies of the fact were
wrong together, which is the exact failure #352 built this check to prevent.
Fixed both halves in one commit — headline 623 → 629, TR- 156 →
160, XP- 77 → 79, total 623 → 629 — and the counting
rule now says what a letter-suffixed id is. Found by #351's measurement, in the
register rather than by the check. Also corrected the re-base's own addition count
in the same table, stated as 96 one paragraph below where it is stated as 108 twice
above; measured across the re-base commit, it is 108 (521 → 629).
The check's "cross-checked two other ways, both agreeing at 515" is
not contaminated: measured on its own authoring commit the folder held
zero suffixed ids, so all three figures were right on the tree they were taken
from — the six landed on two other branches the same morning and the blind spot went
live at the merge, which is a lesson about corroboration rather than about arithmetic.js/wethire.js was removed by #390 on 22 Aug; four test suites
and two module docs still told their reader the vm loads it — and not
decoratively, since every one of the six is explaining why a given bug cannot be
caught in tests/run.js, which is the load-bearing distinction
between the node suite and the browser suites. tests/run.js itself was
already correct. Separately, docs/modules/file-splits.md stated the
opposite error — that the file "is not in this repo and never was (no commit in
history touches it)" — and two commits touch it. The cause is an
instrument that lies: origin/master's history begins 23 Aug, both
commits pre-date that root, so git log -- js/wethire.js exits 0 with
no output, which is indistinguishable from a file that never existed.
git log --all finds both. We could not look was written down as
there is nothing.tests/suites/NN-name.js and the number is the
running order, so two briefs claiming 61-driver-outbox.js and
61-levy-editor.js claim two different paths — the pre-flight
collision check cleared them, correctly on its own terms, and both sessions then
created a suite numbered 61 because each reserved “the next free
number” from the only tree it could see. Measured on master: seven
numbers shared by eighteen files in three days, from two coordination
sessions working one backlog without seeing each other's branches.
tools/preflight.js now reads a claimed suite path as a claim on the
number as well, against the other briefs and against the directory
as it stands, and hands each colliding brief a different free number —
one suggested number being exactly what two sessions both take. A brief claiming
a suite that already exists is editing it rather than minting a number and is
never flagged; a claim naming no particular number
(tests/suites/*) is reported as not checked on this axis
rather than folded into the pass; and a free number is always qualified as free
in this tree, because a number nothing claims here may already be claimed
on an unmerged branch, which is the root cause rather than the cure — measured,
the two lowest numbers free on disk were both claimed on branches nobody had
merged. Verify-to-fail: neutering the axis reddens 9 of 58 checks in
tests/preflight-check.js, unwiring it from the report 6,
handing both briefs the same number 2, and dropping the
already-exists exemption 1.tests/run.js globs the directory, sorts by whole
filename and enforces only the NN-name.js shape, so files sharing a
number both run — in alphabetical order of their suffixes — and
// @needs <suite-name>, the one real dependency mechanism, is
keyed by name expressly so a rename cannot rot it. One number one suite is
therefore a convention, kept so the running order is intended rather than
accidental, and not a correctness requirement: a duplicate costs nothing at
runtime, and when a bad ordering does bite it is a loud ReferenceError
naming the missing binding rather than silently different output. This is worth
stating flatly because getting it backwards nearly bought a renumbering of
eleven files — and the renumbering is the option with real cost attached,
since @needs survives a rename but the 18 prose citations of
those filenames in js/, docs/,
tests/manual/ and changelog.d/ do not, and no check
sees a stale one. Recorded in docs/modules/testing.md,
docs/agent-dispatch.md and the checker's own header; no suite was
renamed.backend/src/handler.js carried a raw NUL
byte — canon()'s undefined sentinel, typed as a byte
instead of as the \x00 escape. GNU grep classifies a file as binary
on the first NUL and then prints binary file matches instead of
lines, with exit status 0, so a search that found 40 matches and one that
found none were indistinguishable and neither looked like a failure. In a repo
that measures by grep, that is an instrument reading zero without saying so.
The sentinel's value is unchanged. A sweep of all 671 tracked files found the
same mistake in two more — four separators in
backend/tests-store-postgres-telemetry.js and a join delimiter in
tests/email-intake.js — and a new static check
(npm run checks, and its own step in CI) now refuses a NUL in any
tracked text file, deciding text from binary by an explicit extension list
rather than by “it contains a NUL”, which would be the circular test
that passes on exactly what it exists to catch.localDay()'s own comment has always said “local day, not
UTC. WA is UTC+8 with no DST”; the code read the process timezone, and
no function in the stack sets one, so every day written between
00:00 and 08:00 AWST filed against the previous calendar day —
eight hours in twenty-four, and for an operator whose drivers start before dawn,
the busiest third of them. That day is stamped on every audit row, every history
bucket, every geofence transition, every derived speed exceedance and every daily
rollup, and the seven-year retention floor is derived from it per row. The offset
is now applied explicitly in code rather than through a deployment setting, so it
holds in the test suites, the maintenance scripts and the unattended mail
realm as well as in the Lambda — and the guard re-runs itself under four
timezones, because the four suites that did check the day key each
computed it from the same wrong clock the code did, so both sides agreed and
nothing could see it. Nothing rewrites a historical row. Two things to know:
this deploy should be cut inside the 08:00–24:00 AWST window, and the audit
trail's own date filter is not fixed yet — it is right for rows
written from now on and still wrong for older ones, which needs the database
adapter taught to filter on the timestamp instead of the stored day string.GET /fleet/history now accepts fields=p and returns
time and position per sample, dropping speed, heading and the stamped posted
limit. It exists because the obvious alternative was tried and refused:
fields=s drops latitude and longitude, and trip detection on the
lane report is nothing but position, so it took detected trips from 1,453 to
0. The rows read, the days read, the request count and the 2,000-point cap
are all unchanged — only the columns narrow, and that is asserted rather
than promised. A sample the recorder marked as a frozen-receiver echo keeps that
mark, because to a position-only reader a stuck GPS is indistinguishable from a
parked truck and would be detected as a depot visit. The response also now names
the field list it actually served, so a caller that asked for something we do not
serve can tell, instead of quietly getting more than it wanted. The lane
report itself is not switched over yet — the backend serves the value and
nothing calls it.localStorage, with no expiry
and nothing that ever removes it, on a page an anonymous customer reaches from a
link in an email. Other accounts' consignments, quotes and negotiated terms were
in it, as were drivers' phone numbers and licence classes. The portal now
declares the fifteen collections its own screens actually read and holds nothing
else; the other 34 are never installed and never cached. The dispatcher and the
driver PWA declare nothing and are unchanged, because an undeclared app still
gets everything. What this does not do: the API still returns the whole
document, so this narrows what is kept and what the page can send, not what
crosses the wire — that half needs the server.0 for a fleet nobody
could see. Every read of GET /odoo/assets has stated its own
denominator since the honesty work went live — meta.verdict,
meta.total, meta.totalIncludingArchived — and
measured across the dispatcher, the client portal and the driver app, not one
file read any of it. The import report showed the length of the list it got,
so on the live database it said 0 vehicles read flat: the same figure a
database with no vehicles would show, on a read that in fact could not see the
fleet at all. The report now sources the count from the verdict. A measured read
carries its fraction (500 of 900) and names what fell off it,
including the archived vehicles that are deliberately not imported; the seven
states where the figure is not a measurement are drawn hatched and
each says UNKNOWN in words — a total nobody counted, a live count
with no archived count, a read and a count that disagree, and the one the live
database is in today, where Odoo answers zero to this key and a record rule does
that exactly as readily as a deletion. A payload that states no total at all is
its own third state (never looked, not counted zero), and so is a
verdict this screen has never heard of. meta.available stays a
boolean: two consumers branch on it and the third state lives in the verdict.#/overview called NF.Coaching.effect() twice
per draw over two different windows: the Needs-attention card off the
60-day daily summaries, the Decisions-waiting list off the 90-day ones. That
function has no window of its own — the array it is handed is the
window — and its verdict turns on whether 200 km was driven on each
side of the conversation. So an asset whose distance straddled that floor between
the two windows was escalated by one card and silently absent from the other,
four cards below it. Measured on a fixture built to straddle: the old expression
escalates 2 assets off the 90-day read and 1 off the 60-day one,
and the asset it disagrees about comes out worse at 90 days and
early at 60. On a wall display that is two numbers a dispatcher has
to reconcile, with nothing on either card to reconcile them against. The window
is now a property of the derivation rather than of the read: whichever
rollup it is handed is narrowed to one named window first, through the shared
instant-based window rule, so the two reads give the same answer and a third
caller could not quietly narrow it either — and it is derived once per
draw and passed to both panels, the way the service figures already are,
because the second panel is filled in after an await during which a
sync can replace the conversation records under it. Sixty days survives,
which is the narrower of the two: it escalates exactly the assets the
Needs-attention card escalated before and no others, where 90 days would have
named drivers no card on this screen named. Which window the escalation is
entitled to is still an operational decision, and it is now one constant to move
rather than two cards to reconcile.200 km on one side of
that conversation, earns no verdict — and until now that read as "no
problem here", because the card printed only the escalations and the panel's
empty state said "nothing outstanding — no unheeded conversations".
Both cards now name those assets in their own item, drawn in the 135° hatch
this app uses everywhere for we do not know, and they distinguish the two
reasons, which are different facts: we looked and there is not enough distance
yet, against no sample points were recorded at all, so nobody measured
this asset — the second read off the measured flag put at
the source of the rollup derivation, rather than a second local notion of
“enough data”. Every card carrying a coaching count now also names
the window it measured over, and a draw where the effect could not be derived at
all says so instead of printing a clean card. Counts and names throughout, never
a percentage.?t= of the link we email a customer — was
20 characters of Math.random(), justified in the code by the
sentence "so knowing one link tells you nothing about anyone else's."
That claim is not true of that generator: its outputs are predictable from
earlier outputs, which is exactly what the quote-acceptance module said
about the same primitive sixty lines below while refusing to use it. So the
asymmetry was deliberate and documented, and the decision to keep it rested on
a property the implementation did not have. It compounded twice over, because
minting a token persists it into the one shared state document and the customer
portal caches that document — so an anonymous visitor's browser held
many consecutive outputs of that generator at once, which is a better
position for recovering its state than a single sample. Both link types are now
minted by one shared mint: 192 bits from crypto.getRandomValues,
hex-encoded, and where no CSPRNG is reachable it refuses and returns nothing
rather than falling back — pathFor and linkFor
answer '' so a caller has something falsy to test, and the pasteable
customer update drops the "Track it here" line rather than printing it over
a blank. Measured: reintroducing the Math.random() mint reddens
9 assertions, and removing the refusal from the shared mint reddens
8.cmpActor) was written only when the account matched a person on the
register — so a genuinely signed-in account matched to nobody kept its
module default of L1, while the registry and address-book selectors
moved to the account. That is the middle of three identity states being folded
into a neighbour by a default: verified-and-matched, verified-but-unmatched
and absent are three facts, and code branching on the person id existing
collapses the middle one. Everything behind cmpActor files against a
person id — a credential verification, a rejection, a waiver revocation, the
audit evidence pack, a group archive, the four Learning writers and the con note
cancellation — so an audit row could record a placeholder as the person who
acted. It is reachable with a real Entra sign-in behind it, because a break-glass
admin listed in sso.admins is verified, matched to nobody, and
not read-only. All three selectors are now set in both verified branches,
and the unmatched one takes the account (the address it signed in as),
which deliberately joins to nothing on the register: roles read back empty, the
actor bar says no role assigned rather than reciting a placeholder's roles
beside the signed-in name, and the default-deny on connoteAdmin holds
instead of being satisfied by whoever L1 happens to be. Measured: with
the placeholder default, a con note cancellation on a session that verifies nobody
did not merely name the wrong person, it answered yes. The same defect
mirrored is closed in the same place — a session stops being verified
in-session when its token expires or the API refuses it, and the selectors are put
back to what the selection state held rather than keeping an account id in a
control that has gone back to being a dropdown of people. Clearing them instead
was refused, because that folds the middle state into absent, whose next
action is different. The unverified path, which is what ships while
sso is null, is unchanged.ENVIRONMENT
was set on 0 of the 6 functions in the backend stack, so the natural way to
write an environment gate — if (ENV === 'production') refuse() —
would have permitted the dangerous act in production, because ENV
was undefined there and undefined !== 'production'. It is
now wired onto all six from a new DeployEnvironment template parameter,
settable on the backend deploy. Nothing keyed off it yet, which is why this landed
before the first gate rather than after it.environment key already fails in: read as production an
unset value fails silently and towards the danger, read as undeclared it fails loudly
and towards safety. A gate must therefore be a positive allow-list; a new static check
refuses any equality test against 'production' anywhere in the backend
source, because the branch such a test does not take is reached by both
“declared non-production” and “undeclared”. The permitted
vocabulary is read off the browser's own resolver rather than retyped, so the two
halves cannot drift onto different words.--parameter-overrides only
when it is set, so a deploy that leaves the box empty keeps the stack's previous
declaration — which deployment a stack is is a property of the stack, not
a per-deploy choice, so an omission yields either the truth or undeclared and
never a false claim of production. That is deliberately unlike recorder
and telemetryMode, which are re-passed every deploy precisely to defeat
that stickiness. A backend deploy passing production is still needed
before any gate keys off this, or the live stack stays undeclared.-31.95, 115.86, so a yard nobody had surveyed arrived in the 4 km
arrival geofence, in NF.nearestDepot and in NF.areaFor
as a measurement, up to 1,500 km from wherever it is. A position is now
refused by name, saying what it decides and that FleetView will not guess it;
half a coordinate is refused too, because one number is not a place; and an empty
box is no longer read as the equator (Number('') is 0, and 0,0 is a
place). A row that already holds no coordinates is drawn hatched as not
placed on the depot list rather than left to read as placed somewhere.toast('Enter a code and name'), which slides away while
somebody is still reading it, and there was no NF.Store.save()
anywhere in it — an edit reached the database only if the five-second
autosave happened to fire before the tab closed, with no ack and no error
surface. Refusals are now the writer's own sentence in the
{ok, error, message} shape, printed in the form in place, and the
write goes through save(), which owns the queue.id === code is the model for a depot is still an
open question for Simon; until it is answered, the divergence is at least visible
to whoever is about to edit the row. Note what is still missing and is not a
screen change: a depot has no archive control at all, because
NF.Registry holds six kinds and no depot, and the
absence of an archive button is the incentive for the wrong edit that #603
reports.GET /fleet/gpswox is being changed to
refuse an undeclared read rather than answering with every truck's
position, so the dispatcher — which legitimately wants the fleet answer
for its telematics screen and the asset page's GPSWOX panel — now sends
?scope=fleet. This ships ahead of the backend deliberately:
the deployed route ignores an unknown query parameter, whereas the reverse
order would empty both of those screens for the whole gap between the two
deploys. The declaration is conditional, which is the load-bearing part:
the adapter lives in the shared layer and its load-time subscribe starts the
same poll in every app that loads it — including the client portal,
on the anonymous tracking page a customer opens from an email, four times a
minute, which is the exposure being fixed. An app that has declared a narrower
state scope declares no GPSWOX scope either and is refused, so the narrowing
cannot be undone through its own new front door. Measured: deleting the
declaration reddens 2 assertions and making it unconditional reddens the
same 2 — both directions, because the two apps' request URLs are
asserted to differ.GET /fleet/gpswox now
answers a question rather than a request: a read carrying a consignment and its
tracking token is verified on the server against the stored record and
gets the vehicle on that one shipment, projected to seven position fields with
no IMEI, no plate and none of the unit's sensor payload; a read that declares
scope=fleet, or that presents a signed-in staff identity, gets the
whole fleet exactly as before. Narrowing this in the browser would have left
every other truck's position in the response and in the tab, which is the same
exposure with extra steps. Measured: reverting the scope reddens 67 of 89
assertions in the new backend/tests-gpswox-scope.js; dropping just
the field projection reddens 4.devices key at all rather than
answered with an empty list, because an empty list reads as a fleet that is not
reporting. A scope name the route does not know is refused too, not widened: read
the other way, a typo would mean "everything". This is the same direction an
unset environment and an unknown state scope already fail in. Stated plainly:
this is data minimisation, not access control. It decides what a page can be
handed and cached; anyone holding the published token can still ask for the fleet
scope directly. Also: an unknown shipment, a wrong token and another
shipment's token all return the same refusal byte for byte, so the link cannot be
used to find out which consignments exist; a shipment with nothing allocated yet,
a truck whose unit is not in the poll, a poll that came back empty and a vehicle
the register cannot identify uniquely are four different answers rather than one
empty list; and a register lookup that fails is reported as ours failing, never
as the tracking provider being down.tests/lib/stripcomments.js ended a template literal at the first
unescaped backtick, so ${a.name ? `<div>` : ''} —
the normal shape of every rendering function here — mis-paired the
delimiters, and the closing tag it then read as code opened a phantom regex
literal that ate the template's real closing backtick. Every comment after that
point in the file survived: measured, the last 43,150 bytes of
driver/driver.js and 42,825 of 86,893 — 49% of
js/screens/26-asset-page.js. It now tracks nesting on an explicit
stack, so template text and interpolation code are separate states and a
template inside an interpolation is entered recursively; comments are still
blanked in place, so line numbers survive. Both files now strip to zero
surviving comment markers at the start of a line, asserted on byte counts rather
than a spot check.${…} holds real JavaScript. Template text is string
content the browser receives, so an HTML comment in markup or a CSS comment in a
<style> block is deliberately kept: the pre-fix scanner
blanked 231 bytes of exactly that in
js/screens/58-assistant.js, which is the one case where it destroyed
live text rather than keeping too much.70-po-refusal.js against 5,588 — and two were not
exposed at all. The exposure figures, and the fact that a branch reorder inside
the scanner reddens nothing while substituting the two-pass regex idiom reddens
16, are recorded in the suite so neither has to be re-measured.active !== false filter — harmless until the asset page landed an
entry form, since nothing could archive a document before that, and a live defect
the moment it could: a superseded registration drew exactly like a current one, and
a truck whose only document was archived reached the row branch, so it
showed that one stale document and never reached the honest Nothing recorded
state at all. Second, the key a document is filed under and the id the cab reads it
back with are routinely two spellings of one truck (NF104 against
NF-104, a unit id against a plate), and the cab is the likeliest
holder of the other one — so a real document produced "Nothing recorded",
which is an honest-looking sentence stating a falsehood. The read now goes through
the one spanning lookup the asset page already uses, filters on
active !== false rather than a truthiness test (every document the app
ships carries no active field at all, so !d.active would
retire a compliant truck's whole card), and filters at the read so the row branch
and the empty branch can never disagree about what is current. Measured on the
shipped text: reverting the line reddens 15 assertions, dropping only the archive
filter reddens 7, dropping only the spanning read reddens 4, and writing the filter
as a truthiness test reddens 7. A release has to be cut for this to reach a
tablet: the driver app's offline shell is precached under a cache name stamped
from APP_VERSION at release, so merging alone leaves installed cabs
serving the old screen. The card still has no browser-level check in
tests/manual/.tests/suites/66-asset-docs.js
and tests/suites/53-reports-performance.js.NF104 against a register that spells the truck
NF-104 — or filed under a unit id against a row that carries a plate
— printed Nothing recorded for an asset whose registration and insurance we
hold. It now reads through the one shared identity rule, with no fall-back to the
bare key, because a fall-back is the second rule the fix exists to remove. An
archived document is still left out, filtered once at the read, so a row in this
table is a document in force and an asset whose only record was retired reads as
empty rather than as current. Reverting the spanning read reddens 6 assertions;
switching the filter to a truthiness test — which loses every document entered
before the field existed — reddens 4.#asset-roll, the assistant's recommendation panel through
rollScopeBits(), and speedAlertScan()'s own 8-day
narrowing read by carrying the answer out on the scan for the Events log to
paint through the same shared painter — so nothing on any screen
changed and no code needed changing. What changed is what stops it being
quietly undone. That strand was held by three regexes over the app's source
text, and measured on a clean checkout those covered one of the scan's
four exits: dropping the carry from the refusal exit reddened 0
assertions, and dropping it from the no-candidate exit reddened 0.
The refusal exit is the one the Events log's own comment calls the one that
matters, because a refusal is when that log is emptiest and least able to say
why. The shipped scan is now called for real, out of whichever file declares it,
and its answer painted through the real rollScopeInto() with the
Events log's own impact sentence read out of that screen — so every
check reads what a dispatcher would read, not the object it was built from.
Measured: dropping the refusal carry now reddens 5 and the no-candidate
carry 4 in tests/suites/59-rollup-coverage.js (64 → 93
assertions), against 0 and 0 before, in that suite and in
tests/manual/rollup-scope.js alike. Also newly held: a caller that
hands its own read in is charged for no second read and is disclosed
against the read the alerts really came from; the recorder path stays silent
because no summaries were read at all (scan.rolls || {} is the
plausible mistake, and it reddens 2); an explicit
complete:true read still gets no second paragraph; and a recorder
reporting neither still gets the third wording rather than being folded into
either.
This is latent, not live, and worth saying so. Every window in play is 90
days or fewer, inside both the 120-day front-end clamp and the backend's
128-partition cap, so a trimmed read is unreachable by construction today
and only a dead partition's unread can bite. Nothing is wrong on
screen this morning. The disclosure exists so that a read which came back short
cannot be presented as a whole one, and so a future report asking for a wider
window does not silently inherit the gap — and a guard nobody can break is
not holding that line, however green it reads.doc->>'person' yields
'' for a key that is present and empty, that ''
violates a foreign key, and that no FK can therefore go on any of the citing
columns as they are defined today. All of that reproduced verbatim on
PostgreSQL 16.13 against the shipped DDL. What it does not carry is the
write ordering: PUT /state pushes every FK-candidate child
row before the person row it cites — consignments, form
submissions, course submissions, credentials, quiz attempts and session records,
then the person collections — in one array and one transaction, so a plain
foreign key refuses that order outright and a dispatcher who adds a driver and
files their licence in the same five-second autosave gets a failed save.
NULLIF does nothing for that. The only workable configuration is
NO ACTION DEFERRABLE INITIALLY DEFERRED, which is measurably legal
on a generated column and which RESTRICT cannot have — under
RESTRICT the kind-scoped replace the schema makes mandatory is
refused. The brief also finds nine citing columns rather than
seven (the two session-record ones are absent from every list, including the
schema's own, while its argument names the session attestations), and answers
the issue's own unmeasured question: '' is reachable today,
through two shipped writers, one of them the credential writer whose guard tests
== null where its two siblings sixty lines away test
=== '' and refuse. Row counts in production are written as
unknown and never as zero: there is no endpoint in the repository and the
one read that could answer it is sysAdmin-only. The sample dataset
is 0 empty strings and 0 orphans across all nine, identical at seven clock
settings.ON DELETE CASCADE
— the most destructive one in SQL, accepted, and exactly what somebody
reaches for when RESTRICT starts refusing the replace. On a
50,000-row table, one kind-scoped DELETE FROM people under
CASCADE removed 103 person rows and all 50,000 credentials;
under RESTRICT the same statement was refused with every row
intact. So CASCADE has to be named as forbidden, which nothing in
the tree does today. Separately, DROP COLUMN silently drops all
eight indexes that include a migrated column, four of them composite, with
no warning — and nothing in this repository could notice, because there
are zero SQL predicates on any of the nine columns anywhere in
backend/src/. The rewrite everyone was worried about is 167 ms
per 50,000 rows and is not the cost that matters.backend/sql/001_business.sql lists driver among
cross-collection references that "are INDEXES, NOT foreign keys", and
backend/SCHEMA.md's open questions say "do not let a FK reject a
valid whole-document PUT" — against §2.1's "every one of them
becomes a real FK", added later for the person-table decision. Nobody had
noticed, because the two paragraphs are far apart and were written eight weeks
apart. That moves the disposition: if the first two win, the whole
NULLIF migration is work nobody needs to do, so the question goes
to Simon before anything is built. The person-table decision itself is
not reopened and does not need to be. Four alternatives are costed —
including an additive second projection column, which keeps every index, and a
sentinel person row with an empty id, which works and is refused because it
emits a phantom person into the reassembled driver list. One thing worth knowing
before the slice starts: there is no migration mechanism at all. The two
sql/ files are idempotent bootstrap DDL applied by hand, so editing
a column definition in place changes nothing on the live cluster and everything
on a fresh one.FD_FIX_DATE_ORDER. Re-measured against origin/master
be8c640: the deploy landed as run #115
(eda11195, 3 Sep 20:12Z, established by ancestry against every
run's head sha), both template parameters exist since run #116, and run
#120's own smoke test reports diag.dateOrder: "dmy" —
proved by the live feed's own arithmetic, never echoed back from configuration.
The field order is therefore measured, not a preference, and the one
outstanding action is a deploy input. The variable is provably still unset
without an API token, because timeShape is assigned in
exactly one place, so a non-empty shape with badTime and
futureTime both 0 forces ambiguousDate ≥ 1,
which cannot happen once the order is set. New decision brief at
docs/decisions/602-null-ts.md; no code, test or configuration was
changed by it.ts null on 703 of 703 rows” cannot still be true,
and the current figure cannot be measured today — which is a third state,
not a zero. One row in the sweep of 4 September demonstrably parsed, and
mergePositions drops any position not re-seen within 60
minutes, so rows written null by the old parser aged out of the snapshot
within an hour of run #115 and no backfill is owed. The current
per-row count is not measurable: /fleet/live answers 403
unauthenticated, no token was held or minted, and the one second-hand window is
capped at head -c 400 — which #602's own two new diagnostic
keys pushed the vehicles array straight past, so the instrument that
produced the headline number can no longer see a single ts. What the
visible fields do support is a bracket: between 1 and 15 of the 16
positioned reads in that sweep carried a usable ts, reported as a
bracket because a midpoint would be invented.9/99/9999 9:99:99 PM, with every datetime built from the run-time
clock: the pre-#159 expression Date.parse(…) || Date.now()
dated 6 of 6 and was exactly right 0 of 6, returning a
four-minute-old fix as 147.7 days in the past and a 26-hour-old one as
177.7 days. The pre-#602 parser dated 0 of 6, all
badTime. So the refusal did exactly what it was built to do, and
“undo #159” would restore a line that answers confidently and is
never correct.futureTime: 0 settles nothing about the
timezone knob — in the first twelve days of a month every row young enough
to expose an offset error is refused as ambiguous before the future check
runs — which confirms #602's own sequencing rather than improving it.
Guessing the order instead of reading it is measured at 66 of 365 fresh
fixes dated plausibly and wrong by up to 178 days.ts breaks exactly one derivation, and the screen that
reads it least honestly is the busiest one. backend/src/handler.js
reads a vehicle's ts zero times — history samples are
stamped from the server clock and the merge expires on seenAt —
so movement history, the daily rollup, speed exceedances and geofence events are
all unaffected. But NF.fixState answers unknown, which
makes stale unreachable, and fixStatus then prints a
bare “Moving”/“Stationary” on the dashboard fleet
list and in every map popup — identical to a fix dated four minutes ago,
two states where this app's rule is three, with a
“last moved…” readout added on top. The asset page and
Assets Management already draw the third state properly and are the pattern to
copy. One consequence nobody had written down: a fleet-wide null ts
silently moves the position authority from Fleet Dynamics to GPSWOX —
measured preferring a 9-minute-old GPSWOX fix over a 4-minute-old FD one, correct
by the clause's own wording and undocumented until now. And the ts
in the issue's title is not the ts of the AWST audit-day work:
the audit one is the server's own Date.now(), never FD-derived and
never null, so the two do not interact and only the word is shared.head -c 400 body.json, and /fleet/live's diag object had outgrown it.
diag is served back out of the jsonb snapshot, so PostgreSQL orders its keys by length then bytewise and puts the longest one —
ambiguousDate — last: the two keys added by the undated-fleet fix pushed it past the window (the deploy of 4 Sep printed
"ambiguousDa and stopped) and pushed the whole vehicles array past it as well. So the one counter that proves
FD_FIX_DATE_ORDER reached the Lambda was invisible on exactly the deploy that set it, and a production configuration change could be
verified only on the deploy going green rather than on its effect. Raising the byte count was rejected — it works until the next key is added,
and it is a blanket permission to print whatever the body happens to hold, which for this endpoint is a rego, a driver's name, a street address
and coordinates per row. The step now prints named fields only: every key of diag, the snapshot's own age, and vehicles as
counts including how many rows carry a usable fix timestamp. Nothing from inside a vehicle row, and nothing at all from a body that did not parse
as a JSON object — an auth refusal or a proxy error page is bytes nobody here has read the shape of.diag keys it knows, and prints the names — never the values — of any key it does not,
so the next person widens it deliberately after deciding the value is safe for a public log. That is the property a byte cap can never have, and it is
the reason this was not fixed by raising the cap. Two readings ride with it, both of which go quiet once the configuration is right: the field order the
sweep's own arithmetic proves against the one the deploy set — a disagreement is a partial and silent dating error, not a visible one — and
a non-zero ambiguous-date count, which names both of its two possible causes rather than one. Neither fails the job: the endpoint serves a stored
snapshot for 15s and a stale one for up to 60s, and the rotation sweeps a slice of the fleet per cycle, so a tally read seconds after a stack update can
legitimately have been computed by the previous configuration, and failing on that would redden deploys that succeeded.GET /fleet/gpswox on the strength of an audit that named it
as the portal's position feed. Measured, it is not: the tracking map subscribes
to the Fleet Dynamics adapter, which polls GET /fleet/live every 15
seconds and returned the whole fleet, and the customer's own vehicle was
then picked out of it in the browser — which narrows what is drawn
and nothing at all about what is sent or cached. So an anonymous visitor
holding one forwardable emailed link received every Northfleet position four
times a minute for as long as the tab stayed open, and each row carried more
than a coordinate: the driver's name, the rego, the reverse-geocoded
street the fix resolved to, the odometer and the fuel level. A truck's position
is the location of the person driving it, so this was other people's data by
inference, and the driver's name removes the inference step.
GET /fleet/live now answers a question rather than a request: a
read carrying a consignment and its tracking token is verified on the
server against the stored record and gets that shipment's vehicle projected
to five position fields; a read declaring scope=fleet, or one
presenting a signed-in staff identity, gets the whole fleet exactly as before.
Two things are specific to this route. The customer's row is keyed by the
vehicle id their own consignment names rather than the feed's, because
Fleet Dynamics falls back to the plate for its own id — echoing it would
have handed over the identifier the last fix explicitly refused to project. And
a customer's read never sweeps Fleet Dynamics inside the request: it serves the
stored snapshot, says how old it is, and nudges the same one-winner background
refresher the dispatcher's poll uses, so a tracking tab keeps the feed warm
without being able to buy a 15-second upstream call. Measured on the new
backend/tests-fleet-live-scope.js (127 assertions): reverting the
whole narrowing reddens 89; removing just the capability path reddens
34; dropping the field projection reddens 8; dropping the row
filter reddens 14; keying the row by the feed's id reddens 3; and
in the browser suite, a portal that goes back to picking from the fleet reddens
11 of 136 checks.vehicles key at
all rather than answered with an empty list, because an empty list reads as
a fleet that is not reporting. A scope name the route does not know is refused
too, not widened: read the other way, a typo would mean "everything". The
declaration is gated on the app — an unconditional one in the shared data
layer would have handed the portal the fleet again through the new front door,
and that costs 9 of 136 browser checks when tried. Stated plainly:
this is data minimisation, not access control. It decides what a page can be
handed and cached; anyone holding the published token can still ask for the
fleet scope directly, which is the shared-token weakness, unchanged and tracked
separately. Also: an unknown shipment, a wrong token, another shipment's
token and a revoked link all return the same refusal byte for byte, so the link
cannot be used to find out which consignments exist; a vehicle the register
cannot identify uniquely returns nothing rather than a guess (removing
that refusal reddens 5 assertions here and 3 next door, and the fixture proves
why — the loose register row points at another customer's truck); and a
register lookup that fails is reported as ours failing, never as the GPS
provider being down.NF.Tracking.linkFor can legitimately answer nothing — and
the consignment's Customer tracking link panel drew that as an empty
readonly field under the words Not shared yet., with Copy link, Copy
update message and Email the customer all live. A dispatcher could copy an empty
string and email a customer a link to nothing. The panel now prints the refusal
in place, drawn hatched because we could not is not the same fact as
nobody has sent it yet, and offers no control at all over a value that
does not exist. The wording is the one the quote-acceptance panel already uses —
the two link types share one mint, so they share one sentence, and a browser
check reads that file and asserts this render carries it so the two cannot
drift.KAR/KTA, Newman NEW/ZNE,
Perth PER/HAZ) — so a stored scope read as unticked and
an untouched Save wrote it away. Measured on the seeded Depot
supervisors group: ['PER','KAR','PHE'] came back as
['PHE'], two of three yards gone. The scope is now read as an
id or a code and always written as ids, which is correct under
either answer to the open question of whether a depot's id and code are the same
field, and a no-op if they are ruled to be. A stored value naming no yard at all
is a third state and is neither dropped nor pretended to resolve: it is drawn
hatched, named in the note, and kept ticked so a save writes it straight back.BUN/TOM collision), and the refusal panel's suggested
remedy now names the option the dropdown actually offers — it said "or ask for
KAR" when no option in the list carried KAR. A filter held from
an earlier paint that still cannot resolve keeps its hatched panel and stays
visible in the control, rather than the box reading All depots while the
sheet is narrowed to nobody.background shorthand from a stylesheet that
loads earlier and wins on specificity, which reset the hatch to
none — measured in Chromium off the shipped stylesheets. An
unplaceable scope value is always ticked, so that was the whole population of
hatched boxes.backend/tests-backup.js derived the day it
expected from the process clock — which in CI is UTC — while
the code it was checking derives it in AWST (UTC+8, no daylight saving, the
#591 answer). For the eight hours of [00:00, 08:00) AWST, which
are the same instants as [16:00, 24:00) UTC, the two disagree by a
whole calendar day, so npm run backend went red on master with
nobody having touched it: measured at UTC 2026-09-04 16:10, 375 passed, 4
FAILED, got "2026-09-05", want "2026-09-04". The code was
right and the expectation was stale. For an operator whose drivers start before
dawn that window is also the busiest third of the day.
npm run backend now passes at every hour: measured green both
inside the window (AWST 00:21, 48 of 48) and outside it (AWST 09:22,
48 of 48), reading the command's own exit code rather than a pipe's.tests-backup.js,
tests-edi-coates.js, tests-fleet-history.js and
tests-store-postgres-telemetry.js each re-exec under
UTC, Australia/Perth and America/New_York
and must give the identical answer in all of them, following
backend/tests-localday-awst.js. The non-Australian zone is the
load-bearing one: it is the only one that catches an offset applied with the
wrong sign, which two Australian passes agree about. Each suite also
carries a block of day assertions that read no clock at all —
typed-out expected strings against explicit Date.UTC instants
— so the fault is now detectable at every hour rather than only during
the eight when the two conventions happen to differ. Measured: reintroducing
the process-clock derivation reddens 12 assertions in
tests-backup.js inside the window and 8 outside it (on
master it was 4 inside and 0 outside, which is why it survived), and
6 in each of tests-edi-coates.js and
tests-store-postgres-telemetry.js at every hour. The gate costs
about 8s more — ~33s to ~41s — almost all of it
tests-fleet-history.js, the slowest of the four.tests-store-postgres-telemetry.js keyed every telemetry row it
wrote with a UTC day and then read it back through the handler's AWST day plan;
it stayed green only because every read asks for a window of 24 hours or more,
so the newer partition absorbed the mismatch — and the one assertion in
it that pins day-independence by hand was pinning to the wrong day for eight
hours a day. tests-edi-coates.js built its delivery stamps from the
runner's wall clock, so the sentinel it asserts absent from every refusal
("today's date appears nowhere in the answer") was looking for the wrong
string in that same window. Both are now AWST throughout, so all four passes
build a byte-identical fixture and any process-zone getter that comes back
reddens. tests-fleet-history.js's own derivation was already
correct — #596 fixed it, and the getFullYear() a grep still
finds there is a comment describing what it used to be — so it was left
alone and only given the detector.GET /fleet/live with a token and
no scope declaration, so the route correctly refused it as an anonymous caller naming no consignment — and the step then reported that the API had
rejected the CI token, a confident and specific remedy for a secret that was never wrong. The serious half is the other one: it recorded the deploy
as unverified and carried on, so from run 122 onward nothing established that the handler answered at all, and a genuinely broken handler
would have deployed reporting exactly the same thing — the step's own comment calls that "a check that cannot fail, which is worse than no check at all".
The request now declares scope=fleet, which is the same declaration the dispatcher's own front end makes, and is the fleet-snapshot path this
check exists to prove rather than a per-consignment tracking read.scope_undeclared, scope_unknown and scope_unverified each fail the step with their own remedy naming the parameter
to add, because a scope refusal can only mean the workflow's request and the route's contract have drifted apart — there is no benign reading and no timing
artefact, and it stays true on every deploy until somebody edits the file. That is deliberately the opposite call from the ambiguous-date counter
beside it, which warns and never fails precisely because it does have a benign reading: the snapshot it is read from can predate the deploy that
changed the setting. Any other 403 keeps the token-mismatch wording unchanged. The classification comes from the reader that already parses the response,
off a fixed list of error names, so nothing arriving off the wire decides what the step does next and there is no second reader free to disagree with the
projection printed above it — and a body that was never classified is a third state, which prints the token wording followed by a warning that the
diagnosis is unchecked, rather than passing an unclassified body off as a classified one. One reading returns with it: the fleet diagnostics are in the log
again, because a refusal body carries none at all.run: block that contains a ${{ }} expression at 21,000 characters, and that is not a YAML rule — so the
check that reads every workflow parsed the file perfectly while the smoke-test step's block sat at 22,887 characters and the workflow refused to
start: Exceeded max expression length 21000. Not a failed run, an unrunnable workflow, with every backend deploy blocked. The step's body now lives
in two files under .github/scripts/ and the API URL rides the step's env: beside the token, so the block is 34 characters
with no expression in it and has nothing left to cap. Behaviour is unchanged and that is measured rather than claimed: the extracted pair was run
against fifteen saved response bodies — a healthy scoped answer, all three scope refusals, a plain auth refusal, an HTML error page, empty, malformed,
a bare array, and the three server-error codes — and both the log and the step's outputs are byte-identical to the previous version on every one.run: block is, because parsing it cannot tell you whether it will run.
A new check fails above 18,000 characters, naming the file, the job, the step and the measured length. The threshold is deliberately below GitHub's own
21,000: the cap applies to the string GitHub composes and an interpolation expands at dispatch, so measuring the source against the real limit
would pass a block that then fails. It measures every block rather than only the ones carrying an expression, because adding one expression to a
19,000-character block would break dispatch with no other change. It runs in the same CI step as the workflow parse check — the two questions are
does it parse and can it run, and answering only the first is what let this ship. Verified against the file as it shipped broken: it
reddens with the measured 22,887, and the longest block left in the repo is 7,564.docs/modules/
held 71 pages and none for the asset-paperwork surface a driver hands to an inspector at a
roadside intercept. The new page names the three empties it tells apart — a document we
hold that nobody has dated, no current document at all, and no truck to look up when the
tablet is unassigned — the once-at-the-read active !== false filter that makes
an archived-only truck fall to the honest empty instead of showing one superseded record, the
content-hashed document id, and the sharded backend collection with the arithmetic that forced
it (1,190 assets × 4 documents is 227% of the item guard).#346 entry asserted “the deployed site is undeclared today,
deliberately”. Measured at be8c640: deploy.yml writes the key
and defaults an unset vars.ENVIRONMENT to production, with a
warning saying it is an assumption rather than a declaration. Corrected rather than swapped
silently, and the deliberate asymmetry with the backend workflow — which refuses a typo and
keeps its previous value rather than defaulting, because a wrong production there
permits a destructive act on an unlabelled stack — is now stated, since it reads as an
inconsistency. #646's AC2 is still open and now says why: ENVIRONMENT is
set on all six Lambdas and read by nothing in backend/src/.css/ file at all, so a hatch borrowed
from the dispatcher draws we do not know as an ordinary pill — which is why 13
of the suites in tests/manual/ test the computed background-image
instead of the class name. And such a guard is by construction hard to find: people.js
guards #467's capability rendering with zero mentions of the mechanism it
guards, which was read as no guard exists and cost a dispatch to rebuild one that already
existed. Also recorded: a gated assertion cannot tell its own absence from its own success
— one such group reddened 1 of the 16 checks waiting for it.KAR) — which the same release makes placeable, ticked and
saved. The rule they exist for has not moved: a scope value the register cannot
place is drawn hatched, named in words, and kept. So the fixture now holds
a value that is neither any yard's id nor any yard's code, derived off the
register at run time rather than typed, and the hatch is read off that
value's own box instead of anywhere on the form — because "something here is
hatched" was true of the previous render too and would have gone on being true of
a build that hatched the three code-carrying yards and silently dropped the
unplaceable one. The suite is unchanged in size at 16,108 assertions;
reverting the group-editor fix reddens 4, dropping the unplaceable value
reddens 5, and showing its box unticked — kept on screen but not by a save
— reddens 1, which is the variant the previous wording could not see.
Three of the four also stopped sitting behind a mismatched.length
gate they never depended on, so correcting a depot's code cannot make them go
quietly vacuous.store-postgres.js retried a failed Data API statement on a
window that reached 55,500 ms by its own constants — and, measured
over a counting fake with the resume fault armed, 55,490 ms across 11
sends at its maximum and 27,750 ms at its minimum.
backend/template.yaml gives ApiFunction a
Timeout: 15. So it was not that the window could overrun the
function: every exhausted retry sequence outlived it, the minimum by
12.75 s and the maximum by 40.5 s. Since DbMinCapacity
defaults to 0 the cluster scales to zero, so the first read after an
idle window is exactly the condition the retries existed for — and exactly the
one that killed the Lambda mid-backoff. A killed Lambda returns a bodiless
platform error, which means the one failure the completeness vocabulary matters
most for (#301, #306 — complete, note, the named failed
part) was the one where none of it could reach the client, because there was no
body to put it in. Riding out a 15–30 s Aurora resume inside a 15 s
function is arithmetic, not tuning, so the retry now spends a 10,000 ms
wall-clock budget chosen against that timeout — counting the failing sends'
own time, not just its sleeps — and then fails describably inside the
function's lifetime, leaving ~5 s for the request's own work and its
response. The backoff cap drops 8,000 ms to 2,000 ms so the budget buys
more attempts rather than fewer long waits: a single 8 s sleep spent 80% of
it on one wait. Needs a backend deploy to take effect.budget or
attempts), the elapsed time and the send count, and classifiable
through a new isRetryExhausted export that sits alongside the
existing isRetryablePg / isResultTooLarge. Before this
the state had no name because it had no error at all. The new suite section reads
ApiFunction's Timeout off template.yaml at
run time rather than asserting 15 from memory, and drives the budget on a clock
injected into the adapter's own realm, so it waits for nothing real and follows
the template if the timeout is ever raised.KAR/KTA,
Newman NEW/ZNE, Perth PER/HAZ
— so filtering to any of them returned 0 of 22 people, while their
ids returned 11, 16 and 16. That nought printed identically to Newman Airport,
Tom Price and Kalgoorlie, which are yards that genuinely have nobody — so
on the screen somebody consults to answer who at this yard is qualified for
this work, the lookup did not resolve read as nobody is
qualified. A filter that cannot resolve is now drawn hatched, names
the value asked for and the id the matcher actually reads, and states in words
that it is not a claim about anybody; a filter that resolves and matched nobody
still says “Nobody to show”, because a measured nought is still an
answer. The depot dropdown marks an option that will not resolve before it is
chosen.NWMN, HD4, Mesa J, Gap Ridge
go on the site master, the address book finds a site by one, and one alias can
only ever point at one place. Measured on the corpus: "HD4", "Sino",
"West Angelas Mine" and two others were all accepted and priced as delivery
points with no address, no pin and no query.NF.Locations.resolve() tells a name nothing matches from one that
matches an archived place, from one that answers to two places (which it names
and refuses to choose between), and — the answer that was missing —
from a site we hold and cannot place, because it has no address or no
coordinates. Near names are offered as suggestions, labelled as suggestions,
never as a match: a partial name is not resolved to the place that contains it.config.js ships environment: '' for the same reason it
ships an empty apiBase, and it fails in the same direction. Read as
production, an unset key fails silently and towards the danger — an
unfinished test deployment looks exactly like the live site and every gate asking
"may I write to a customer?" answers yes, and nobody finds out until a real
client is emailed from it. Read as undeclared it fails loudly and towards
safety: every surface says so, both gates refuse, and it is noticed in minutes by
the person who can fix it. An undeclared build is drawn hatched rather
than in the test colour, because "nobody told us" is not the same fact as "this
is test". Nothing is inferred from the URL, the API base or the presence of a
token — a build that guesses can guess "production" and act on it. A value that
is set but not recognised (prodution) lands on undeclared too, and
the banner prints back what was actually declared so the typo is findable.NF.Env.maySendCustomerMail() and
NF.Env.mayUseLiveIntegration(name) return the house
{ok, error, message} shape, name the integration they are refusing,
and the banner prints their own sentences — so the screen cannot say something
softer than the gate does. The enforcement is not built. The two places
that have to ask are NF.Intake.sendPath() and the Lambda that runs
the unattended send, and neither could be written on this branch; the exact
wiring for both, and for the server-side check that a browser guard cannot
reach, is written out in docs/decisions/346-environments.md.OQ-26 and
D-11 are left where they belong, with Simon and the GM. One
correction to the issue: it sequences the banner last, and it is built first —
the banner is what makes a mistakenly non-production build visible, and today's
single deployment already cannot say which environment it is.intakeQueue collection does not exist on the deployed API, the
poller records no delivery outcome, and the cursor still blocks on every failure.
Three other changes were already waiting on the same deploy when this landed.cron(0 17 * * ? *). That is
17:00 UTC = 1am AWST — AWST is UTC+8 with no daylight saving, so the
conversion is fixed all year and the naive cron(0 1 * * ? *) would
have fired at 9am Perth, on the busiest hour the dispatch board has. The
hour is a template parameter with that default, so a deploy can move it without a
code change.POST
/backup/retention with preview:true runs the identical
evaluation and writes nothing, returning the sentence a screen shows in the form:
“this would purge 2 backups, oldest dated 2026-07-04, on the next
scheduled run”. A reduction can never purge silently on the night after
somebody typed it.Number('')
is 0 in JavaScript, and 0 days here means keep nothing.FR-DB-23, and the reason is arithmetic rather than principle: a
purge with no record of what it removed is indistinguishable from data
loss. Somebody opening the catalog in March and finding nothing before
February has no way to tell a working purge from a bug that deleted the lot
— and #536 is the recorded case of that question being asked too late. The
rows go through the same machinery every other audited change uses, so they are
keyed, capped and seven-year retention-stamped like the rest.failed catalog row carrying the writer's own sentence. On such a run
the purge stands down entirely and says so, still stating how many backups are
past retention and will go on the next run that captures successfully —
“nothing happened” is never the whole answer.s3:PutObject grant and nothing else, so a compromised
route could neither read the database back out nor destroy the copies. The purge
needs s3:DeleteObject. Rather than widen that grant, the schedule
and the purge went onto their own Lambda with no route, no SSM read, no media
bucket and no self-invoke — so the API function still cannot delete
or read a backup, and there is deliberately no API path that runs a purge.
s3:ListBucket was refused there too: the purge is driven by the
catalog rows, which carry the exact key, and an object it holds no record of
could only ever be deleted unaudited. The consequence is stated rather
than hidden — an object in the bucket with no catalog row is never reaped.apiBase); the API is there and the backup routes are not deployed, which is the state
a live site is in today because #531 has not had a backend deploy; the routes are deployed and no backup
storage is configured; the request was refused, which is an answer about your access and not about
the catalog; the catalog could not be read (#531’s own message says this “is not the same
as having no backups”); and the one real empty, no backup has ever been taken. Five are drawn
hatched, the app’s one treatment for we do not know; exactly one — the measured one —
is a plain statement. A seventh state is not empty at all and is the one that reads worst: every capture on
record having failed. There is no flat green “backups healthy” anywhere on the card and no tick
over a catalog nobody read. Where the API reports more catalog rows than this tab is holding, the panel says
the list is short, not empty, and names the count. Measured: collapsing the six into one sentence
reddens 2 assertions in tests/run.js and 20 of 87 checks in
tests/manual/settings-backups.js.leaflet.css
names by a path relative to itself, without which a warm load with the CDN down
draws a map with broken images on it — shipped in v0.150.0, and both
service workers do carry all five Leaflet URLs. But the only thing checking that
was tests/manual/map-offline.js, whose driver-worker assertions poll
the shell cache and stop waiting at keys.length > 8, a threshold
the 13 same-origin entries meet on their own before the five CDN entries have
landed. Measured on one machine, one commit, with no edit between the two runs:
4 of 102 FAILED, then all 102 passed. A genuine removal of the
marker URLs from driver/sw.js was therefore indistinguishable from a
slow test runner.tests/suites/78-marker-precache.js — 27 assertions in
node tests/run.js, no browser, no service worker, no clock, so it
cannot flake. It pins the three marker URLs in both workers, that the two
workers name the same Leaflet URLs (the anti-drift claim the docs made and
nothing enforced), that the root worker's list is exactly those five and
nothing else (a same-origin entry at root scope would need a version stamp that
worker has not got), that the marker paths resolve against
leaflet.css's own directory rather than being three magic strings,
that every Leaflet URL index.html, portal/index.html
and driver/index.html actually load is an exact string in the worker
that controls that page — matching is by exact URL, so a version bumped on
one side alone precaches a release nobody requests, silently — and that the
driver shell cache name is concatenated from APP_VERSION
rather than written out, since a hardcoded name would leave a new precache list
unreachable on every already-installed tablet while every existing check stayed
green.notify(blk.message), which falls back to the word “Not
allowed”) and moving the whole refusal into a toast that slides away after
2.6 seconds while somebody is still reading which of two con notes to ring about
— the shape this app forbids for a writer’s refusal — both left
every assertion and every browser check green. The browser suite now
clicks the real Finish control on a real ambiguously-matched con note and
reads the painted dialog heading back, byte for byte against the
writer’s own words, together with the acknowledge-only property (one OK, no
Cancel, and it stays until it is dismissed) and that nothing was toasted; a third
con note with nothing in the way still gets a question with a Cancel beside it, so
the block is proved to be a gate and not a wall. Verified to fail, off each
runner’s own summary line: dropping the heading argument reddens 5
checks, toasting the refusal reddens 13, and collapsing the ambiguous
heading into the settled one reddens 3 on screen and 6 in the model
suites. One gap in the model was closed with it — that every refusal
this reader can return carries a printable heading, from a closed pair of two;
that break reddened 0 before and reddens 3 now.NF.Permits.quoteGate() was complete, asserted and had no caller
anywhere in the app — so never price a permit without the dimension that
justifies it was true of the model and false of the building, and the
measured incident behind it (a 4.6 m permit against a load that stood
5.0 m on the trailer, two con notes of futile charges and a wasted permit)
was not prevented on any screen a person uses. The enquiry ticket now carries a
travelling-dimensions card — the three dimensions with an unmeasured one drawn
hatched rather than as zero, the four permit outcomes recorded through the
model's own writer, and the gate's own sentence printed in the card — and Send
will not put a quote out past it. It binds where a permit is being charged for
(read off the quote's own charge lines, which is exactly the shape of the
defect: "Permit – Over height: $340.00 + GST" on a quote carrying no
height) or where a dimension is certainly over a limit; where it binds on
neither, the card says the movement is unmeasured rather than implying it is
cleared.NF.PoGate.check() and waive() had no caller at all, so
a client configured to require a purchase order was not stopped anywhere. The
new-consignment form asks it before the record exists and the con note asks it
about the record; a block is the writer's own sentence under the writer's own
heading, in the form, never a toast, with an ambiguous purchase order given its
own heading and the hatch because a maybe is not a settled no. The gate ships
off for every client, so this changes nothing for anybody until somebody
configures one — the panel is not drawn at all while it is off, and a
missing PO is still chased rather than blocking.Math.round(delivered/(delivered+0.0)*100) || 100 — which is
delivered/delivered, so always 1 and always 100%, with the
|| 100 printing 100% for the NaN when nothing had been delivered
at all. Measured: it was never rendered, so it was one edit away from putting a second,
permanent, meaningless on-time percentage on the screen the leadership team looks at
first. It is removed rather than corrected, because an honest figure cannot be computed
here either: on-time delivery needs a promised date to measure against, and there is not
one on file, so it is declared unmeasurable on the Reports screen instead. The
reason there is no percentage on the dashboard is now written at the site, and a test
guards it, so the absence is not read as an omission somebody should fix.apply() now takes them,
merged by id — a partial or bounded read must never make a catalog that
has backups read as one that has none, which is the false negative the whole feature
exists to prevent — with a failed capture carried as its own row, because “we
have never taken one” and “every attempt failed” are different phone
calls. snapshot() is deliberately not given the collection: it is
server-authored, and a browser that can send a row back is a browser that can rewrite the
record of who took a complete copy of every rate, contact detail and credential in the
company. The asymmetry is stated at the code and asserted in both directions, so nobody
“completes” it later./* appearing inside a // comment is not a
block-comment opener, but a block-first strip reads it as one and swallows
everything up to the next close marker. Measured on js/app.js: the
stray opener sits at char 73381 (line 1061) and the next close
marker at char 75111 (line 1086), so a block-first strip removes
1,732 characters spanning 26 lines — and that region is the
body of highlightNav(), including the line that calls
updateInvBadge(). The function signature survives, so the region
looks present. A guard asking "does the nav handler mint an invoice" would have
been green and blind to the only line that could answer it. Two guards were
actually reading that truncated text, established by instrumenting
String.prototype.replace across a full test run: the ICU date sweep
and the fuel-levy "no live reference" sweep. Neither was giving a wrong
answer today — both were proving an absence over text that was missing,
which is not an absence. For the record, because the figure has been mis-quoted:
the ~41,000 characters often cited is simply the total comment volume of a
heavily-commented file, and both strip orders remove about that much. The
ordering penalty is the 1,732.tests/lib/, instead of fourteen.
tests/lib/stripcomments.js sits beside appsrc.js and
composes with it. Line comments come out first, and once you are walking the text
anyway the two other ways the regex idiom misread this repo are free to fix:
string and regex literals are now recognised, so a // inside
'https://…' is not a comment, an apostrophe in prose
(“both workers' lists”) cannot open a string and swallow the next two
entries of a list — the defect that reported three phantom precache paths
nobody had written — and a pattern that matches a comment marker is
left alone. Comments are blanked in place rather than deleted, so line structure
survives and a guard reporting file:line is now right about the
line, which it was not before. It is a scanner and not a parser, and says so: a
template literal is passed through whole, interpolations included, so a comment
inside ${…} survives — the safe direction, and pinned
as a recorded limit rather than left as a surprise. That it removes comments and
nothing else is proved the only way that is really provable: strip a file
and it must still parse. Two sets, each named, because the figures differ and a
count against the wrong denominator is its own defect — the shell plus every
screen (68 files) and, wider, those plus the shared data layer, the driver
app and the client portal (72 files). The shared stripper leaves
zero unparseable on either. The naive block-first idiom leaves 6 of the
68 and 10 of the 72 — and the four extra are not an ordering
failure at all, but the same idiom's line-comment matcher eating the
// inside a URL. The zero is asserted; the counts are asserted only
as at least one, because a fixed integer is a fixture that rots. Twelve of the fourteen
guards were measured not exposed — they strip a single screen file, a
marked pure block, CSS (which has no line-comment syntax at all),
js/integrations.js or driver/sw.js, none of which
carries the shape — and were deliberately left alone.nf-state-synced from the boot sync, and a storage event
from any other tab, both re-enter the screen. Measured on a 1,190-row register
with 50 prime movers against a fake API (client cost and fan-out only, no server
time; uncompressed): opening the page cost 7.257 MB twice — once on entry
and once when the boot sync landed — and with the app open in a second tab, whose
autosave fires every five seconds, it re-read 7.257 MB every five seconds
indefinitely, about 87 MB a minute. The read is now held per asset list and
per window, so the first render is 1 request / 7.257 MB / 382 ms and every
redraw after it is 0 requests / 0 MB / 65 ms against 327 ms before. The
register arriving still triggers a fresh read, which is what makes the
empty "no prime movers yet" state correct itself when the database lands; the
held read carries the time it was taken and a Re-read now button, so a
cached answer is never passed off as live.NF.FleetDynamics.history() chunks the fleet and reports
partial when a chunk fails; this screen never looked. Measured with
200 prime movers (four chunks) and one chunk failing, the old screen drew
20 lane totals as plain numbers off a read missing a quarter of the fleet
and said nothing — a trimmed read reading as a quiet fleet. It now says the read
is incomplete, names the reason, and draws every lane figure hatched with a
≥ — a floor, not a count — because a lane showing three trips may
have run thirty. The trucks that reported are still counted honestly; what is
refused is the total. A silent count is also withheld (null, not
zero) on a partial read, because a truck whose chunk never arrived and a truck
with no trail look identical from the browser.fields=s halves the read and takes detected trips from
600 to zero (it drops lat/lng, and trip detection is nothing but
position), and a lower point cap is a coarser stride — at 400 fixes a measured
83% of quarter-hour depot visits and 31% of one-hour visits stop
being detected. Both are now guarded against reattempt. Still outstanding and
outside this change: readHistory has no fields value
that keeps position while dropping the speed, heading and posted-limit this
screen never reads — measured at 110.9 KB per truck against
149.0 KB, so a position-only narrowing would take the read from 7.26 MB to
5.41 MB with no loss of detection at all. Every payload figure here is
uncompressed; #73 moves all of them by roughly 10×.drawLongRun(), which lives in the fleet-overview screen
and is reached only from this page — so #587's node count survived the
first fix. Measured on a register shaped like the live one (1,190 rows / 573 in a
track-live type, 90 days of summaries each, fake API so client cost only,
Chromium 151 via Playwright 1.62.0, 3 Sep 2026): #ut-long was
24,093 DOM nodes across 573 rows of a 25,197-node page —
95.6% of the whole screen. It is now 1,833 nodes and the page
2,937. Rows arrive least-used first, so what falls off is the busiest
assets: the ones nobody is redeploying or selling. The cap is
UT_ROW_MAX by reference, not a second literal, so the two
tables on one page cannot come to teach different numbers.<title>, not one per weekly bar.
Thirteen weekly bars each carried a <title> element whose
whole job was a hover tooltip: 7,449 elements, 31% of the entire page, and
the single largest line item on it. The numbers were not deleted — they
moved into one <title> child of the <svg>,
which is both the native tooltip and, with role="img", the chart’s
accessible name, backed by a matching aria-label. All thirteen weeks
are still drawn, including the empty ones, because a run of empty weeks is
the finding. Per row: 42 nodes → 31.NF.Telemetry.fromRollup divides moving points by total points and
returns movingPct: 0 when there are none — and the recorder
writes points: it.points || 0, so a day row written before that
field existed, or one carrying no histogram, reached this screen as a hard 0%
with enough days for a verdict. On the one screen whose output is a
disposal or redeployment conversation, that was an accusation manufactured out of
an absent field, and it was being banked into the headline under-used count. It
now reads as not measured in the app’s hatched treatment, carries no
percentage and no coloured bar, is counted and named in its own sentence, and is
left out of the under-used count. An asset measured across 27,000 points
that never moved is a real finding and is unchanged at 0% — the two are
told apart by whether anything was recorded, never by the share, which is
identical. Such rows also sort as their own group at the head of the table rather
than being ranked as a 0%, which means the cap can never trim them away.foRegIndex had landed three lines above a per-row
NF.assetFor() — a linear scan that re-normalises both of every
register row’s identifiers on every lookup, and 43.7% of this screen’s
CPU self time. There is now no NF.assetFor call site left
anywhere in the screen, with the same answers: the index is filled in array
order, so the first row to claim a key keeps it, exactly as .find()
did.UT_ROW_MAX by reference so the two tables on one page cannot come to
teach different numbers — but the reference was
const LONGRUN_ROW_MAX = UT_ROW_MAX; at the file’s top level,
and that is resolved when the file is evaluated rather than when anything
is called. Calling across screen files is sanctioned and is what the old
single closure did; resolving across them at load time is a different animal, and
it threw ReferenceError: UT_ROW_MAX is not defined the moment
anything evaluated this screen alone. Which is now normal: a
tests/lib/appsrc.js suite loads exactly the file that declares the
function it is testing, and this file declares
rollScopeBits()/rollScopeInto(), so #312’s new
59-rollup-coverage.js does precisely that. Measured: a green branch
and a green master combined to 12,810 passed, 1 FAILED — reported as
“suite 59-rollup-coverage.js did not finish”, one red standing
in for a whole suite that never ran. Same family as the load-order constraint in
docs/modules/job-day.md, from the other direction. The cap is now
read through an accessor inside the draw, so the single source of truth is kept
and the file evaluates alone; the stand-alone fall-back is pinned equal to the
sibling screen’s own literal, read off disk, so it cannot drift into
being a second number. The guard nobody had is now there: the suite evaluates
this file in a bare realm and asserts it declares its bindings without throwing
— and asserts that a top-level = UT_ROW_MAX in that same realm
still throws, so the check cannot go vacuous.#/overview at 1,595 ms on 4.42
MB and recorded that nobody had looked at why. Diagnosed on the live
register’s shape (1,190 rows / 573 tracked) against a fake API, so client
cost and fan-out only — no server time, no TTFB, payloads uncompressed:
126 ms of the draw was network (4.6%), and 74% of the CPU was register
lookup — NF.assetFor 43.7% and NF.assetKey
30.6% of self time, off 10,125 assetFor calls and 36,340,390
assetKey calls in one draw (nine times the 1,146 #106
measured on #/movement, and for the same reason). The remedy is
#106’s: one keyed index per draw for the register and for the live
snapshot, filled in array order so .find()’s first-match rule
is preserved exactly, and never cached across a draw because
apply() replaces NF.assets wholesale on every sync.
Measured on the same rig, median of three runs: 2,657 → 983 ms, and
the synchronous head of the render — everything before the page first
paints — 1,115 → 365 ms. Nothing is capped and nothing is
trimmed: every count, coverage line and total is computed over exactly what it
was before.(NF.assets||[]).map(a => NF.serviceDue(a.vehicleId || a.plate)),
with no odometer argument. NF.serviceDue reads
odoNow as null when it is given no reading and returns
state:'unknown' before it computes a countdown, so the
overdue filter could never match: 1,190 serviceDue
calls, 3,570 assetFor calls and a walk of
NF.assetService per row, to produce the constant 0. The visible
half was worse than the cost — two panels on one screen disagreed, the
Needs-attention card naming assets overdue for service while the panel headed
“Decisions waiting” stayed silent about them. It now takes the count
from the walk that already happened, so both panels name the same assets, and the
item says it is counted on the vehicles reporting an odometer: one that reports
none is not measured either way, which is not the same fact as being in
order. serviceDue calls per draw 2,380 → 573.NF.serviceDue with none returns 'unknown' — which
this list has always filtered out — so an asset absent from the live
snapshot could never appear on it. It is skipped rather than computed and
discarded. The predicate is deliberately just “is it reporting at all”
and nothing cleverer: anything that also second-guessed the odometer
value would be a second copy of serviceDue’s own numeric
parse, free to drift away from it and drop a truck that really is overdue. The
suite computes the unskipped list and compares, so the equivalence is measured
rather than argued.#/servicedue was measured at 1,008 ms of render on zero requests —
nothing is fetched, so the whole of it was client-side derivation. Profiled on the live
register's scale (1,190 rows): one draw made 7,140 NF.assetFor calls, exactly
6.00 per asset, and NF.assetFor is a linear scan of the register that
re-normalises both identifiers of every row it walks. Five of those six lookups happen
inside the shared derivation (NF.serviceDue, NF.serviceHistory,
NF.assetOdoUnit, NF.assetOdometer) and were invisible from the
screen. The screen now builds one map of the register per draw and resolves against it, and
does the same for the live GPS snapshot it used to search once per asset. Measured on the
same rig with a realistic service log: 8,937 ms of derivation down to 33 ms,
and the per-row cost is now flat as the register grows instead of doubling with it. Indexing
only the parts the screen owned outright was measured too, and moved it 9% — which is
why the register lookup itself is what got fixed.
Not a change to what the screen says. The map keeps the first-match rule the old
linear search had, so every asset resolves to the same register row it always did —
asserted id for id against the original resolver, over hand-picked identifier collisions and
over 40 seeded random registers, plus a structural comparison of the entire forecast (every
count, row, bucket, depot and the whole engine-hours pipeline) with the index in and out. It
is rebuilt on every draw, because a sync replaces the register wholesale and a cached map
would forecast a retired unit's service under whatever now answers to its number.NF.serviceHistory filters the whole service log for every asset, so the forecast
is also proportional to assets × service records. It is not what costs the measured
second today — the Odoo service log is not readable yet, so that log is nearly empty
— but it will be the whole cost the day that grant lands. A test pins the shape as a
measurement and not as a fix, so the next reader does not conclude the screen became
constant-time: even indexed, a row still costs more as the service log grows.NF.UTIL_SCAN_ASSETS — a budget for
a week of samples per asset, not a budget for table rows. Measured on a
register shaped like the live one (1,190 rows / 573 in a track-live type, fake
API, 3 Sep 2026): the 7-day panel cost 2,027 DOM nodes at the default cap
of 120, and 9,275 with the read cap raised to the whole tracked fleet
— which the screen’s own scope note (“453 of 573 candidates not
measured”) is an invitation to do. It now draws the 60 least-used
and prints how many it did not: 2,027 → 962 nodes at the default cap,
and 9,275 → 962 at any cap above it. Rows arrive least-moving first,
so what falls off the end is the busiest assets — the ones nobody is
redeploying or selling.summarise() returned a share of 0,
which reached the table as a coloured bar reading 0% and was averaged in
as one. On the one screen whose premise is that unknown time is never idle time,
that was an invented number in its only leadership-facing column, and it made an
accusation out of a gap in the recording. Nothing measurable is now drawn in the
app’s hatch (“not measured”) in both tables, the average is
taken over the assets that have a measurement and says how many of them
that is, and with nothing measurable at all the average is withheld rather than
printed as 0%. The asset stays on the list, because its unknown hours are the
finding.#/utilisation at 21,568 DOM nodes and named
the #106 remedy. Re-measured on the register above, the page draws 26,136
nodes in #view — and 24,093 of them (92%) are the
90-day “long run” table, at 573 rows and ~42 nodes a row, of
which 7,449 are the <title> elements inside its weekly
sparklines. That table is drawn by drawLongRun(), which lives in
another screen file and was outside this change. The 5.82 MB payload is the
90-day rollup read (/fleet/rollup?days=90), which carries a
26-bucket histogram per asset per day and has no fields narrowing;
that is a backend and adapter question, not a rendering one. The bound landed
here takes the page to 25,071 nodes; the remaining 24,093 need the file
this change could not touch.NF.Invoicing.backfill(), and the
shell calls that badge from highlightNav() — which runs on
every route change. So the first navigation of every session, including simply
logging in, minted a draft invoice for every delivered con note that had none:
measured on the sample fleet, ten financial records created by nobody
doing anything. A nav handler and a render path are reads. The badge now counts
the same backlog through a read-only reader and paints exactly the same
figure — invoices already drafted plus delivered loads with none
raised, which is arithmetically what the old badge showed, because
backfill() minted every one of them as a draft.{ok, error, message}, never throws, and prints its own sentence
in place rather than in a toast that slides away while somebody is
reading how many records they just created. NF.Invoicing.backfill()
has exactly one caller across the shell and every screen, asserted off disk
from the directory rather than from a list anybody has to maintain.n != null && n > 0 instead of asking
NF.Desk.slaTarget(), and the two had come apart above the one-year
ceiling: with enquirySlaHours set to 100,000 the card printed
"Target: 4 h wall-clock, from NF.settings.enquirySlaHours" — the
shipped default, presented as an agreed commitment, attributed to a key holding
something else entirely, while the arithmetic behind it used 4. One reader now
answers it, with its provenance, and the ceiling is written down once.NF.assetDocs was in none of the four places a collection needs — no
snapshot(), no apply(), no backend whitelist, no change-log filter —
so an expiry entered against a truck was accepted, drawn on screen, and gone on the next
autosave five seconds later. It is read in eight places (the asset page, Settings,
Reports, the fleet document outlook, the lost-revenue register, the Odoo document merge and
the driver’s cab), which makes this a store that was read and silently empty rather
than one nothing looked at. Now emitted and merged, with the change-log filter carrying it.
Two of the four places still need a backend deploy: the collection must land
sharded, not as a single item — measured at 1,190 assets × 4 documents
× 182.7 B that is ~863 KB, 227% of the 389,120-byte item guard, and
121% even with no author stamps at all.NF.Pools.qualified() up as a
four-level advisory and drew it as a chip on the board row, but deliberately left
it out of the drag guard's warning — so a run dropped onto a driver the rig
is not rated for said nothing about it until the next repaint drew the chip, and
the guard's message is the only place a move is narrated. The schedule board's
move guard now folds the same erQualAdvice() the chip uses into the
sentence it already composes for a conflict and for hired-in capacity, so a drop
reads Moved — unconfirmed: RT3 — unchecked; F100 —
untracked in one breath instead of Schedule updated.
Warn, never block: the fold only ever appends to the returned warning, so
it cannot refuse a move — docs/modules/crew.md's settled
position (#263) is that every qualification finding is an advisory a scheduler
may override, and the run lands. Measured: making the advisory return
false instead reddens 8 of 277 checks in
tests/manual/engine-room.js, three of them pre-existing ones about a
timing warning not blocking. And the state travels with it, not the level word
alone. unconfirmed is three different answers wearing one coat
— a credential that is held and whose currency nobody knows
(indeterminate), a ticket we track with nothing on file
(unchecked), and a capability we track no ticket for at all
(untracked) — and crew.md records the first of
those, riding on an otherwise clean answer, as “the one failure here with a
licence consequence”, so one word for all three would throw the distinction
away at the one moment somebody can act on it. The words are
NF.Pools' own state keys, so there is no second vocabulary; the full
sentences stay on the row's chip. Collapsing them back to the chip's label
reddens 3 of 277, and removing the fold entirely reddens 5. The
requirement is still read off the allocated unit and never off
lane.classes, which looks like what the run line requires and is a
third copy of what the driver holds (#467). Two things this does not do:
the pool rail's own drop guard is untouched, because it runs before the mutation
and would describe the prime being replaced rather than the one being dropped;
and the advisory is scoped as the chip is scoped, to the visible window's worked
days on the receiving row, so a drop onto a row with no work in the window yet
says nothing until the chip appears a repaint later.NF.Pools.qualified() had zero callers. The
reader that answers “does this driver hold what this work needs”
was called from nowhere in the dispatcher app, the driver PWA or the client
portal; the only calls to it in the whole repository were 9 test assertions. So
the qualification chips on the schedule board said what somebody holds,
the compliance screen reasoned about tickets, the crew record showed provenance
— and nothing consulted any of it at allocation time.
Departure was not covering it either, which was the first thing to check.
NF.Compliance.gateBlockers fires only for a requirement carrying a
gate, and of the 10 entries in NF.QUALS exactly
one (MC) points at a gated requirement.
REQ‑VOC‑ROADTRAIN — which both road-train
endorsements hang off, and which is the whole “the new fellow guys might
not be good for triple road trains” case — carries no
gate at all, and neither does the forklift licence. Allocation-time
checking was a genuinely separate job that nothing was doing.
Measured the moment it was wired in, on the generated sample board: 6 of 23
run lines have a prime mover allocated that requires an endorsement, and not one
of the six is answered by a verified credential. Five are a firm shortfall
(named on the row: two drivers on triple road trains, two on doubles, one on a
100 t float) and the sixth passes only because somebody ticked a box.
Nothing is blocked — warn, never block is the settled position, and
the row says out loud that the allocation stands.NF.Pools.qualified() answers with six words, because
ok is a boolean and the question is not. It already reported
via — which store answered — and nothing reported
how good the answer was, so two genuinely different facts shared one word:
a 100 t float ticket (which we track no credential for at all) and an MC
licence (which we do track, with none on file) both came back
via:'licence', and no caller could have told them apart however
carefully it tried. There is now a states map answering
verified · indeterminate ·
refused · unchecked ·
untracked · not-held, with
unconfirmed, confident and source beside
it. indeterminate is its own word for the reason the crew screen
already gives — a credential held that nobody knows is still current,
riding on a clean answer, is the one failure here with a licence consequence.
ok and missing are unchanged, deliberately: an
unchecked claim still passes exactly as it did, so every existing reader and the
compliance gate keep their meaning, and the new fields are an additional axis
rather than a replacement. Measured on the seeded register: 37 qualification
passes are answered by a claim nobody has checked and 0 by a verified
credential — and before this, every one of those 37 returned a bare
ok:true, indistinguishable from a verified road-train VOC.lane.classes, which looks like the answer and is a third copy of
what the driver holds, with no writer and rendered nowhere since v0.144.0. A
hired-in row gets no advisory, because we hold no tickets for somebody else's
driver and the row already says so.GET /state
has handed out a backups collection since the capture engine landed, but
the browser threw it away — three of the four places a collection needs were
wired and the fourth, the read, was not, so the Settings catalog had nothing to draw
and could not tell nobody has ever taken a copy from every attempt failed.
It now merges by id, newest capture first, and a short read can never blank a row the
browser already knew about. Deliberately one-directional: the app still never sends
the catalog back, because a backup row is evidence a capture happened and evidence a
client can rewrite is not evidence.HasCert condition in two structurally
different places — and the difference is destructive. In the site template it sits on
properties, so no certificate means the distribution reverts to its
default hostname and the site keeps serving; in the API-domain template it sits on the
resources, so no certificate deletes the custom hostname and its stage
mapping. Both are correct — an API Gateway domain has no "no custom domain" property
state, a CloudFront distribution does — but they are one line of YAML apart, and moving
that line in the site template would delete the live distribution while every check in
the repo stayed green. A new check now computes, per template and per condition, exactly
which resources disappear when that condition is false, and refuses any set other than
the declared one — in both directions, so making the site template destructive fails and
making the API template silently non-destructive fails too. The declared set is
cross-checked against the sentence the deploying script prints on screen under
CLEAR_DOMAIN=1, because a script that reported "no custom domain was
created" on the run that had just removed one is where this came from. Node,
no new dependency, no AWS call; measured by breaking it seven ways, each of which the
check refuses by name.cfn-lint for the infrastructure templates, with the
reasons. The recommendation is written down in the deployment runbook and at the head
of the new check rather than left to be re-derived. In short: a schema linter would not
catch the destructive difference above at all — both placements are valid CloudFormation
used correctly, and no rule computes which resources vanish when a condition is false, or
compares a template with what a script promised an operator; the cost is a Python package
with a moving upstream ruleset on a repo that declares no dependency for any root check,
whose likeliest failure is reddening a build over a directory nobody touched; and what it
would catch — a wrong property name, a bad reference — already fails the deploy
loudly, in front of the person who typed it. If CloudFormation's own opinion is ever
wanted, the cheap place is a dry-run change set in the provisioning script, where the
credentials already are.day is stamped server-side, and the recorder's Lambda resolves it in
UTC — measured, 0 of the 5 functions in
backend/template.yaml set TZ — while the browser
derived its cutoff in local time, which for a Perth dispatcher is AWST. The two
disagree for every instant in [00:00, 08:00) AWST (the same eight hours as
[16:00, 24:00) UTC of the day before), where the stamped day reads one date
earlier, so at the trailing edge of the rolling window an asset whose only
recent summary was a pre-dawn start fell out of the candidate list — with no
error, no count and no note, which is the silent narrowing the scan bounds exist to
refuse. The window is now applied as an instant range: every summary carries
firstT/lastT off the samples inside it, an instant belongs
to no timezone, and both sides of the comparison come off one clock. That is right
for every row ever written and no row was rewritten — the same
answer NF.Forms.subDay already ships for form submissions, where
day is "the stored convenience field".counts.undatedDays) — 0 being a
measurement that every row was dated, never how "we could not tell" arrives. The
three narrowings (speedCandidates,
utilisationCandidates and the superseded
recentRollupAssets) now share one window rule, because three copies of
the comparison is how there came to be three copies of the defect.days and no server-side day-partition fan-out, so the caps
#301 and #306 put on those reads still hold. Correcting the window
can admit summaries it wrongly dropped, so a candidate list may be marginally
longer — the ceiling on assets read, and the remainder it states on screen,
are where the bound lives and neither moved.tests/suites/64-speedlimit-throttle.js,
1 Hz fixes, 400 ms round trip): standing still with every request failing cost
3,600 requests/hour against 277 when they succeeded — 13.0×, and it was the same
3,600 at any speed. The trigger was exactly the condition the app most needs to be quiet in:
being rate-limited (HTTP 429), or being out of coverage in the Pilbara. A failed lookup now
throttles precisely like a successful one — measured 277/hour at rest and 1,800 at
100 km/h whether the requests succeed or all fail, i.e. no penalty for failing at all.
Nothing about what is sent changed: the query still asks at the fix, unrounded, because
rounding it onto a 500 m grid was measured to resolve only 4 of 10 on-road points against
10 of 10 asked at the fix. The lever is the number of requests, not the precision of each one.NF.SpeedLimit.lookup returned a bare
nothing for two quite different facts — we asked, and the map has no limit recorded for
this road and we could not ask — so a timeout or an HTTP 429 was
indistinguishable from an answer. It now returns the answered case as an answer, and a refusal
carrying its reason (rate-limited, an HTTP status, or unreachable when nothing came back at
all); a cached limit handed back after a failure says the lookup failed, rather than looking
like an ordinary throttled echo. Three facts, three answers. The cab's display is deliberately
unchanged by this and still draws every one of them as its existing hatched unknown state
— never a figure and never green — so the remaining half of the work is the cab
wording, which still prints “No posted limit mapped here” for a failure it can now,
finally, tell apart.CLAUDE.md
states that a document nothing links to is a document nobody reads, and nothing
enforced it — nothing in tests/, tools/ or
.github/ read the file at all. tools/docs-index-check.js
refuses a relative link in CLAUDE.md or anywhere under docs/
that does not resolve, and refuses any file in docs/modules/,
docs/decisions/, docs/audits/ or docs/policy/
the spine does not link — an exemption has to name the file and give its reason.
Measured on the commit it landed against: 0 dead links in 240, and
seven documents nothing indexed — five decision briefs (#255, #327, #333,
#343, #378) and two module docs (Coates EDI, external geofence alert routing).
The issue expected it to land green.NN- prefix in tests/suites/ is
refused, and the three already on master are grandfathered by name. The
number is the running order and was never required to be unique; three lineages
dispatched in parallel each numbered forward from what their own tree could see,
leaving 58 claimed four times and 59 and 61
twice each. Renaming them would conflict with every open branch, so
tools/suite-number-check.js grandfathers those three by the exact
set of files — a fifth file claiming 58 is still a new collision — and hands
back a free number (--next) instead. The renames stay a follow-up on
#625.tools/ is now part of the "nothing is on disk that nothing runs"
accounting. The suite-wiring check covered the backend suites, the browser
suites, tests/*.js and infra/, and asked nothing about
the directory holding the release writer and the dispatch pre-flight. Each file
there now names the check that runs it, that check is read to confirm it still
does, and a tool with no runner is refused — the same refusal
infra/ got in #574.CLAUDE.md is the first file every session reads, and
it states the reason for its findings index in one sentence: “a document
nothing links to is a document nobody reads”. Nothing enforced it,
because until now nothing in this repo read CLAUDE.md as a
file at all — every mention of it in tests/,
tools/ and .github/ was a prose comment.
tests/doc-links-check.js resolves every relative link in the spine
(142 today, all of which land on a file that exists) and asks the reverse
question too: is every document under docs/modules,
docs/decisions, docs/audits and docs/policy
linked from it? Measured on the day it landed, nine were not — two
module write-ups (Coates EDI, geofence alert routing) and seven decision briefs
— and none of the nine is linked from any other document either, so today
they are reachable only by knowing the filename. They are named in the check with
their reasons and printed on every run, passing or failing, because
writing an index entry says what a document is and that is a judgement for
whoever owns the spine, not something a link checker may do by itself. The list
cannot outlive the debt: an entry naming a file that has been deleted fails, and
so does one for a document that has since been indexed. Anything not on it
that goes unindexed is a hard failure, so the gap is frozen at what was measured
rather than licensed to grow. It says out loud what it does not check —
external URLs (there are none), heading anchors, links inside the module docs
themselves, and the doc folders the spine never claimed to index — because a
checker that implies it validated everything is the failure this repo refuses.
Wired into npm run checks and CI, and therefore accounted for by
npm run wiring.CLAUDE.md states the reason for its index
in one sentence — “a document nothing links to is a document
nobody reads” — and tests/doc-links-check.js
measured nine documents on disk that it linked to from nowhere. Re-measured on
this branch the list was unchanged, and seven of the nine were linked from no
document anywhere in the repository, so a reader reached them only by knowing
the filename. Two are module write-ups and go in the module index: Coates
EDI (the delivery milestone derived from our own record, which deliberately
emits nothing anywhere because the specification is held by Coates IT and has
never been obtained) and external geofence alert routing (a tested sender
for webhook, email and SMS that nothing calls, because the route belongs
in a file its branch could not edit). Seven are decision briefs and go in the
findings section, where an entry says what a document found: that a
backload optimiser cannot be built because 703 of 703 live fixes carry no
timestamp and the board holds 0 lanes; that the quote document is already
generated from data, against #327's own central premise; that dangerous goods has
a third state — in scope and half-built — worse than today; that the
driver app's watchPosition runs for the whole of a run, which #343
said it did not; that one people table is insulated by the wire
format rather than by NF.People; that Overpass is asked every
50 metres rather than every 12 seconds, up to 21,600 times a day;
and that the Lambda's UTC day stamp costs the eight hours in
twenty-four before 08:00 AWST, keyed into five row families rather than one.
Four briefs record a decision that is still open or still with a named
person, and each entry says so rather than implying it is settled. The
check's KNOWN_UNINDEXED list is correspondingly empty, its two
~ marks are now ticks, and the spine's link count is 151,
all resolving.backend/src/fleetdynamics.js has read FD_FIX_DATE_ORDER
since the timestamp fix landed, and it is the one setting that turns
ts back on for every live fix whose slash date the arithmetic cannot
settle by itself. There was no FdFixDateOrder parameter in
backend/template.yaml, no environment wiring on any Lambda and no
deploy input, so no deploy could apply the fix the change itself prescribed,
and the fleet went on carrying no timestamp at all on the ambiguous
majority of rows. A correct parser and an unreachable knob produce exactly the
same symptom, and the parser half was green under four timezones the whole time.
There is now a FdFixDateOrder stack parameter
(AllowedValues: '' | dmy | mdy), wired to
FD_FIX_DATE_ORDER on all three Lambdas that read Fleet Dynamics
— the refresh sweep, the recorder and the API, the same three that already
carried FD_FIX_TZ_OFFSET_MIN — and an fdDateOrder
input on the Deploy backend workflow. Set it to what
GET /fleet/live's diag.dateOrder proves; the evidence
of 3 Sep 2026 says dmy.ambiguousDate, because reading 3/9/2026 the wrong way
round does not fail — it answers, files a truck seen four minutes ago six
months in the past, and reads as a fact. The apparent inconsistency with
FdFixTzOffsetMin's Default: '0' is not one: both
defaults mean the behaviour the deployed code already has, unchanged. An
offset has no unset state, since the arithmetic must use some number; a field
order does, and the parser honours it by refusing. Blank on the deploy input is
appended to --parameter-overrides not at all rather than as
FdFixDateOrder=, which the SAM CLI rejects as an invalid format and
which has aborted every backend deploy twice in this repo's history — and
because an omitted parameter keeps its previous value, a later deploy that
forgets to re-pass the order cannot silently undo it. That is the opposite
treatment from recorder, deliberately: there the sticky value can be
the wrong one, here it is a measurement somebody took. A value that is neither
dmy nor mdy is refused by the workflow with a named
error instead of being passed on, because the Lambda reads an unrecognised value
as unset — a green deploy, one warning into CloudWatch, and every
ambiguous fix still undated. backend/tests-template-fd-knobs.js
(26 checks) pins all of it, including that every value the template permits
is one the deployed parser actually recognises.fixTime() refused every one of them:
ts null on 703 of 703 live rows on 3 Sep 2026,
badTime 19 of 19 successful reads, and NF.fixState
answering unknown on 692 with moving/stationary on
zero. That format is now read, component by component, never through
Date.parse. The field order is not guessed.
3/9/2026 is the 3rd of September to an Australian and the 9th of
March to an American — 174 days apart, with nothing in the string to say
which — and the reported shape cannot settle it either: 9/03/2026
2:04:00 PM and 3/09/2026 9:24:33 PM mask to the same
9/99/9999 9:99:99 PM and describe the same day. So the order comes
from arithmetic where the row settles itself (a field over 12 can only be a day,
in any calendar) or from the new FD_FIX_DATE_ORDER setting, which
has no default — unconfigured, an ambiguous row is refused exactly
as before. Reading it the wrong way round would not have failed; it would have
answered, filing a truck seen four minutes ago six months in the past, which is
worse than the refusal it replaces. A 24-hour slash date
(02/09/2026 14:05:00) stays refused, and so does everything else
fixTime could not read before.ts stopped
arriving at backend deploy run #108, 2026-09-02T22:33–22:37Z —
the first deploy to carry fixTime() at all. Fleet Dynamics is not
known to have changed anything: the previous line
(Date.parse(…) || Date.now()) accepted the same 12-hour
strings silently and read them in the opposite field order. docs/modules/frozen-fix.md
cited run #107's smoke test as having measured a real FD datetime and
confirmed FD_FIX_TZ_OFFSET_MIN should stay at 0; run #107 did not
contain fixTime(), so that reading came from the very parser the
measurement was meant to be taken without. Read as d/m/y in AWST,
the two fixes in those logs are 130.4 minutes and 4.3 minutes old
at their snapshots instead of 177.8 and 177.7 days — and the 4.3-minute
one is the freshest asset in a 608-vehicle snapshot. The offset is therefore
unconfirmed, not measured, and the same evidence points at 480. Both
values remain knobs, set out of band, and the recommendation is now written down
with what would settle each.live() now writes a warning when not one fix in the sweep has
a usable time, naming which of four causes it is (no datetime sent, a format we
cannot parse, every fix future-dated, or a day/month order nobody has
configured) and the parameter that fixes it. It is deliberately silent for a
partial failure: a handful of ambiguous rows is the expected state before the
order is set, and a line that fires in the normal case is one people learn to
scroll past, which is how this survived a day. Two new diagnostics ride with it
— ambiguousDate, kept separate from badTime
because "we read this and declined to guess" and "we cannot read
this" are different phone calls, and dateOrder, which reports
what the sweep's own unambiguous rows proved about the field order and is
derived only from that arithmetic — never echoing the configured value, so a
setting that disagrees with the feed shows up as a disagreement. A proof from one
row is never applied to another, because a timestamp that depends on which other
vehicles shared the 34-call slice is not a pure function of the row it came from.tests/manual/node_modules is
gitignored, and git worktree add carries nothing gitignored — so
a linked worktree, which is the normal working shape for a dispatched agent, starts
with an empty one however completely installed the checkout it was cut from is. The
instruction everywhere is once per checkout, which is true and reads as
already done. The first suite therefore died with Cannot find module
'playwright' naming a require inside the suite file, which
says nothing about an install step; three agents hit it in one day, in three
worktrees, and the folklore remedy of symlinking node_modules in from
the parent checkout is evidence of the defect rather than a fix for it.
npm run screen now goes through a launcher that refuses that case up
front and names the cause: the gitignore, the worktree, the exact symptom text
somebody would be searching for, and npm run screen:install. It runs
the suite exactly as before otherwise — same working directory, same exit
code — and CI does not use it at all.^1.49.0 — written when
it meant 1.49.x and resolving to 1.62.x by the time anybody looked — quietly
changes which browser every suite in this repo measures against, and on a machine
that preinstalls a browser rather than downloading one it stops the suites
launching at all. It is now 1.62.0, which is what the tracked lock
already installed, so no measurement moves; the lock's own dependency line was
updated with it, since npm ci refuses a package file and lock that
disagree and that failure lands on the browser job's first step. CI was green
throughout and always will be — it installs its own matched pair — which
is exactly why this stayed invisible while costing laptop and agent sessions.npx playwright install — which is
the one thing an agent session is told never to run, leaving the working remedy
undocumented at the moment it was needed. The launcher now names the build
Playwright wants, what is actually on disk, and the CHROMIUM_PATH= line
that every file in tests/manual except the shared harness already
honoured but which was written down in exactly one comment. It answers in
three states — a browser it can name, none on disk, and could not
look — and it says outright that running against the other build is
running against a different browser, so a result reported that way says so. Measured
on 3 Sep 2026: build 1234 (Chrome for Testing 151.0.7922.34) absent and the
image's 1194 (Chromium 141.0.7390.37) present, the line it prints gives 18 of
18 and exit 0. The diagnosis is deliberately after the fact rather than a
pre-flight, because a launcher that predicted a launch failure would eventually
refuse a run that would have worked — and it will not blame an ordinary red
suite on the browser, which is asserted in both directions.tests/manual/package.json
asked for ^1.49.0. Playwright pins one exact Chromium build per
release — and a machine that ships a preinstalled browser (every
agent session, and a laptop set up the same way) has only a build or two on disk
under PLAYWRIGHT_BROWSERS_PATH, with no way to fetch a third when
downloads are disabled. Measured 3 Sep 2026 by reading
browsers.json for all 28 releases that range allowed: exactly
four map to a build present here — 1.56.0/1.56.1 (build
1194, Chromium 141) and 1.62.0/1.62.1 (build 1234, Chromium
151). Against the base image alone, which carries only 1194, it is two of
28. The other 24 refuse to start, demonstrated on 1.60.0 (build 1223)
with downloads skipped: browserType.launch: Executable doesn't exist at
…/chromium_headless_shell-1223/…, exit 3. The version
is now exact, at 1.62.0 — which is what installed anyway,
because package-lock.json is committed and already resolved there;
the caret's real reach was npm update and anything that regenerates
the lock, not a routine install. The reason is written into the package's own
description, naming both builds on disk, so a future tidy-up has to
read why before restoring the range. CI never had this exposure and is untouched:
it runs npm ci and then downloads the matching browser itself.
What did not reproduce: the reported symptom. On a clean worktree
npm install resolved 1.62.0 and npm run screen ran
board-conflict.js 46/46, settings-backups.js 87/87 and
rates.js 147/147, with no override — because
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is not set in this
environment, so the install fetched build 1234 itself. The mismatch is latent
here rather than live, and the escape hatch was already in the repo: 74 of the
75 files in tests/manual/ honour
CHROMIUM_PATH, and pointing it at the unversioned
/opt/pw-browsers/chromium symlink runs Playwright 1.62.0 against the
preinstalled Chromium 141 with all 46 board-conflict checks still passing.NF.Store.lastSync(), not on the size of the result, because a
count cannot tell "we looked and found none" from "there was nothing to look at" —
and with the sample fleet off by default, an empty board is what a real deployment
shows first. A conflict is never suppressed by the unread state: findings on an
unsynced board are still drawn in full, with a hatched caveat above them saying the
list may not be the whole list. (#617)localStorage: they survived a reload on the
tablet or desktop they were typed into, reached nobody else, and no write was
recorded anywhere — compliance dates on physical vehicles, held in one place
nobody could audit. assetDocs is now a first-class backend
collection, so a document entered on one device reaches every other device and
every change is filed against the acting identity.assetDocs collection still does not exist on the deployed API and
the documents still go no further than the browser they were typed into. No
database migration is needed with it — the rows land in the existing singleton
table, one per asset.graphmail_send_forbidden: the thread went from Draft
to Quoted, the ticket moved to the customer's court with "The
price is with them. Nothing is owed by us.", and the desk's own first-response
card read "Answered in 46 days" against an email that never left the
building. The information-request path did the same, starting a clock against a
customer nobody had managed to write to. The stamp now waits for the mailbox's
answer, and a refused send leaves the thread exactly where it was — the ticket
still says Quote ready to send, in our court, and the card still says
Unanswered.sentAt has always meant
a named person sent this rather than it arrived, and the operator is
about to send it from their own mail client. Only the third outcome changed: a
route that was reached and refused. Whether the thread would accept the stamp is
still asked before anything is emailed — through a new read-only
NF.Desk.stampRefusal(), which returns the writer's own refusal
without writing — because a customer receiving a quote the record then rejects
would be worse than the defect being fixed. The stamp is deferred, never
unwound: markSent also appends to the thread's append-only
history, and deleting a row to tidy up a failed send would be rewriting a record.
The manual desk path now uses the same attempt-then-stamp order the unattended
autosend path in the same file has always used.NF.Credentials, which accepts none from its caller. No
application code changed; nothing a user sees is affected. (#635)00:00 (in a timezone that springs forward at midnight it is not),
and a credential claim and its verification are seeded one millisecond apart
either side of it. A window over the first day carries the claim, over the second
the verification, and over both — counted inclusively — both acts.
Reverting that inclusive bound reddens 2 assertions. (#635)tests/manual/map-offline.js polled the driver PWA's shell cache and
stopped waiting once it held more than eight entries. The driver worker
precaches 13 same-origin files and 5 from unpkg, and the 13 come off a
local test server — so the poll could return before the Leaflet script,
stylesheet and three marker images had landed, and the four assertions about
them then reported a slow runner as a worker that had stopped
precaching Leaflet. Measured on one machine, one commit, with no edit
between runs: 4 of 102 failed, then all 102 passed; it then failed live
on a pull request whose diff touched none of it. The wait now keys on the five
URLs the section is actually about, and still falls through when its 15s
budget is spent — so a genuine removal of the three marker URLs reddens
3 of 102 rather than hanging or being waited into a pass.tests/suites/78-marker-precache.js gains a group asserting that the
browser suite's driver-cache wait keys on the URL list rather than an entry
count, that no count can end it, and that the wait is bounded and reports what
the cache really holds. Deleting the three marker URLs from
driver/sw.js already reddens 4 assertions there in 0.3s,
against 3 in a 90s browser shard, so the precache list is checked as the static
fact about two text files that it is.NF.AssetDocs writer: add, edit,
archive and restore, each returning its own sentence for the screen to print in
the form rather than a toast. A document is archived (active:false
with who and when), never deleted; its identity is hashed from its own content,
so two dispatchers entering the same renewal derive the same record instead of
two; and an expiry nobody has typed stays unknown — drawn hatched, never
as valid and never as today's date.NF.assetFor — the shared register lookup behind 43 call sites,
several of them whole-screen — dereferenced every element of an array the
app does not own: it is filled verbatim from the server's document, and a
round trip through the local cache turns an undefined entry into a
null one. It now skips a bad row instead of throwing on it.NF.assetFor() is a linear scan that re-normalises both identifiers on
every row, so the asset number being looked up was uppercased and stripped once
per register row rather than once per lookup. On the live register that was
1,146 calls costing 199 ms of the whole-fleet view’s 787 ms render;
it is now one keyed index per draw and zero scans, with the same answers.bus was
not ticked track live and the filter was empty by construction. Measured
off the live document on 3 Sep 2026 it is ticked and carries 202
rows — second only to light vehicles’ 281, out of 573 tracked
across five ticked types — so a bus read is now real work. Also measured:
0 of 1,120 rows that carry both a unit id and a plate use the same string
for both, so asking by both identifiers really does double the keys.error, and neither screen looked at it. Measured on a
573-asset register with the summaries healthy and /fleet/history
answering 500: 3 requests, 0 speed events filed, and the note still
saying "built from the raw samples of the 120 assets … 50 with a recorded
peak at or above their own limit" — a check that did not happen, over an
empty log. Both screens now say the samples did not arrive, carry the read's own
error, retract the claim above it in words, and state that the absence of speed
events below is not a measured zero — while the peaks the summaries did
return are still stated as sound. Keyed on the adapter's own
error/partial answer and never on a count of assets
returned: samples expire after a week while summaries are kept for months, so a
candidate whose peak is eight days old and whose samples have aged out is part
of a perfectly complete read. One builder, so the two screens cannot say
different things about one derivation.assets=<120 ids> history
read of 3.88 MB), against 10 requests / 36.3 MB for the unnarrowed
whole-fleet read the issue describes, reproduced through the same adapter on the
same fixture. Once the recorder route is deployed it is 1 request / 14 KB,
and 142 ms to settle against 486 ms. Rendering is not the cost on this screen —
916 nodes and 30 KB of markup, paint in ~50 ms — so unlike #106 there is nothing
to bound in the draw.js/, driver/
and portal/. There is now a charge-type register on the rate card
(Rates → Charges) carrying detention, futile/cancellation, standby
and the collection/delivery fee, each with its own rate, its own free allowance
and its own rounding convention. A charge whose vehicle combination cannot be
resolved to a row on the card is refused, naming both the combination and the
charge type — never priced at nothing, and never off the
nearest-looking row. That refusal is the point: “Single trailer”
and “100Tn Float” are on the quote picklist and on neither rate
card, so those jobs genuinely have no detention rate, and the screen now
says which ones and why instead of producing a figure nobody chose.NF.settings, which
is already in the document snapshot, in the merge, in the API’s collection
whitelist as audited, and in the change log’s filter list. A rate is the
money, so who changed it and from what is the first question when an invoice is
queried.0 KB,
which would read as “nothing is held”. Sizes are real UTF-8 bytes rather
than string length, an item that could not be sized is named and excluded from the
total with the total saying so, and a storage key the app has no friendly name for
is still listed under its raw name rather than quietly dropped.docs/decisions/343-byod-controls.md and are not built. (#343)NF.Rates.priceQuote and
NF.Invoicing.totals both summed their lines' already-rounded
amounts, while NF.Quotes.price summed the raw
qty × rate products and rounded once at the end.
Measured on two per-km legs (130.5 km at $3.85 and 40.5 km at $2.55,
whose raw products are 502.425 and 103.27499999999999): the quote came to
$605.70 / $666.27 and the invoice to $605.71 / $666.28 — a
cent apart on the two documents a customer reconciles against each other. The
frozen accepted snapshot carried both halves of the disagreement, storing
per-line amounts that summed to $605.71 under a subtotal of $605.70, so the
immutable record disagreed with the sum of its own line column; and
NF.Rates.variance then reported a 1c margin leak on a job where
nothing had changed. All money in js/integrations.js now goes
through the one nfCents helper, and nothing already issued is
rewritten — an accepted price stays the number that was accepted.Intl rounds the float it is actually given: 40.5 km
at $2.55 is 103.27499999999999 in JavaScript, strictly below its own half-cent,
so the letter printed $103.27 while NF.Invoicing —
which rounds through nfCents, and so adds
Number.EPSILON first — billed the same line at $103.28.
The letter now prints the figure the invoice will charge, so its own column adds
up to the subtotal printed beneath it.wouldBe is the half of a demurrage claim that carries the money, and
it was rendered with a bare toLocaleString() — which sets no
minimum number of fraction digits, so a whole-dollar figure printed
$330 and 3 hr at $145.10 (435.29999999999995) printed
$435.3, on the invoice's own explanation of a charge it is
declining to raise.NF.Quotes.conversion computed its percentage as
(n / d) * 1000, which divides through a float
before it scales — the ordering docs/modules/rates.md rules
out. Measured across every ratio with a denominator up to 400, two come out a
tenth of a point low: 201 of 400 is 50.25%, and
(201/400)*1000 is 502.49999999999994, so it reported 50.2 where
50.3 is correct.tests-fleet-history,
tests-fleet-rollup, tests-speed-events and
tests-geofence-events used to fake the AWS SDK and run the real
DynamoDB adapter on top of it — so claims like “the rollup read is
one Query per day partition” and “every read narrows on the asset at
the database” were being proved through store-dynamo.js, which
nothing has deployed since the DynamoDB decommission. All four now run against an
in-memory double at the store.js seam and assert the read the
handler planned — which partitions, the per-asset narrowing, the
prefixes a state read excludes, how many keys a by-key read asks for. Same
claims, dead adapter out of the middle: 172 assertions in the speed-event suite,
66 in the geofence suite, 63 in the rollup suite and 30 scenarios in the history
suite, all green in both telemetry layouts.backend/tests-dynamo-double.js is an in-memory implementation of the
store.js interface with no AWS SDK, no chunking, no paging and no
retry — those belong to whichever adapter ships, and it records every call
and injects faults, which no adapter would. Run directly it checks its own
fidelity, including that its method list is read out of store.js's
interface block (a new seam method reddens it rather than going quietly unfaked)
and that nothing under backend/src/ can reach it. Being a
backend/tests-*.js file, the suite glob picks it up with no workflow
change.UnprocessedKeys re-request, the 100-key chunk limit and the
empty-but-not-last page were assertions about code inside the adapter being
deleted, with no faithful RDS Data API equivalent. Their Postgres counterparts
are already pinned by tests-store-postgres.js — transient
resume/throttle retried transparently, a SQL error never retried, a whole-unit
retry that cannot double-apply, and 1 MB keyset paging on every over-cap
read. What survived the move is the half that was never the adapter's: a read
that fails and keeps failing still degrades to a handled
history_unavailable, never a throw and never a cheerful empty list.#/desk, beside the count it
governs — a number of hours, the person making the change, and a live
preview that names which enquiries move into and out of “past the
target” at the figure being considered, before anything is written. The
preview also names what a change does to enquiries already answered (a
reply that was “in time” becomes “late”, which is a
different fact from an open ticket moving) and names the ones carrying no
readable date, which cannot be measured against any target at any figure. Zero,
a negative, a value that is not a number, and anything longer than a year are all
refused in the writer’s own sentence, printed in the form: a target of zero
would put every enquiry past it the moment it arrived, and one of 100,000 hours
would report nothing while looking agreed. Clearing it is allowed and is a
separate act, so handing the target back is a real thing a person can do rather
than a one-way door. It rides NF.settings, which already persists
and is already audited, so there is no new collection and no backend
deploy; the change is stamped with who made it and when, and shows up in the
change log under settings.POST /backup captures the application
database — the same collections GET /state assembles, read
through the same path rather than a second implementation — gzips it,
writes it to a dedicated encrypted bucket with no public path and no CORS, and
files a catalog row carrying the timestamp, who asked, the size (both stored and
raw), the S3 key, a SHA-256 content hash and what was included and excluded.
Every capture writes a change-log entry. It exists because of a measured loss:
the production asset register went in late August, and when recovery was
attempted Aurora’s earliest restorable point was five days after the
data disappeared — there was no application-level copy to fall back
on. The separate bucket is the security control, not tidiness:
GET /media/{key+} serves back any key the media bucket holds, so a
backup dropped in there would have been one key away from publishing every rate,
contact detail and credential in the company. The API function is granted
PutObject on the backup bucket and nothing else.data: URL, a private
key block, an AWS key id, a JWT, a bearer value, a vendor API key, a presigned
URL or the value of a field whose name says it holds a credential is replaced by
a marker naming the rule and the byte count, listed by path on the artifact and
in the catalog row. The value is replaced, never blanked: an empty field reads
as “there was nothing here”, which is a different fact.
Measured guard — backend/tests-backup.js plants eight
token-shaped sentinels across a fixture, takes a real capture, decompresses the
object the route actually wrote and searches the bytes; deleting the redaction
pass reddens 20 of its 142 checks, and dropping the telemetry exclusion from
the capture read reddens 7. The mirror matters as much: an ordinary
/media/ URL, a portal link, an empty token, a null one
and plain prose are each asserted to survive byte for byte, because
over-redaction quietly damages the recovery the file exists for.acceptToken and the wrong one for
the form a capability actually travels in — a URL inside a sentence.
NF.Desk.send keeps the whole composed body of every outbound quote
email on the quote's own trail (quotes[].emails[].body, plus
snippet) so that “what did we actually send them” is
answerable months later, and that body carries
…/portal/#accept/<quote>?t=… — whoever
holds it can accept a quote on the customer's behalf. The audit gate was never
consulted for any of it: the changed field is emails, and the
strings inside it are body and snippet. Nothing was out
of order — redaction has always run on the way in, before a row exists
— the hole was coverage. Measured: the acceptance URL starts at
character 2,278 of a 2,639-character composed quote email, so at
AUDIT_MAX_STR's 512 that capture was saved by the truncation
and by nothing else; a size control was doing a secrecy job by accident, and the
much shorter pasteable tracking message was never covered at all. The server now
also asks the value's shape — not entropy, but the two link forms
this app itself mints and portal/portal.js itself parses,
#track/<id>?t= and #accept/<id>?t= —
and withholds only the t= parameter, in place, as
[withheld:capability-token]. The sentence, the route and the record
id the link named all survive, so the row still says a link to this quote
went out and only the credential is missing. Adding link/
url to the name list was the plausible fix and is wrong twice over:
it misses body/snippet, where the link actually is, and
it blanks a POD signature URL, the published terms permalink and any ordinary
report URL, which are evidence — so the suite asserts that direction too,
including a field named trackLink whose link carries no
token. The application's own copy is untouched: the customer's link still works.
Existing rows are not rewritten — the trail is meant to be the thing
you cannot quietly edit, and the remedy for what was already captured is
reissuing those tokens (#552), whose enumeration step needs widening, because a
link captured inside a quote's email trail is filed under
changedFields:['emails'] and no search for
trackToken/acceptToken will ever find it. Needs a
backend deploy to take effect. New suite
backend/tests-audit-links.js, 65 assertions; removing the
withholding reddens 31, dropping #accept from the matched shape 26,
stopping the withheld run at the first punctuation 5../provision-site.sh no longer takes the custom domain down — and the runbook no longer documents the command that did.
AcmCertificateArn and DomainAliases were passed on every run, set or not, so
a bare ./provision-site.sh against a stack that already had a custom domain sent
--parameter-overrides AcmCertificateArn= DomainAliases=. An explicitly empty
value is not silence. aws cloudformation deploy accepts it as a real override
— parse_key_value_arg splits on the first = and keeps '' — so
site.yaml's HasCert condition went false, the Aliases block and the
ViewerCertificate were dropped, and the distribution reverted to its default
*.cloudfront.net certificate. infra/DEPLOY-NEW-ACCOUNT.md step 1 documented that
exact bare invocation, and fleetview.northfleet.com.au has been live since
31 Aug 2026, so there was a real certificate and real aliases for a re-run to remove.
This is the inverse of #368, whose premise turned out to be wrong: omitting a
parameter makes the CLI send UsePreviousValue, which is exactly why the fix is to
stop passing the pair unless the invocation names it. infra/provision-api-domain.sh
had the identical shape and it mattered more there — HasCert false deletes
the AWS::ApiGatewayV2::DomainName and the ApiMapping, so
api.fleetview.northfleet.com.au would stop resolving while every client's
apiBase still pointed at it, and the script then printed “no custom domain was
created” on the run that had just removed one. Its ApiId is likewise now passed
only when the run actually looked it up, instead of overwriting the recorded id with
nothing. Reasoned from HasCert plus the CLI's override semantics and deliberately
never reproduced, because the only way to observe it is to take the production
distribution's custom domain down.CLEAR_DOMAIN=1 is how you deliberately unset a custom domain, so omission can mean “leave it alone”.
Simply omitting the pair would have removed the only way to revert to the default
certificate, so the scripts now tell “I did not mention this” apart from “I want
this cleared”: nothing named sends no override at all, CERT_ARN+DOMAINS sets the
domain, and CLEAR_DOMAIN=1 is the one thing that passes the pair empty — printing
the certificate and alias it is about to remove first, in three states, because
could not read them is not there is nothing there. CLEAR_DOMAIN is not read as
a boolean: anything but exactly 1 is refused, since CLEAR_DOMAIN=0 typed to mean
“no, don't” would pass a truthiness test and take the domain down — the same
mistake !active makes on a master record. Both halves of a domain are now required
together (the refusal provision-api-domain.sh already had), and CLEAR_DOMAIN=1
combined with CERT_ARN is refused as a contradiction. Omission was chosen over
refusing when a certificate is live because omission is correct without knowing
what the stack holds: a rule that consults the read has to decide what to do in its
third state, where refusing blocks a legitimate deploy of the stack that owns the live
site's bucket and proceeding is the original bug where nobody can see it. It also
avoids making a routine re-run re-paste a 70-character ARN that CloudFormation would
have kept for free, which is how the ap-southeast-2 API certificate ends up in the
CloudFront slot. ApiStage stays omitted, confirmed by measurement rather than
assertion: its default is the literal $default, so writing it into a command line
unquoted expands the shell variable default and aborts under set -u with
“default: unbound variable” before the CLI is reached — and with set -u off it
becomes ApiStage=, an empty stage on the mapping. Nothing in CI tests any of
this: there is no shellcheck, no bash -n and no infra shell check anywhere in
.github/ — ci.yml parses infra/*.yaml and has never looked at the .sh
files. It was proved instead with a test-double aws first on PATH printing
the constructed command line argument by argument, so an empty AcmCertificateArn= is
visibly different from an absent one, across all four stack states and the deliberate
clear — never against the live stack.infra/provision-site.sh is executable, so the documented ./provision-site.sh actually runs.
It was mode 100644 in git while its sibling was 100755, so the invocation
infra/DEPLOY-NEW-ACCOUNT.md gives fails with permission denied on any Linux or
macOS clone and works only under Windows Git Bash, where the bit is not enforced. Fixed
with git update-index --chmod=+x; the proof harness for the entries above hit it
first time, which is how it was confirmed rather than assumed.capabilityFor whether a driver actually holds
the class a job needs, and it was answering a flat not-held —
“their recorded capability does NOT include this” — whenever
the store the read order happened to pick left the class out, even when another
store said he holds it. Reproduced in a bare Node realm: a driver whose licence
text reads MC / HR and whose recorded set reads ['HR'] was
reported as not holding MC, which the board draws as
“Driver is not rated for this work” and the cab as
“Ring operations before you move”. That is an unknown folded into
a negative on the one path that answers whether somebody may legally do the work,
and the two wrong answers are not symmetrical: reading a disagreement as
not-held takes a possibly-licensed driver off a run, while reading it
as held puts an unlicensed one on a road train. So the answer is now a
third thing rather than a better guess at which store to believe —
disputed, which asserts neither, names the disagreement in its own
sentence and refuses to say which store is right, because nobody has. The gate
still fires and the blocker still stands: this changes what the screen
claims, not what it allows (warn, never block).capabilityFor has a fifth word, and reusing claimed
was measured and rejected. Four distinguishable words beat five where two
overlap, so claimed — “a store asserts this but nobody
verified it” — was tried first. It was ruled out on a rendered case:
where the driver record wins with ['HR'] and the recorded map
claims ['MC'], the licence text never mentions the class at all, so
claimed's own sentence (“their licence text mentions it, but
nobody has recorded it”) is false in both halves. That sentence is not only
drawn: it is copied into the gate blocker's capabilityNote and frozen
verbatim into the con note's permanent override record, so a word whose sentence
would have to lie inside an evidentiary record is the wrong word. The fifth one
costs nothing to add — the words are a map read by key, and all three
renderers of capability (the compliance screen, the driver PWA and the
override writer) test only for not-held and otherwise print whatever
sentence comes back, so disputed escalates nowhere and falls through to
no default. Precedence is untouched (record > map > licence text), the mirror
direction stays held, and the leadership-facing overdue, TNA,
site-readiness and evidence-pack reports read statusFor rather than
capabilityFor — measured on the sample data, their counts and the
#466 capability split (2 not-held, 2 held, 8 claimed, 6 unknown, 4 unmapped) are
unchanged.revenueCards()
called NF.Invoicing.backfill() in the render path, which mints an invoice
record for every delivered con note that does not have one — so a leadership team
looking at a report created financial records, and the revenue figures moved between
one look and the next. The call is gone. The revenue figures are now summed from invoices
that already exist, and the delivered loads carrying none are named on the card
(“3 delivered loads have no invoice raised yet, and they are NOT in the figures
above”) with a link to Invoicing, which is the review queue that raises them.
Reporting less money than the fleet earned is a floor somebody can act on; minting an
invoice to make a report add up is not. The same rule was already asserted for the sibling
Revenue pipeline screen and simply had not been applied here.c.onTime !== false, so a
consignment nobody ever recorded an outcome for was counted exactly like one
recorded on time: on a four-load fixture it called three of them on time where only one
is. With nothing delivered at all it printed 100% — and since sample data is
off unless a build asks for it, a perfect on-time record was the first thing the screen
said to anybody who opened a fresh install. Meanwhile a card lower down the same page
stated in words that on-time delivery is not measurable here, because no structured
promised date exists to measure against. In place of the donut: Recorded on time,
Recorded late and No outcome recorded as three counts with the con notes
named under each, the not-recorded one drawn hatched in the screen's existing
“we cannot tell” treatment, and no percentage at all. Nothing delivered
now says so in words — dimmed, not hatched, because that is something we know
exactly. The card also states that recordPOD stamps on-time when the person
capturing the POD answered nothing, so the recorded-on-time count is a ceiling rather than
a measurement.infra/ provisioning scripts finally have a test, and it runs on every PR.
#561 fixed a documented command — a bare ./provision-site.sh — that took the
live site's custom domain down, and the identical shape in provision-api-domain.sh, where a
false HasCert deletes the DomainName and the ApiMapping rather than
reverting a certificate. It was proved with a test-double aws that lived in a scratchpad and
never landed, so nothing in CI has ever read infra/*.sh: no bash -n, no
shellcheck, no behavioural check. tests/infra-scripts-check.js is that harness, landed
— 79 assertions, run by ci.yml and by npm run checks. It executes the real
scripts end to end against a double first on PATH and asserts on the command line as
constructed, argument by argument, because AcmCertificateArn= passed empty and
AcmCertificateArn not passed at all are one character apart on screen and opposite instructions
to CloudFormation. Measured by deliberately reintroducing each defect into a throwaway clone:
the #561 shape reddens 7 assertions in the site script and 7 in the API script;
reading CLEAR_DOMAIN as a boolean reddens 9; putting ApiStage on the command
line reddens 6; committing provision-site.sh back to mode 100644 reddens
1; a syntax error reddens 6. The scratchpad harness's own flaw is fixed on the way in
— the file-mode assertion reads git ls-tree HEAD, not the index, since
git ls-files -s reports what git update-index --chmod wrote and so passed in the
“before” run too; measured on a clone whose index said 100755 and whose commit said
100644, this check is red and an index-reading one is green. Nothing here touches AWS:
every AWS_* variable is stripped from the environment the scripts run in, HOME is
redirected so ~/.aws is unreachable, the double takes precedence on PATH rather than
relying on a real CLI being absent, and it refuses any call that is not
aws cloudformation …. Only the presence of a credential variable is ever inspected;
no value is read, printed, logged or compared.npm run wiring now looks in infra/, which it never did.
Its closing line — “is anything on disk that nothing runs” — was answered
“no” only because the accounting named three families (tests/suites/,
backend/tests-*.js, tests/manual/) and infra/ was in none of them. That is how a
provisioning script sat at mode 100644 with the runbook telling people to run it. There is now a
fourth family, accounted for by extension rather than by a list of the two scripts that happen to
exist today: *.sh is covered by tests/infra-scripts-check.js and *.yaml by
ci.yml's template-parse step, both of which glob the directory, so a third script added
tomorrow is covered the day it lands. A file with any other extension is refused until somebody names
the check that covers it or excludes it by name with a reason — the same refusal this script
already makes about a stale browser-suite exclusion. Measured: a new infra/rotate-keys.py fails
the check, as does removing the ci.yml step (2 findings), deleting the suite, or changing it to
name the two scripts instead of listing the directory.GET /odoo/assets counted the records
it read and never the records that were there — search_count
only fired when the read hit its cap — so a payload showing no vehicles was
indistinguishable between a fleet that had been archived and one that had never
existed. Measured on the live database on 3 Sep 2026, that is exactly what it
returned: available:true, count:0, no warnings, having
returned 900 vehicles on 30 July. The count is now taken on every read, for
vehicles, service records and contracts alike, as a pair — under Odoo's own
domain, which silently excludes archived records, and again counting them —
and each read says in one sentence what its answer means. A count nobody could
take reads as unknown, never as zero, and carries the reason it is
unknown. Cost: six extra calls, three extra round trips of latency, because each
pair is issued together.node backend/probe-odoo.js prints the count pair for all
three models; when nothing is visible at all it reads the API user's own allowed
companies and lists the record rules on fleet.vehicle with their
domains, which is the one cause a count cannot see — a record rule filters a
count exactly as it filters a read, so a hidden fleet and a deleted one both
answer 0 of 0. --id <known id> separates those last two
by Odoo's own exception class. All of it is read-only through the existing
whitelist, which needed no widening; nothing un-archives or restores anything,
including to test whether archiving is the cause. backend/tests-odoo-probe.js
drives the real script against a fake Odoo, because nobody here holds the Odoo
credential and a diagnostic nobody has seen run gets run for the first time by
the person who most needs it to work.NF.Rates.levyAt({ customer }) with no
at:, so it read today's percentage — while the priced lines on the same
page are stamped. Measured on the sample rate table: a quote priced on 6 Aug 2026 is
charged 24.47% and its own charge line reads Fuel levy (24.47%), and once the
7 Aug rate arrived the caption above it read “Fuel levy: 32%” — one
document, two percentages, on the figure a customer is most likely to query, and
FR-QD-14 (“reprint any issued quote exactly as it was sent”) failing
for the first concrete time anybody had pointed at. Worse, the caption was emitted with
no look at whether a levy line existed at all, so a quote carrying no levy still
stated “Fuel levy: 32% (applied to cartage; included in the total above)”
over a total containing none: a false statement about money, in writing, to a customer.
Every figure in the caption now comes off the quote's own stamped record — the
code:'levy' row on q.rates, which is the same array the printed column
is composed from, so the caption and the column cannot disagree — or the percentage
written into that row's label, which is the shape an accepted snapshot's line holds.
Where the quote charges no levy there is now no levy sentence. This is not a
second levy resolver: NF.Rates.levyAt() still decides every levy that is ever
charged, once, at pricing time (#353), and the letter no longer consults the rate
table at all.“Fuel levy: 0%” is never printed for a levy that was not applied.
A hand-typed or older levy line can carry a dollar amount and no percentage anywhere.
That the charge is in the total is certain, so the letter says so — and then says the
percentage it was priced at is not recorded against this quote, rather than filling
it in from today's table, which is the defect above wearing a fallback. It is #328's
shape: “that wording is no longer held” beats today's text under an old
version number. The three states where there is no levy line keep their own sentences,
each off the stamp the quote was priced with rather than a live read: excluded by
agreement, a configured nil, and “no rate was on file when this estimate was
priced … that is not nil”. “Fuel levy: 0%” is refused
outright for a levy that was not applied, because it reads as one calculated at nil
— a different invoice query. The one sentence that may still come off live
NF.clientTermsFor is the exclusion (“your agreed rates include the
fuel”): it is not a figure, the document corroborates it by carrying no levy line,
and the stamp wins wherever there is one. An already-issued quote reprints
differently from before — that is the point of the fix, and it is
customer-visible. This governs the unattended reply too
(backend/src/mailquote.js calls the same one producer), which is where it
mattered most: nobody reads that email before it goes. Verified to fail, measured
off node tests/run.js: restoring the original block reddens 34
assertions, re-reading the percentage live while keeping the no-line guard reddens
25, and dropping only the no-line guard reddens 7. The group composes one
stamped quote in three realms pinned to 6 Aug 2026, 22 Aug 2026 and 1 Mar 2027 and
asserts the caption line is byte-identical.tests/suites/53-reports-performance.js going red on "any day other
than the real one", and named two candidate causes: a screen that would state
the wrong date to a real reader on some real day, or an artifact of skewing the
process clock rather than the realm's. It was the second, measured two
ways. The suite is green on a genuinely different real local day —
231/231 under all six of UTC,
Australia/Perth, Pacific/Kiritimati (whose own local
day is a calendar day ahead), Pacific/Niue,
America/New_York and Europe/London, on unmodified
master — and red only when Node's Date is monkey-patched,
which is not a day anybody will ever have. The cause: tests/run.js
never loads the dispatcher's screens, so this suite loads
js/screens/55-reports.js into a second vm
realm, and a fresh realm created with no Date in its sandbox gets
V8's own built-in on the real system clock. So loadNF({today})
— this repo's one way of checking day-independence — moved
NF.localDay(), NF.schedToday() and
NF.Pipeline.month() onto the faked day and left every card's
at = Date.now() on the real one. The screen's own day
derivations were correct throughout and are unchanged; no card, figure or
date a user sees has moved. What changed is that the claim can now be driven:
the loader hands the screen the realm's own Date, and a new section
builds ten realms per run — eight consecutive faked local days,
which necessarily contain a Saturday and a Sunday, plus the last day of a month
and the first of the next, since two of the three cards are keyed by a calendar
month. The suite went 231 to 365 assertions; dropping the one-line
loader repair reddens 47 of them, and the single swept day that stays
green is the real one. Recorded in
docs/modules/testing.md as the twelfth occurrence of the
day-dependence class — the first of the twelve that was never actually red
on the calendar, and a reminder that a day-independence claim nothing can drive
is worth the same as no claim at all. The sweep's own first construction
repeated the fault (it appended this month's boundary, colliding with the
consecutive eight on every month end: red at 31 Jan, 28 Feb and 31 Dec) and was
caught only because it asserts its own shape.navigator.serviceWorker.controller
being non‑null, which meant “this worker was already running” until the
site’s CDN‑resilience worker took scope / on 24 Aug: that
scope covers /driver/, so on a device that has already opened the
dispatcher app or a customer tracking link, the driver PWA came up under the root
worker and reloaded as soon as its own worker claimed the page. The controller is
now compared to the driver worker’s own URL, so a genuine update still reloads
and a first install does not. Measured: on the twenty‑driver run‑sheet
check, one driver in twenty — always the first, because every later load is
already under the driver’s own worker — had the sign‑on screen back
up mid‑test, reporting an empty run sheet as a disagreement between the app
and the board that did not exist.js/data.js generates day −7 to +21 around today on every load,
but the sync replaces the board wholesale with the database's copy, and a
production build (no sample data) generates nothing anyway — so the board
walks off the end of whatever was saved. Measured on one document
re-applied at increasing ages: runs on the board over the next seven days go
145 → 113 (age 7) → 111 (14) → 35 (20) → 18 (21) →
zero from age 22. The lanes outlive the cells, so the compliance layer
kept answering “No job allocated today or tomorrow” — a
settled negative about two days the roster does not reach — for 20 of
20 drivers, printed verbatim beside an expired licence and ranked
least urgent of the four, so it sorted below every real no on the very
queue built to surface it. It now answers roster not known, naming the
last day the driver's line reaches and how many days behind today that is, so
somebody can go and refresh it.backend/refresh-roster.js, which replaces the roster and so
discards any run a dispatcher moved by hand. Pinned by 39 assertions in
tests/suites/01-dates-zones-scheduling.js, day-independent by
construction and swept over eight consecutive faked local days including a full
weekend; verified to fail on the summary line of a full run — removing the
branch reddens 15, using “is there a cell on this day”
instead of the line's reach reddens 3, and letting it outrank an
allocated job reddens 6.NF.reconcileJobs() derives a
job row for every run the board holds and the jobs list does not, on every
client, at boot and after every sync — and it named those runs
'JOB-' + (++seq) off a max(digits on the board) scan,
which is not shared state. Measured on the sample board: one dispatcher
authoring one extra run earlier in the sweep shifted every later number by one,
so all 62 ids named a different run on each client and merge-by-id kept
62 rows carrying the wrong run's time, route and goods — a silent rewrite
rather than a shortfall anybody would notice. #542 fixed it by hashing the id
from the cell's own content; what makes the rule worth stating is the near miss,
because reusing the random minter #383 had just added was measured worse than
the bug (0 of 62 ids agreed, 61 duplicate cards on a driver's sheet, and 26
extra board rows that are permanent and self-amplifying), random having fixed a
collision by destroying the determinism a derivation exists to have. The rule is
now in CLAUDE.md's cross-cutting list, with the full account in
docs/modules/two-schedulers-one-board.md and the derivation, the
deleted digit scan and its load-order constraint in
docs/modules/job-day.md. Two of the verify-to-fail figures were
re-measured on master while writing it: restoring the sequential mint reddens
11 assertions in tests/run.js (10 when the branch was cut,
before the suite grew) and minting from the CSPRNG instead reddens 12.
Three claims that had gone stale on master are corrected in the same pass: the
Fleet Dynamics endpoint configuration was three files rather than two and
all three have said https:// since #565, the TLS handshake it left
unverified was settled by the 3 September deploy, and the crew screen does now
name a licence-store disagreement (#564) even though its capability chips remain
uniform. Documentation only — no code, no behaviour and no test changed.docs/policy/retention-residency.md, which stated in the
present tense that app-level backup files are kept for 30 days in S3 —
so a reader following it would go hunting for a bucket mid-incident. Measured on
master: grep -ri backup backend/src/ matches nothing, the only
S3 bucket the template declares is MediaBucket (signatures, POD photos
and permit PDFs, explicitly out of the epic's scope), and #529–#533 are all
open, so Aurora's own automated backups are the only recovery path that exists
at all rather than merely the only sanctioned one. The bullet is now future-tense,
and records for the capture engine still in review that its IAM grant is a
hand-written s3:PutObject only (it deliberately cannot read or
delete a backup) and that apply() has no backups line
(#593), so nothing populates in the browser until one merge line lands.
docs/modules/movement-coverage.md claimed tracked coverage of 559
and a residual of ~151; re-measured against the live document, coverage is
573 — confirmed twice, as NF.trackedPlates().size and by
walking the register's exclusive buckets — because bus,
lv and plant were ticked track live after the
projection was taken, and the residual is 23, of which
NF.ASSET_NOISE matches 0 against #537's expectation that they
carry status notes. The 137 that made up the difference was never a
data-entry gap: those rows are typed trailer, which ships
track: false, so they are one checkbox rather than 137 phone calls.
The stale 205 not-tracked figure was its arithmetic partner and is 191.
Finally, CLAUDE.md's index section states that it exists "because a
document nothing links to is a document nobody reads", and six of the eleven
documents under docs/audits/, docs/decisions/ and
docs/policy/ were absent from it — the portal state-exposure
audit and the #77/#76, #236, #267, #350 and #537 briefs, each now indexed by the
measured finding that makes it worth opening. Documentation only — no code,
no behaviour and no test changed.Compliance screen
— no notice, nothing on any other page. There is now an Escalation tab
that derives a three-rung ladder and files a notice to the event log as each date
passes: to the person when a credential enters its own renewal window, to
their supervisor at half that window (capped at 14 days) or as soon as it
lapses into grace, and to the ops manager once it is past expiry and past any
grace — the point it stops releasing a job. Each rung is filed once per person,
requirement and tier, so a repaint cannot re-notify anybody; measured in a browser,
three repaints of the tab leave the notice count unmoved. It also runs when the
database lands, so escalation no longer depends on anyone opening the screen.
gaps() lists only the people who are not clear, so
neither screen carries a per-requirement denominator. With six of twelve MC
licences filed and verified, the TNA reports REQ-MC-LICENCE at
6 short and sorts it above REQ-BHP-NEWMAN at
5 short — when Newman is short for all 5 of the people it
reaches and the licence for 6 of 12. Two of report 5's columns are
therefore new capability, not a duplicate. The full brief, with a
file:line for every claim, is in
docs/decisions/236-requirement-report.md. No production code changed.demoData:true: 6 published requirements, of which 1
carries a courseId — and that course
(IND-CORE) is a video with no quiz. The one course that does
carry a quiz (ASM-HAULROAD) is attached to no requirement.
So 0 of 6 requirements could produce a score. Two further findings behind
that: courseCredential() synthesises a credential from a completion
but drops sub.score, so even where a score exists it never
reaches the compliance layer; and the only UI in the tree that files a
credential is the driver's own ticket form (issuer, number, issued, expiry, note)
— there is no dispatcher-side filing form at all, so an assessor's mark has
nowhere to be typed by anybody except the person it is about. The brief recommends
omitting the column for externally-issued credentials and carrying
sub.score onto the synthetic credential as the cheap upgrade path.
The decision is Simon's, because it is about what Northfleet will assert
about somebody else's certificate on a record kept for seven years.startedAt and submittedAt, so an
assessment's elapsed time is derivable today and nothing reads it. Nothing else is
timed: no completion of a video, document or bulletin carries a start stamp. The
SCORM path may already be persisting cmi.core.session_time unread
— the shim posts the whole cmi bag and the driver stores it
verbatim — but with no package to run that is inference, and the brief marks
it unverified rather than claiming it. The recommendation is to drop the
average-time column and instead stamp an open in
openCourse() (one choke point, and
NF.CourseSubs.submit() already passes startedAt through,
so no model change and no backend deploy), which makes report 5's in
progress column real for the whole catalogue rather than for one quiz. The
reason a duration is refused is stated plainly: a driver opens a bulletin at the
pickup, gets called to move the truck and comes back forty minutes later, so
closedAt − openedAt would flatter the least busy people and
read as diligence.cmpWaivers()) is the only one of the ten with no
“as at” anywhere and the only one with no
cmp-empty card — its empty state is one sentence inside a
<td colspan="7">, which does not tell “no waiver has been
granted” apart from “no requirement exists, so none could be”.
And the expiring report prints an as-at only when the overdue table
above it has rows: in the good-news case (nothing lapsed, things expiring soon)
the screen says least about itself, and its empty branch states more
coverage than its populated one. Both are copy-and-a-count fixes reusing
coverage() and cmpWhen(); neither is a wrong figure and
neither needs a decision. The other eight carry CSV, coverage-and-as-at and at
least one named empty, with five carrying three or more distinct empties.#/fatigue over the
NF.EWD seam, which until now nothing in any of the three apps had ever
called. An Electronic Work Diary must be NHVR-approved to be legally valid, so
FleetView is not one and the screen says so in full-size text on every path: the
figures are a planning aid, hours are on paper today, and there is deliberately no
way to record hours here. With no certified provider connected — which is
every build today — each driver reads as a hatched unknown rather than
a zero or a green "compliant", because the adapter's fallback
work: 0 means "nothing was passed in", not "this driver has not
worked". Where a provider can report something it is shown with its provenance
and its age: a reading outside the freshness window keeps its value but is drawn
dimmed and dashed with the time it was taken, and its warnings are
re-worded into the past tense, because a four-hour-old breach is not a statement
about the driver now. The planned column counts days off the schedule board
— planned work days, planned rest days and days recorded unavailable
— never hours: a duration derived from planned times would manufacture
exactly the record a work diary exists to keep.docs/modules/ewd-compliance.md said the driver portal's Hours tab took
its figures and its disclaimer from NF.EWD.notice(). There is no such
function anywhere in the repo and no Hours panel — the driver app's tabs are
Run, Drive, Prestart, Truck and Learning — so the in-cab break warning
Anthony asked for by name is still unbuilt rather than built. The doc now records
what exists, what the new dispatcher screen refuses, and the five steps connecting
a certified provider would actually take (including that the credential does not
belong in config.js, which ships publicly with the site).backend/src/edi-coates.js
derives exactly that dataset off a con note, plus every reference we hold that
they might match on — each labelled with where it came from, and none
nominated as the key, because which one they use is theirs to answer. It
emits nothing: no route, no schedule, no queue, and the module's code is
asserted to contain no require(, no fetch( and no URL.
The transport is a documented seam that refuses, naming the one thing missing
— the technical specification, held by Coates IT in South Australia,
described verbally at GM level for about three years and never obtained on our
side (OQ-04). docs/modules/coates-edi.md is half
module note and half the request itself, written so it can be sent as it stands:
ten numbered sections covering the transport, the format and its version, field
names and types, how they want a signature represented, which of our references
keys to which of theirs, what they want when there is no signature, corrections
and cancellations, the acknowledgement, retry, and go-live.HH:MM with no date at all, so a POD whose only
stamp is a leg time reports the time it holds and refuses to infer the day. A
recorded name with no signature artefact, a split family's head, a withdrawn
part and an archived record are each their own named refusal. The signature image
itself is never in the payload. The derivation reads a clock nowhere, so the same
record derives a byte-identical payload forever. Measured on a
134-assertion suite (backend/tests-edi-coates.js): letting a
status-only con note count as PODed reddens 7 assertions, dropping the
undated-delivery refusal reddens 5, and writing the archive test as
!c.active instead of c.active === false reddens
18 — it refuses every con note ever booked.c.legs[i].covers as an explicit
override since they shipped, and nothing wrote it — so in practice every chip on the
board was derived from the chain's geometry. The boxes are on the New consignment
form, under “Who is doing which part”, and there are three states rather
than two: derived (nothing stored, the chain answers, and the panel shows what it
says with nothing ticked), set (somebody chose — still an override even where
it agrees, because it will not move if the chain changes), and set where the chain
disagrees, which names the phases it adds and the ones it drops instead of quietly
winning. Derived is drawn dashed and labelled auto, never hatched: the
geometry is a known answer, not an unknown one. Unticking every phase goes back to
derived, which removes the stored value rather than storing “covers nothing”.
Nothing about the traffic-light colour changed, and no backend deploy is needed.#/reports with the reporting work, so the one thing keeping that issue open
is built. The schedule board's horizon disclosure is unchanged, because the board is
still windowed and still cannot answer “why is this job from last week still
showing?” — the answer now exists somewhere that can.docs/decisions/350-open-questions.md takes #350's
register of open questions and adds the three things that separate a register
from something that gets answered: a cost of delay per question in the
register's own measured figures, a recommendation with its reasoning per
question, and every "blocks" claim re-checked against master. The verification
is where the value is. OQ-22 (is the enquiry reference customer-visible)
was answered on 22 Aug 2026 and shipped — enquiry:'E' is in the
numbering settings — so the register's stated risk, that the matching cascade
would degrade to sender-plus-subject, is retired. OQ-19 blocks the terms
content and not the mechanism, which landed in #328. OQ-23 does
not gate mailbox intake, because the poller shipped ahead of it and is landing
raw message bodies now. And two questions have been quietly pre-empted by code:
detention rounding is already Math.ceil to the whole hour, the
opposite of the recorded habit and a factor of two apart on the same wait, and
an hourly job already prices off the origin depot's card on site time only.
Also written up: eleven blocking questions rather than the stated ten, with the
arithmetic shown; agendas for three meetings and one email that somebody can
walk into; the six register overlaps confirmed against
99-open-decisions.md, where two of the six are two questions each
and one is three; and five things that could not be recommended without
business knowledge nobody has written down, named rather than guessed.minHours is free text
("3 (Mon-Sat) / 4 (Sun)") read with a first-digit-run match, so a short
Sunday hourly job out of Hazelmere bills a 3-hour minimum where the card says 4.
The fuel levy resolves as at today on the invoicing path, because
priceConsignment passes no as-at date — so the delivery-date basis
is already implemented by omission, which is the silent default #355 was
designed to refuse. Published terms versions are dropped by
PUT /state, because termsVersions is in neither
backend whitelist, and seniorManager is still absent from
NF.Compliance.ROLES — both should land before anybody is asked to
publish a first wording into a store that discards it. And raw inbound email
bodies are served by GET /state to the shared site token and cached
in full on every driver tablet and in every customer's browser, while the live
read of the same inbox is staff-sign-in-only for the stated reason that the
token ships in a public bundle.no-cors POST out of the dispatcher's tab: it
never blocks the GPS sweep, and its response is opaque, so a delivered
webhook could not be told from a rejected one. backend/src/alerts.js
is the fix for that and the missing email/SMS senders at once, because all three
needed the same thing: a credentialed sender running in the Lambda. Every channel
answers {ok, error, message} and none throws — an alert is a
side-effect of a GPS sweep, and one that throws takes the sweep with it. The
webhook now reads the status. Email goes through the mailbox we already
have (graphmail.js, which can only ever touch the one configured
address), and reports accepted with delivery unknown, because
Microsoft taking a message is not a delivery receipt. SMS goes to a credentialed
HTTP gateway with the number normalised to E.164 — including stripping the
bracketed trunk zero out of "+61 (0) 412…", which left in is a well-formed
different phone. A malformed address or number is refused with its own
sentence and never attempted, measured as zero requests on the wire rather
than only on the return; a transient failure (timeout, 429, 5xx) is retried to a
bound of three and says how many times it tried, while a 404 or a 401 stops at
one. Not configured is a third answer: neither sent nor rejected, naming
the parameter path to store a key at. No credential value reaches a returned
object or a logged string, asserted in both directions — including the
realistic case where the gateway echoes our own Authorization header
back inside its error body. 184 assertions in backend/tests-alerts.js,
in CI via the existing glob; breaking enabled !== false into a
truthiness test reddens 25 of them, attempting a malformed destination 11, and an
unconfigured channel claiming success 8.#/geofences takes both
alongside the webhook in the same "Where the alert goes" panel, stored on the
fence with no new collection and no backend deploy — but the sender has no
route in front of it (that route lives in a file this change could not touch), so
both are drawn hatched and labelled stored, not sent, on the fence
row and in the editor. No delivery status is invented for a backend that cannot
yet feed one, and the note beside it states the webhook's own honest limit: its
response cannot be read, so a delivered webhook cannot be told from a rejected
one. A refusal about a destination is printed in the form, beside the field
it is about, rather than as a toast that slides away while somebody is still
reading an address back to themselves — and a comma-separated list gets its
own sentence, because a distribution list belongs in a mail group where somebody
leaving is somebody's job to notice. What is stored is normalised, so an address
typed in capitals and a number typed four ways each read one way to the sender.
The exact wiring that switches sending on — the route, its gate, the request
shape, the one credential function and the SSM grant — is written out in
docs/modules/geofence-alerts.md, along with the who-can-edit
recommendation: gate the sending rather than the drawing, and bound the
destinations with a domain allowlist, since the dangerous act is naming an
address outside the business rather than drawing a shape.marker-icon.png,
marker-icon-2x.png and marker-shadow.png were three
further requests neither list named. With unpkg unreachable the library loaded,
the map drew, and every plain marker rendered as a broken image: the fence
editor's draggable centre handle, and the origin and destination pins on the
customer's own tracking page — the one reached from a link in an email. All three
are now in sw.js and driver/sw.js, at a measured cost
of 4,548 bytes. Not layers.png: no app creates a layers control, so
it is never asked for. Nothing about how Leaflet is loaded has changed, so the
still-open decision on vendoring it is untouched.driver.css from the driver
worker's precache list reddened zero assertions, because the matcher read
<script src> tags only — and a run sheet with no CSS is
unreadable in a cab, not merely plainer. It now reddens one and names the file.
The list parser also strips its own comments first: an apostrophe in a prose
comment inside the list used to open a string that swallowed the next two entries
and report three phantom paths nobody had written.leaflet.js and leaflet.css on any
host, so no check could tell a CDN load from a locally-served copy — and a
locally-served copy that failed to load would have been quietly answered by the
harness instead of failing. It is now scoped to unpkg.com, and
serves the three marker images from the same pinned copy, so a broken pin is a
red check rather than something no suite can see.portal/index.html loads the same js/data.js +
js/integrations.js the dispatcher does and
NF.Store.load() runs at module initialisation. Measured in a clean
Chromium against a production-shaped build (no sample data, the document
arriving from a stubbed GET /state): all 47 registry
collections land in the tab — 236,032 bytes parsed, 29,114 on the
wire gzipped — and 234,124 characters are written to
localStorage under nf_state_v2 with no expiry,
still there after the browser is closed and reopened. What the tracking view
actually reads, projected from the same document, is 1,087 bytes (677
gzipped): 217× raw, 42× gzipped. Scoped to a whole
account it is 12,950 bytes and 33 of the 48 collections drop out entirely
— including every credential, form submission, driver↔HQ message and
raw inbound email. Of the 50 consignments in the document, 44 belonged to other
accounts; of 65 job threads, 58; and every consignment's
trackToken — a bearer capability — rides along, so one
customer's link hands them everybody's. Written up in
docs/audits/portal-state-exposure.md: what is exposed collection by
collection, the bytes and the ratio, where it lands and how long it lives, the
list of collections and fields portal/portal.js genuinely reads
(derived from its own reads, and the specification for any scoped read), and
five costed options — three of which need the leased
backend/src/handler.js and one of which needs no backend deploy at
all. No production code changed here. Two constraints the audit found and
any implementer has to carry: the portal presents the shared bearer token from
the public bundle, so on the “signed-in” path there is no verified
identity to scope by; and PUT /state writes a col#
singleton wholesale whenever the key is present, so a scoped client
document must omit collections rather than truncate them — narrowing
NF.customers to one row in the portal would replace the customer
register with that row.GET /fleet/speedevents bounds its own fan-out at 128 day partitions
and reports a trimmed window as windowDaysRead of
windowDays, plus future for rows it read and refused as
impossible (dated after the end of the window — a clock fault on whatever
recorded them, not missing coverage). NF.FleetDynamics.speedEvents()
forwarded complete, unread, reads,
unwritten, truncated and the route's own
note, but dropped those three — so a trimmed read reached the
speed screen as a partial whose cause it could not number: it could say
“incomplete” but not “128 of the 201 days you asked for”.
Measured against the live API on 3 Sep 2026, the sibling day-partitioned route
answers ?days=200 with windowDays:201,
windowDaysRead:128; the same three fields now pass through the same
tri-state normaliser rollup() uses — one shared vocabulary, so
a finite 0 survives as a measured “nothing was missed”
while an absent field stays null, because an older Lambda that never
reported is not a complete read. Not a live defect: nothing in the app asks past
128 days today. GET /fleet/geofence reports the same fields and
still has no front-end reader — the only client call to that route is the
crossing write, which reads written and nothing else.deploy.yml's Write config.js step was the last place carrying
${{ secrets.API_TOKEN || vars.API_TOKEN }} — a fallback added so
publishing kept working before the repository secret existed. The secret now
exists and is what deploys use, measured on the deploy of 3 September 2026, whose log
read apiToken -> set (from the API_TOKEN repository secret; masked by the
runner), so the variable branch was already dead code. GitHub does not mask
vars.* at all and the runner prints a step's env: block
before the script runs, so had the secret ever been removed that fallback would
have printed the token in cleartext on exactly the run nobody expected it — the
#292 leak itself, not a cure for it. The step is now
${{ secrets.API_TOKEN }}; with the secret absent it warns and publishes an
empty apiToken rather than reaching for an unmasked value. The three-way
"which source did this come from" log went with it, because a branch that can never be
taken would have misdescribed every future run. One step remains and it is not a code
change: a repo admin must delete the API_TOKEN repository
variable, which nothing now reads but which still holds the token in repo
settings./fleet/assets route from a scratch copy of template.yaml makes
the route check exit 1 naming that path — the exact failure it exists for, "a 404
sitting in front of perfectly good code". Deleting ApiFunction's
GRAPHMAIL_SECRET_PATH grant makes the SSM check exit 1 naming the prefix the
Lambda could not then fetch. Replacing one script invocation in
deploy-backend.yml with echo skipped makes the wiring check
exit 1 naming the script and why a deploy may not skip it. All three steps sit after
Verify before deploying and before setup-sam, the role assumption,
sam build and sam deploy, and none carries if: or
continue-on-error, so a non-zero exit aborts the run before anything is
deployed.infra/provision-site.sh was never resetting the CloudFront price class.
The report was that omitting PriceClass from --parameter-overrides made
the template default win, silently putting the distribution back on the most
expensive PriceClass_All. aws cloudformation deploy does the opposite: it
sends UsePreviousValue true for every template parameter it was not given
(awscli/customizations/cloudformation/deploy.py, merge_parameters) and strips
those only when the change set is a CREATE, or for a parameter the deployed
template did not declare (deployer.py, create_changeset) — so an omitted
parameter keeps the stack's current value. That is the rule this repo already
documented for DbBackupRetentionDays and siteBaseUrl. The proposed fix
(always passing PriceClass=PriceClass_All by default) would have created the
reset it was meant to prevent. The one real exposure is a first-time case with
no previous value to lose: a brand-new stack takes PriceClass_All, the most
expensive class, with nothing saying so.infra/provision-site.sh prints what is live before it deploys, and takes PRICE_CLASS.
The stack's current parameters are shown first, in three states rather than two
— the parameters, no stack yet (where the template defaults genuinely do
apply), and could not read them, which is not the same fact — followed by the
exact --parameter-overrides the run is about to pass. PRICE_CLASS=PriceClass_200
./provision-site.sh sets the price class, validated against site.yaml's
AllowedValues up front rather than by CloudFormation a minute later, and
appended to the overrides only when set, because an empty value renders
PriceClass= — the shape that has aborted deploys in this repo twice. Unset
still means “leave the live price class alone”. The header now also warns that
CERT_ARN/DOMAINS are passed on every run, so a bare re-run of a stack that
has a custom domain would drop the aliases.http:// explicitly —
backend/template.yaml's FdEndpoint default,
deploy-backend.yml's sam deploy override and
backend/deploy.sh's laptop fallback — so the stack
contradicted the code. That is not merely untidy. FD runs IIS 7.5 on Server 2008
R2, where TLS 1.2 is not enabled by default and Node 20 refuses anything below
it; if that handshake fails the fleet's live positions stop arriving, and the
first question anybody asks is what endpoint is it configured with? The
answer was http://, sending whoever asked after a defect that was
already fixed while the fleet was dark. FdEndpoint is also the
rollback lever, and an operator reaching for it under pressure should find it
already saying the right thing. All three now say https://, and a
new static check in backend/tests-fd-https.js reads the three files
off disk and fails if a cleartext FD endpoint reappears in any of them —
the drift is a one-character edit that no suite previously read, and it can no
longer break the transport, so nothing else would have noticed it. The adapter
itself is unchanged: it still upgrades whatever it is given, because that stack
parameter is hand-settable and CloudFormation keeps whatever it was last passed.NF.SPEED_SCAN_ASSETS (120) candidates on the fall-back
path, or a recorder answer that can come back short — and the screen said
nothing about either. Measured on a 200-asset fixture: 160 candidates, the worst
120 read, 40 dropped, 20 over-limit runs filed, and not a word about the 40.
The #ev-roll slot covers only the daily-summary read (#312), and on
the recorder path no summary is read at all, so it was correctly hidden and the
screen was silent. The log now carries the same three renderings the alerts page
does — only an explicit complete: true may claim nothing was
left unchecked, false is a named partial printing the route's own
sentence verbatim, and absent is an older Lambda that gets its own third wording
— plus the cap remainder, the path that derived them, and a refusal saying
that an empty log is not a measured zero when the scan could not run at all.spdScopeNote() printed on #/speeding are now
built by spdScopeBits() and painted by spdScopeInto()
— the shape rollScopeBits()/rollScopeInto()
already has for the daily-summary read — so the alerts table and the Events
log cannot come to differ about which path produced the fleet's alerts or how
many candidates fell off the cap. A second screen supplies pointers only
(where to find the leaderboards it does not have, what to treat as a floor), never
a second version of the facts; every pointer defaults to the alerts page's own
wording, so that screen's text is unchanged to the byte. No fetch changed: the
narrowing itself shipped in v0.123.0 and v0.128.0, and opening Events still costs
exactly one named read — measured at one request naming 120 assets, against
the unnamed whole-fleet read this issue was raised about.svc.fleetdynamics.com.au over http://, which
put the FD login, the ASP.NET session cookie and every vehicle position on the
wire in cleartext — and that session cookie is itself a credential granting
the whole fleet's live location. The issue had been recorded as unfixable
without the provider ("it needs FD to offer HTTPS"); measured on 3 Sep 2026 that
is no longer so — FD serves the same service on 443 behind a valid,
hostname-matching certificate, and its own WSDL advertises https://
in all four bindings. FD does not redirect http→https, so the caller
has to choose: the scheme is now forced in the adapter, applied to the
configured endpoint rather than only to the default, because the deploy
stack still passes http:// explicitly — fixing only the
default would have left production in cleartext with every test green. There is
deliberately no HTTP fallback, because a downgrade that works looks identical to
a secure connection.NEW means
Newman, Newman (Whaleback) or Newman Airport — three rows on the register,
three prices — cannot perform the check we are asking them to perform.
Both messages now name a place through NF.placeName, the one shared
reader in js/data.js that the quote email and the acceptance page
already used, rather than a second copy of the resolution. A place we hold no
record for is still echoed as the customer’s own wording, which is the
honest value and the one they can correct.N/A, and the other end of
the lane is unaffected.NF.poOf itself
reports as uncertain, because the con note's reference box holds a purchase
order, the customer's own job number or something else again. The remedy was the
expensive half: a dispatcher who goes and gets a second purchase order for a job
that already has one bills the customer twice, against two POs, for one load.
Where either side of the match came out of that box the refusal now says the
string may be the same purchase order, names both con notes and where each
value came from, and asks for the phone call that would settle it instead of the
paperwork that would not. It still blocks — an ambiguous reference was kept
blocking deliberately, because dropping it loses the warning on exactly the
customer who records their PO in the reference box and nowhere else — so what
changed is what the dispatcher is told, not whether they are stopped. A
recorded PO keeps the flat wording, and a split part inherits its parent's
certainty either way round.NF.invoiceBlock() now owns the sentence, the certainty and the
suggested next step, and the schedule screen renders them rather than composing
its own wording — the writer's-own-sentence rule the rest of the app follows. It
also puts the claim inside reach of tests/run.js, which loads
js/data.js and never the app: while the screen composed the
sentence, what the dispatcher is told about a decision to bill somebody could
only have been matched against source text. Two smaller repairs came with it —
the printed PO is read through NF.poOf instead of a hand-rolled
c.poNumber || c.ref, which had printed "PO was already
invoiced" with an empty number for a PO captured on the thread at
acceptance; and the customer lookup is guarded, where an unresolvable customer
used to throw inside the click handler so the finish button did nothing at all
and said nothing about why.licence field split on the slashes — and the read
order picked a winner in silence. So a recorded set that omits a class the
licence text names, or claims one it has never mentioned, was invisible on every
screen in the app. NF.Pools.qualsAgreementOf(id) reports
four states, not two: none, single (one store
holds an opinion, so there is nothing to check it against — deliberately
not read as agreement), agree and disagree,
with the classes each side claims named either way. The driver's capability panel
on #/crew prints that in the reader's own words, hatched, because two
stores contradicting each other is an unknown rather than a pass or a fail.
Measured on the seeded fleet: 1 of 20 drivers disagrees (a forklift class
recorded against somebody whose licence text does not mention it), 4 agree, and
15 have only one store speaking at all.MC (WA) exp 3/27 produced five
“qualifications” — MC, (WA),
EXP, 3 and 27 — and see
file produced two, each drawn as firmly as a recorded fact. The reader now
lists the tokens that name no capability we record and says which store they came
out of, so somebody can go and decide what the string meant. Nothing was
rewritten and no migration runs: an expiry claim typed into a licence field is
still there to be read.kind: 'licence' capabilities are
compared, because an endorsement or a card is not written on a driver's licence,
and what fell out of the comparison is named rather than left implied.'JOB-' + (++seq) off the highest number it could find on
the board. That runs at load and after every sync, on every client, so it was
never a name two people could agree on: measured on the sample board (23 lanes,
62 jobless runs across today and tomorrow), one dispatcher authoring one extra
run shifts every later number by one, so all 62 ids named a
different run on each client — and because job rows merge by id,
62 runs collapsed into 62 rows carrying the wrong run's time, route and goods.
Nobody would have seen a shortfall. The scan also ratcheted rather than holding
a sequence: one stale-document reconcile moved the maximum 5722 → 5784.
The run id is now derived from the cell itself — the lane's driver, the
absolute day key, the row's index in that day's list, and its time, con note,
route and title — so two clients holding the same board derive the same
name with no round trip, and it does not move over midnight. A run id is an
internal handle: it is not printed, quoted or sorted on, and the old sequence
was not one anybody could have relied on anyway.JOB-QMBTKXHRVW rather than JOB-5723, matching the shape
every other place in the app that mints one has used since v0.140.0. Letters only
means the board's old "highest number in use" scan can no longer see anything it
mints, so that scan — which had exactly one reader — is gone. The
con note and thread numbers a customer quotes back (CN-…,
Q-10042) are untouched.NF.reconcileJobs() is invoked while js/data.js is still
loading, some 9,200 lines above the app's general-purpose id minter. Minting from
that minter there would have been a TypeError during load —
the whole app down, on a path no browser test can reach, and one that would not
have fired until the first real board with a run nobody had a job for. The
derivation is defined above the call, and the regression suite now loads
js/data.js cut at that call and makes it mint, which is the only way
to see it: with the definitions moved below, node tests/run.js exits
with a TypeError before printing a single assertion.tools/release.js publishes
only the changelog fragments it can see and deletes what it publishes, so a
release cut from a branch that master had moved ahead of left the fragments
which merged in the window behind, under a version number already released
— each one a shipped, customer-visible change with no entry in the
changelog. It happened for real at v0.140.0: seven fragments merged an hour and
a half before the cut, and the enquiry reference, the quotes dashboard, the
driver's five-tab bottom nav, the effective-dated fuel levy, the quote lifecycle
stamps, the split-shipment change and the email-corpus harness would each have
gone out unannounced; it took a hand repair on master to recover. The release
tool now establishes where it stands against origin/master
before it reads, assembles or deletes anything, and refuses —
writing nothing — if it is behind, naming how many commits, naming by path
the fragments this tree cannot see, naming what is lost if it proceeds, and
naming the fix (merge origin/master and re-run). It refuses on a
--dry-run too, because a preview assembled from a stale tree is
precisely the false confidence that made the bad cut look correct. "We could not
tell" — no remote, no reachable network — is its own answer and also
refuses, rather than being read as "level".
--allow-behind covers a deliberate or offline cut and prints that it
was used, so a note that does go missing has a reason in the log. It refuses
rather than fetching the missing fragments in and publishing them, because a
fragment travels in the same commit as the code it describes: an entry lifted
out of a commit the release does not contain would be a release note for
something that has not shipped, which is worse than a note arriving one release
late.portal/#terms/<version>, so a quote sent months ago still
shows the wording that customer actually agreed to. A quote records which version was in
force when it was sent and carries the link and the number — never the clause text.
Overriding clauses are agreed per client and versioned on the same basis, addressed by their
own opaque id (portal/#terms/<version>/<overrideId>) so no customer id or
name appears in a link anyone could be shown. Published and read from Terms &
Conditions, beside Settings; the customer's page needs no sign-in, because somebody
clicking a link in an email is not signed in to anything.
docs/policy/retention-residency.md
answers the question nobody could answer before: how long does FleetView keep each kind of record,
where does its data live, and what would a purge record about itself. It inventories all
47 collections, the five telemetry stores, the change log, raw inbound email, media in S3
and every device-local key — and the answer today is that only two of them have any
expiry at all (the change log's seven-year stamp, which nothing reaps, and the posted-limit
cache's seven-day sweep). It proposes a schedule of 21 record types held as data, marks
every period as a statutory floor or a business decision, names each statute by section, and
flags anything uncertain as verify with legal — including that Western Australia is
not an HVNL jurisdiction, which the module docs currently assume it is. Six decisions
(OQ-28, OQ-48, D-14, OQ-23,
OQ-27, OQ-29) each get options and a recommendation, so answering
them is a finite task. Design and decision pack only — no code, no configuration, no deploy.ap-southeast-2 by every default in the repo but is asserted nowhere in
backend/template.yaml; StorageEncrypted appears 0 times, so the
cluster's encryption at rest is unknown from code. Six of the twelve services have unverified
residency, and the AI path is the one that matters: the quote extractor sends a customer's
Subject, From and message body to the global API endpoint with no region
pinned and no redaction pass, so FR-DB-03's second clause and FR-AO-08
are both unmet today. Also names what the backup template does and does not settle for
FR-DB-22: the 7-day retention has landed, the live cluster has still never been read
back (measured PITR reach was roughly 1.9 days on 2 Sep 2026), a restore has never been tested,
and a database restore does not restore media.NF.jobDay() would have done to every record predating the
job-day field. Nothing is labelled overdue until an age is configured
(NF.settings.podOverdueDays), and the card says that this is not a
statement that nothing is late.NF.Pipeline is called for every number, so its refusals carry
through unchanged and an invoice derived from the rate card still reads
cannot be valued rather than being counted as revenue. The two zeros stay
different at both levels: a depot with nothing outstanding is $0.00, a measured
answer, while a depot holding loads none of which could be priced is a dash, and
a report where nothing at all could be valued is a dash that says so rather than
being called a floor — a floor is a real number missing some of its parts.
Work delivered before this month and still unbilled is its own column and is not
folded into the month, an undated delivery is attributed to no month rather than
quietly to this one, and a load whose thread names no depot gets its own hatched
row instead of being dropped or spread. Months are local, not UTC.NF-18 asked for the driver app to load "within an
agreed target" and #349 recorded that there was no agreed target and no
measurement. There is now a measurement:
docs/audits/driver-cold-start.md. The driver page pulls
15 requests / 2,349,326 raw bytes / 786,336 gzipped on a first cold start,
of which js/data.js alone is 1,347,192 raw / 438,528 gzipped —
larger than everything else on the page combined. Rendered under 4× CPU
throttling, three runs per profile: on a Slow-3G-class link (400 kbit/s,
400 ms RTT) DOMContentLoaded landed at 43,381–43,384 ms
uncompressed and 14,918–14,952 ms with gzip on, first paint at
6,676–6,700 ms and 2,364–2,392 ms. CPU is not
the term: with the network out of the way the whole boot is 333–407 ms.
And the byte figures have a short shelf life: measured twice on one day, six
merges apart, the driver payload grew 19,126 raw / 6,193 gzipped bytes with
nothing in CI to notice.
Three targets are proposed rather than one, and a fourth is deliberately left
unset because the sign-in read (XP-930's measured 15.3 s for
349 KB) cannot be measured without the deployed API and is named as the
dominant unmeasured term. No production code changed.NF-18's second half is confirmed met on measured numbers, and the
levers behind the first half are ranked by size. After one good load the
service worker serves the whole shell: 10 of 10 requests from cache, zero
bytes over the network, 141–196 ms to the Sign on control across nine
runs, offline and warm alike, with the network profile making no difference
because the network is not used. Two findings came out of the same measurement.
CloudFront compression is configured (infra/site.yaml:123) and
is worth a measured 28,429–28,466 ms of Slow-3G
DOMContentLoaded — but it has never been verified at the edge, and
js/data.js is now within one order of magnitude of CloudFront's
10 MB compression ceiling, past which objects stop being compressed with no
error anywhere. And the driver sign-on screen blocks its first paint on two
render-blocking cross-origin stylesheets (Google Fonts and
unpkg Leaflet): measured on an idle local network, first paint
went from 188–208 ms to 12,632–12,724 ms when
those hosts did not answer. The #265 degrade is proven and still
works; what it cannot cover is the timeout before it can run.NF-17 wanted minimum versions stated
and tested on a real in-cab device; #349 recorded the first as "Nowhere".
docs/audits/browser-device-matrix.md derives them from the features
the three apps actually use, each cited file:line: Chrome and Edge
≥105 (dispatcher, set by :has() at css/core.css:177
and five more, with no fallback and the sidebar accordion depending on it),
Chrome on Android ≥108 and iOS Safari ≥15.4 (driver app, set by
CSS dvh at driver/driver.css:354 — the one
dvh use in the file with no vh declaration behind it,
without which the Drive screen loses its no-scroll layout). Recorded as
not setting a floor, because they were checked and are absent or
feature-detected: structuredClone, crypto.randomUUID,
Array.prototype.at, <dialog>, inert,
native HLS, Intl.*, navigator.wakeLock,
IntersectionObserver, ResizeObserver,
AbortController. The tablet's model, OS and browser appear nowhere
in the repository — and NF.Devices deliberately records only a
browser family (cabPlatform(), "the full user agent is a
fingerprint"), so it cannot be answered from data either. It is stated as unknown
and asked as three questions.docs/audits/browser-device-matrix.md carries a one-page,
14-step stopwatch script for an iPhone and for the in-cab tablet, with what to
record at each step and a single pass condition — covering the notch and
home-indicator insets, the Drive screen fitting one screen in both orientations,
an offline cold start after killing the app out of the switcher, a POD captured
with no signal, and a 48-hour wait to see whether iOS evicted the storage. It also
records what CI can and cannot answer: 72 browser suites across 6 shards
(21 driving the driver PWA, 8 the client portal, 64 the dispatcher, counted 3
September 2026), all Chromium, none of them Safari, none with real safe-area
insets, and none able to evict storage. #349's own figure of 58 suites and the
matching comment in ci.yml were both true when written and have since
drifted, which is the reason the count carries its date.docs/audits/. Issue #349's
first gap asked for the accessibility work to be scoped before it is committed to,
and for no conformance claim until it has been audited. docs/audits/wcag-2.2-aa-audit.md
is that audit — findings and a prioritised plan, no production code and no CSS
changed. Measured, not asserted: 234 distinct text-on-background pairs resolved
from the three apps' stylesheets with 48 below AA (worst 1.22:1, a white
step number on a light disc in the portal's stepper) and 887 declarations recorded
as indeterminate rather than folded into a pass; 490 rendered text observations
across eight screens in Chromium with 19 distinct failing pairs; 342
interactive boxes measured with 42 under SC 2.5.8's 24×24 (worst
14×12, the only control that returns a trailer to the pool); 83
component boundaries measured against the surface outside them with 71 under 3:1,
of which the keyboard focus ring (1.21–1.39:1 on every light surface)
and the --line control border (1.45:1 on white) are the two
systemic ones. Two colour-only status surfaces found — a dispatch-board job's
priority and a schedule run's type — against #278's traffic light, which does it
right. The document states plainly that nothing may be claimed today, lists the
six criteria currently failing, names what was not measured, and sets out the dragging-movements
question (SC 2.5.7 versus TR-062) as a decision with both sides argued:
3 of the board's 6 drag sites have no single-pointer path, and 4 already do.type on 813 of 1,190
asset rows, roughly 333 vehicles that had been reporting stopped appearing on
Fleet movement — and the page said nothing about it: the charts
simply got shorter, which reads as a fleet that stood still. Every render now
carries a coverage line: how many assets returned recorded history, of how many
are eligible (a type ticked track live, not archived), of how many rows
are on the register — plus how many eligible assets recorded nothing in
the window, which is missing data and not a stop. Counts and names, never a
percentage. The default whole-fleet view and a non-empty result both said
nothing before; only the filtered empty state named a figure.null in that state instead
of counted as zero, so a number nobody measured cannot reach the screen. Pinned
by 91 assertions in tests/suites/50-movement-coverage.js, which
evaluates the screen's own counting function out of the app source in a vm;
verified to fail, each measured on the summary line — drawing the coverage
nowhere reddens 3, reporting an unknown coverage as counted zeros reddens 20,
reading active as a truthiness test reddens 20, and folding
“no type” into “not ticked” reddens 7.100 km/h — an invented number that
nothing configured and no sign said — is gone.no_features answer from Main Roads now says which of two very
different things it means. A second, wider, count-only probe separates
sparse (Main Roads maps roads nearby, so this is ours to fix) from
outside-dataset (nothing mapped within 2 km — a private
haul road or a mine access, and the correct answer forever), with a third state
for “we could not find out” that is never folded into the second.
Measured against the live service on eight positions taken from the layer's
own geometry: querying at the fix resolves 8/8 at the current 60 m
radius, while querying at the 500 m cell centre — what the code does
— resolves 5/8. So the coverage gap in the Pilbara is not the dataset being
thin; it is the cell snap moving the queried point up to 379 m from the
truck. Widening the search instead is the obvious fix and the wrong one: at
500 m it under-reads the limit on 2 of 8 and at 2 km on 5 of 8 (a
70 zone read as 50), so the probe counts and never reads a limit. Bounded at
8 extra calls per request (68 against 60 worst case, +13.3%), cached on its own
~2.2 km grid at 16 cells to one probe, and converging to zero on a repeated
corridor. Off by default: switching it on needs a stack environment variable and
one existing assertion relaxed from an exact call count to the bound it means.#/depot/<id> were labelled placeholders with no
way to become anything else. They now read an optional cameras key
on the runtime config, keyed by depot id, and draw a still or MJPEG URL
(kind:'image', re-fetched on refreshMs), an HLS
playlist in a native <video> (kind:'hls') or a
vendor's own embeddable page (kind:'iframe'). No camera vendor is
chosen, no third-party player is loaded and nothing comes from a CDN. A browser
that says outright it cannot play HLS gets a tile saying so, and nothing is
fetched — and a browser that will not say is not taken for a yes:
measured, Chromium 151 answers 'maybe' to both HLS mime types and to
video/mp4 alike while being unable to play HLS at all, so the
obvious !!canPlayType(...) would have drawn every HLS tile in Chrome
as Not reachable — blaming the camera for the browser. It is drawn
Cannot tell — not playing instead, naming both possible causes
(reddens 6 assertions if collapsed back to two answers). Nothing configured is still the normal case and
is unchanged: four labelled tiles reading No feed configured, which a
local run has always shown and which is a real state rather than an error. The
shape is written up in docs/modules/depots.md;
config.js itself is untouched.user:pass@host — or a query
or fragment parameter whose name looks like a token, key, password,
signature or session, including the CloudFront and Azure SAS spellings and
anything ending in …Key/…Signature — is refused
with the reason drawn in the tile. The tile names the parameter so
somebody can fix the entry, and shows neither the value nor the address:
printing it is how a screen built to catch a leak becomes the leak. The check
runs before the kind, scheme and player checks, so whatever else is wrong with
an entry, the published login is what gets named. It is a tripwire rather than a
security boundary, deliberately biased towards false positives — a refused
tile is fixed in a minute and a published password is not fixable at all. An
authenticated feed still needs a backend signed-URL minter with the credentials
in SSM, which is an open decision. Measured: making the refusal sentence carry
the URL reddens 40 assertions, and letting a token= URL
through reddens 15, in the 224-assertion
tests/suites/51-depot-cameras.js.0 there would report every camera as down. The refresh intervals and
media are torn down on route change beside the existing geofence subscription, so
a yard monitor left open on ?wall does not leak a fetch loop per
visit.NF.Components.handOver() minted the remainder’s run as JOB- + one past the highest number on the board. That was a scan of the ids in use rather than of how many there are — so it could never re-issue an id it could see — but it was still sequential, so two schedule boards sitting at the same numeric max both minted the same id. Jobs are stored one row per id and merged by id (#269), so the second write overwrote the first: one hand-over remainder gone, with nothing on any screen saying so. The mint is now random from the CSPRNG, checked against the ids actually on the board, following the in-cab device register and the same fix made for con-note bookings in v0.136.0 — measured on two boards at one max, the ids were identical before and are two distinct rows after. The suffix is deliberately letters only (JOB-QWERTYUIOP): the board still mints its roster runs off the highest number among the job ids, so a hex or base-36 suffix would strip to a five-digit number and shift that numbering upward for good, climbing with every hand-over — the obvious clock-plus-random shape would have fixed the collision and quietly broken the run numbering (measured: 3 assertions red, including the max moving from 9,028 to 20,975). A ten-letter handle is worse to read down a phone than JOB-5601, and that cost is accepted rather than dismissed: the numbers this business quotes are the con note and the thread number, both untouched here. Nothing is renumbered and no existing job id changes.depot on a register row says where an asset lives, and it was only ever settable by hand, one of 1,189 rows at a time: Fleet Dynamics does not carry it and the Odoo import deliberately never writes it. So the column is mostly blank, and every screen that groups by depot — the pools rail, the depot-scoped L&D checks, Fleet Overview, the idle report — reads a fleet with no home. ◎ Depots from GPS on the register (beside Suggest types) measures every asset to the seven yards using the same distance the depot arrival alerts already use, and proposes the nearest one.assets array can no longer blank a populated vehicle register. apply() in js/integrations.js replaced NF.assets on Array.isArray(s.assets) alone, while assetTypes on the very next line had always been guarded on .length. In production something wrote an empty col#assets, and that single [] then wiped every browser’s local copy of the 1,189-row Fleet Dynamics register on the next sync — and because snapshot() writes assets unconditionally, the empty array went straight back to the database. One bad write became fleet-wide and self-sustaining, and it destroyed the localStorage copies that were the only recovery material left once Aurora’s earliest restorable point turned out to be five days too late. Downstream, trackedPlates() bails on an empty register, so telemetry history recording stopped for eight days (25 Aug–1 Sep: 0 assets, 0 points/day, against ~114,000 points/day the week before) and that trail is gone permanently. The guard now matches its neighbour, so a populated local register wins over an incoming []. The trade-off, stated: a genuinely empty register can never be synced as empty — the same trade-off assetTypes, assetService, assetContracts, serviceTiers and levyRates already accept on the lines around it. “Archive every asset” is not blocked by it, and that was checked rather than assumed: retiring an asset is active:false on the record, so archiving all 1,189 leaves 1,189 rows and a length that syncs normally; nothing anywhere removes a row from NF.assets, and even a Fleet Dynamics import returning nothing refills from a merge map seeded with the existing rows. The state the guard refuses is one no user action can produce. Removing the guard reddens 5 assertions in tests/run.js. This fixes the propagation, not the lost data: recovery is tracked on the issue.ip:203.0.113.9 — the log answered “who changed this customer’s rate?” with an address. The dispatcher now sends the one acting identity it already resolves (a verified Microsoft sign-in where there is one, otherwise the name picked in the switcher), and the driver tablets send the driver signed on to the cab — or nothing at all when nobody is signed on, because an anonymous entry is better than one blamed on whoever used the tablet last. It travels in the save itself rather than in a request header, deliberately: a header would have needed the API’s permission list updated first, and in the wrong deploy order that stops all syncing for everybody. Entries are still marked unverified, and that is the honest label — only a real Microsoft sign-in can mark one verified, and a browser claiming to be verified is recorded as unverified anyway; the name it sends is stripped of line breaks first, so nobody can forge an extra row in the exported log. Second, secrets. Two live fields are keys in disguise: the tracking link a customer uses to watch a shipment, and the link that lets a customer accept a quote. Both were being copied into the log in full on every save — into the one record nothing ever deletes, readable by everyone with audit access and included in every CSV export. Any field that is a token, password or API key is now withheld: the entry still says the field changed, when, and who changed it, but the value never enters the log. It is a scalpel, not a blanket — a compliance credential number, a signature and the time a link was created are all still recorded in full, because those are the evidence, and the test that proves secrets are hidden also proves those are not. Third, retention. “We never delete it” was true but unwritten, so nobody could answer how long entries are kept without reading the code. The answer is now seven years, stamped on each entry as the earliest date it could be considered for disposal, shown on screen and carried into the export. Nothing deletes anything: the log is untouched by the routine data clean-up, so disposing of any of it stays a deliberate decision rather than something a scheduled job does quietly. Reading the log is also narrower than dispatcher access — it needs the auditor, compliance-administrator or administrator role, because the people who can change records should not automatically be the only people who can read the audit of those changes. Two things worth knowing: entries written before this update carry no retention date and were captured before the secrets rule, so if a tracking or quote-acceptance link has ever been shared it is safest to reissue it; and the API needs redeploying for the permission-list change, though the identity itself works as soon as the front end ships.backend/template.yaml — all three were previously unset. Retention is a reviewable parameter, DbBackupRetentionDays, defaulting to 7 days (a business decision signed off 2 Sep 2026), which is also the length of the point-in-time-recovery window — PITR reaches back exactly as far as retention allows and no further. This is a real increase, not a confirmation of an adequate default: read from the RDS console on 2 Sep 2026, the live cluster’s observed PITR reach was roughly 1.9 days (earliest restorable 29 Aug 23:28 AWST against latest restorable 31 Aug 20:14 AWST). The backup window is 17:00-17:30 UTC = 01:00–01:30 AWST (UTC+8, no daylight saving, so the conversion is fixed year-round), chosen to align with the #531 app-level backup schedule and not verified against actual load — the fleet runs day and night shifts, so nobody has shown 1am to be quiet. DeletionProtection: true refuses an accidental cluster delete outright instead of relying on the final snapshot to make one survivable; DeletionPolicy/UpdateReplacePolicy: Snapshot are untouched. All three properties are mutable in place on Aurora, so no data migration and no downtime. What this does not do: nobody has yet read BackupRetentionPeriod off the running cluster, and no restore drill has been performed — 7 days is a configured window, not a demonstrated one, and with app-level restore production-disabled (#533) production recovery reaches back 7 days, not the 30 days of backup files #531 keeps in S3.NF.Basemap, and js/app.js was split into js/screens/ from a copy taken before that. The eight dispatcher call sites came back carrying the old hardcoded URL, git merged them without a murmur, and every existing assertion still passed — because they tested the adapter, and the adapter was fine. The bug was that nobody called it. The driver and client portals kept working, so the failure would have been eight watermarked maps in the dispatcher app and a green build. The new group reads the shipped source instead: nothing outside the adapter may name the tile provider, nothing may build a tile layer directly, and the call count must be exactly ten — so a map added or removed moves that number deliberately. js/screens/ is read as a directory, so a new screen with a map is covered the day it lands.NF.Basemap in js/integrations.js, beside the other external-provider adapters, and the next provider change is a change to that one block. The key itself can be rotated without touching code, from the optional MAPS_TILE_KEY repo variable.#history
is that list: every consignment on the account, with its reference, route, goods, booking date, completion
date and status, filtered by status and by period (last 30 / 90 days, last 12 months, all time), searchable
on our connote number, the customer’s own reference, a place name or what was on the truck, and printable
as a table rather than as a screenshot of an app. Where a client is switched on for self-service documents
(#252) each row also links straight to its POD and its invoice; where they are not, the column does not exist.
It claims nothing it does not know: a delivery whose on-time status was never recorded says not recorded
in its own hatched state rather than being counted as on time, and a booking carrying no date at all is listed
in every period rather than dropped out of the narrow ones, with the screen saying why. A con note split
across trailers stays one shipment, listed once at its rolled-up status. 79 checks
(tests/manual/portal-history.js, in CI); verified to fail — reading a split parent’s
stale status reddens 2, listing split parts reddens 3, dropping undated bookings from a period reddens 3,
counting an unrecorded on-time as on time reddens 2, and removing the account filter reddens 15.doc JSON column, which is the single most useful thing to know before writing a query against this schema. Each table offers its first 50 rows and an exact count in one click. Reached from Settings, not from the sidebar: it is a diagnostic surface, not a screen anybody needs to do their job.UPDATE … into a sentence a person can read; a single-statement check so SELECT 1; DROP TABLE jobs cannot smuggle a second statement past a check that only read the first word (semicolons inside literals, dollar-quoted bodies and both comment forms are correctly not statement breaks); and then SET TRANSACTION READ ONLY, issued to Postgres before the statement runs, with the transaction always rolled back and never committed. So a write is refused by the database itself — including inside a data-modifying CTE such as WITH x AS (DELETE …) SELECT, which passes the keyword check on purpose and is the case the guard exists for. Deliberately proven by breaking it: removing the read-only transaction reddens 9 assertions, and committing instead of rolling back reddens 4.sysAdmin, or nothing, on every deployment, and it fails closed — an actor whose role could not be checked gets a retry, never a served read. It is also narrower than the change log on purpose: an auditor and a compliance administrator may both read what was changed, and neither may run a query, because one query reaches every table at once, including contact details and the audit trail itself. Widening that list to the change log’s reddens 3 assertions in the backend gate suite and 3 more in the front-end pin.ANALYZEd is drawn hatched as not analysed rather than as 0 — a zero there reads as an empty table and sends somebody hunting for data that is sitting right there (coercing it reddens 1 assertion). A capped result says it is a page of the result, not the result, detected by fetching one row more than the cap rather than guessed at, and the CSV export carries the same warning plus whole untruncated values. In the grid, SQL NULL and an empty string are marked up differently, because a viewer that draws both as blank space is why people mistrust database viewers. And when a query fails, Postgres’s own words are relayed verbatim, column name and character position intact — a rewritten database error is a worse database error. A timeout, an over-cap result and a SQL error each report as themselves, because they send a reader to different places.PUT /state diff, so it records writes — and the explorer makes none. Who ran which query is recorded server-side instead, logged at the point of attempt so a query that timed out or was refused is on the record too. This is also why the explorer has no write mode and is not getting one casually: an UPDATE from here would change a record with no change-log row and no rev bump, which the dispatch board quotes back for its two-schedulers conflict detection. That is a data-integrity hole, not a policy one.Cache-Control header at all, and with no directive a browser does not refetch — it applies heuristic freshness (conventionally ~10% of the file's age) and reuses its copy without asking. The older the file, the longer a stale copy survived a deploy. Measured on the #history deploy: the edge held the correct portal/index.html and the correct portal/portal.js, byte-identical to master on both domains, and the feature still did nothing for a signed-in user — because a navigation revalidates the HTML far more eagerly than its subresources, so the browser paired the new HTML (nav item there, clickable) with the old JavaScript (no such route). No console error; Ctrl+Shift+R fixed it. The sync now sets public, max-age=0, s-maxage=31536000, must-revalidate — the browser revalidates every time (a 304, not a re-download), CloudFront still holds it and is purged by the deploy's own invalidation. And the verifier could not have caught it, because every curl in it sends -H 'Cache-Control: no-cache' and so asks the edge for a fresh copy the way no browser does: it proved the one thing never in doubt. A new check fetches the nine app entry points without that header and fails the deploy on a missing or long-lived header — verified to fail against real production before the fix, 9 of 9.js/integrations.js is loaded by all three apps — dispatcher, client portal and the in-cab PWA — and it held the WRITE side of email-to-quote, the enquiry desk and demurrage claims: NF.Mailbox, NF.QuoteAI, NF.Drafts, NF.Desk's writers and NF.DemurrageClaims. Those named NF.Intake 42 times and NF.Desk ten times, which dragged the matching parts of the shared data layer into the driver app's dependency chain even though driver/driver.js never mentions one of them. The tablet was fetching it, parsing it, and holding it in its offline cache for a shift. It now lives in js/integrations/, loaded by the dispatcher only — and by the Lambda that drafts quotes from the mailbox, which is the other thing that genuinely needs it. The cab and the client portal load neither file and precache neither.
js/data.js could have taken 88 KB (7%) off the tablet; it can now take 256 KB (20%) — 2.9×, because the enquiry desk, the email intake and demurrage are free where they were not. The same trace found the rest of that issue's premise does not hold: of the eight namespaces it lists as unused by the driver, six are pulled in anyway by parts the driver does need (pools by people and rig, rates by jobs and registry, and so on), and the file it names as the pattern to copy is not in this repository at all. So the bigger split is deliberately not done yet — the trade was four hand-maintained file lists, one of them a driver's offline cache and one a production Lambda, for a 7% saving and a conflict rate the issue itself records as 8.7%. That is now written down with the numbers rather than left as a plan nobody had costed.
NF.Desk.send(…) added to the shared file tomorrow would be a TypeError in a truck, on the one path nothing in this repo exercises — every browser check drives the dispatcher, so it would ship green. A new check reads the file and refuses it: a guarded if (NF.Desk && NF.Desk.x) passes, a bare call does not, and every reference in what the cab loads is checked line by line. It also refuses the cab or the portal loading these files, the dispatcher loading them out of order, and the Lambda layer losing them — asking the server-side loader what it actually finds rather than assuming. The layer's own packaging check was verifying only two anchor files and would have passed with the entire new directory missing; it now compares every file byte-for-byte. Each refusal was confirmed by breaking it on purpose. Separately, eleven test harnesses each carried their own copy of the two-file list they load the shared layer from; they now read one list off disk, so the next file added to it cannot be silently missed by ten of them.js/app.js was 24,847 lines and 1,033 top-level bindings in a single closure, and tests/run.js was 21,012 lines and 9,600 assertions with no way to run part of it; over the last 200 commits they were touched 46 and 30 times. With several branches in flight that is not a tidiness problem, it is where every session met every other session — and the 80-second suite was paid in full on every iteration of every change. The dispatcher app is now a shell (helpers, status vocabulary, router, boot) plus 64 screens under js/screens/, named to match the css/modules/ split the stylesheets have had for years; the suite is 48 files under tests/suites/. Both were moved byte for byte, and the originals were rebuilt from the pieces and compared before anything else was touched. Measured over the last 40 merges to master, using the same branch-pair proxy the changelog ceremony was measured with: of the 253 branch pairs that collided in js/app.js, 181 (71.5%) are now disjoint work, and of the 136 that collided in tests/run.js, 69 (50.7%) are; repo-wide the pair conflict rate falls 55.8% → 45.9%. Working on one area is now npm test -- rates, which takes 0.32 seconds instead of eighty. Nothing about a full run changed: node tests/run.js still prints 9,600 assertions, and all 71 browser suites and 30 backend suites pass unchanged.
npm test, npm run checks, wiring, backend, screen, serve — printed into every Claude session by a startup brief that CI checks for truthfulness, so nobody re-derives them from 950KB of documentation.
tests/run.js for the chain that pulls the sibling suites in; that chain now sits inside a suite file, so it reported seven live suites as dead — right about the text it was given, wrong about the repo. The ICU date sweep, the driver-precache accounting, the demo-data walk, the deploy's Cache-Control check and seven guards that read the app's source text to pin something private to it were all in the same position: they would have gone on passing while reading a file that no longer contained what they were looking for. Green, and blind. Each now reads the app as the shell plus every screen, from a list taken off disk (tests/lib/appsrc.js) so a screen added tomorrow is covered without anybody remembering. Two new checks hold the split up: tests/js-split-check.js refuses a screen index.html does not load, a tag naming a file that is not there, a duplicate, an out-of-order listing, and — the one genuinely new hazard — the same top-level name declared in two files, which in a single closure could not even parse and is now something two people editing two screens can do to each other. It compiles all 65 files too, because nothing else in CI parsed them: only a browser suite would have caught a syntax error in a screen, and it would have reported it as “the app did not boot”.toLocaleDateString('en-AU',{month:'short'}), which current ICU renders as “Sept” — four letters — where NF.fmtDate’s own month table says Sep. Two consequences, the second worse than the first: the register disagreed with every other date in the app, against the rule that NF.fmtDate is the one way a date is written; and the string depended on the ICU build of whatever browser or Node happened to render it, so it was never a stable value. It surfaced as CI going red on 2 September across two documentation-only commits — nothing in the code had changed, the calendar had — and it would have come good by itself on 1 October and broken again every September. The cell now uses NF.fmtDate. Guarded across all twelve months with fixed literal dates, so running the suite in March proves the September case just as well as running it in September — which is precisely the property the original check lacked.108 km/h · Moving · 179° from a position that never changes; that is the very sentence written into js/data.js beside NF.FIX_STALE_MS as the thing the guard exists to prevent. The position is the signal the echo cannot fake. mergePositions is the one place holding both a new reading and the one before it, so it now stamps every merged fix with movedAt (when the coordinates last actually advanced past the shared 60 m jitter floor) and echoes (consecutive readings since, each claiming speed, none moving). NF.fixState reads those and returns a third state, frozen — never “Moving” (the truck is not) and never “Stationary” either (we do not know it is parked; we know the feed has stopped being evidence), so it gets its own mark: the explanatory note is hatched, the app’s mark for a state it cannot call, and the speed figure itself is struck through — hatching a two-digit number is illegible at that size, and a strike says the one thing that needs saying about it. The words carry it as well as the colour, in both. The map popup, the dashboard’s fleet list, Live Fleet and the asset page all read that one classifier. The dashboard list also sorts a frozen fix on 0: that list answers “who is on the road”, and a unit stuck at 108 otherwise pins itself to the top of it for as long as the fault lasts. What the screen says is only what was observed — this unit is reporting N km/h and this position has not changed — never a diagnosis, because a yard with bad sky can jitter a stationary asset over the floor too and the observation is just as true there. Both thresholds, not either: the count is in readings (NF.FIX_ECHO_MIN = 3) because minutes do not mean the same thing in the tracked tier, swept every cycle, and the rotating one, whose lap runs the better part of an hour — “hasn’t moved in twenty minutes” is true of every rotating asset that is driving perfectly normally — and NF.FIX_FROZEN_MS = 5 min is the second condition so a cached reading served twice cannot count as two. At the 5 km/h idle floor a truck covers 60 m in 43 seconds, so several readings and five minutes without that is not a slow truck. Disabling the state reddens 9 assertions in tests/run.js; dropping the echo trail from the merge, and either half of the recorder change, each redden backend/tests-fleet-history.js.ts: Date.parse(tag(xml, 'datetime')) || Date.now() stamped the fix with now whenever FD’s <datetime> was absent or in a shape Date.parse would not take — and that single value is the whole input to #159’s guard, so it could never age out and nothing anywhere reported a fault. A guard fed a manufactured input is not a guard; it fails green. Three refusals now, each ending in null rather than a guess: an unreadable datetime is refused outright; an ambiguous one is refused rather than run through Date.parse, which reads 02/09/2026 as the 9th of February and would have shown the fleet seven months stale; and a fix dated in the future is refused, because NF.fixState ages a fix with Math.max(0, now - ts) and a future stamp clamps to age zero and is permanently fresh — the same silent defeat by a different road. Out-of-range components are refused too: Date.UTC rolls the 30th of February into the 2nd of March, every digit looking right. A bare wall clock is parsed component-by-component rather than by Date.parse, which reads an offset-less datetime as local time and so gave a different answer depending on the timezone of whatever process was running — UTC in Lambda, something else on a laptop. The FD zone is a knob, not a guess: FD_FIX_TZ_OFFSET_MIN defaults to 0, the deployed reading unchanged, because assuming AWST when FD actually sends UTC would push every fix eight hours into the past, read the whole fleet as stale and take the map dark — a worse failure than the one being fixed. New noTime / badTime / futureTime counters on the sweep diagnostics, with the offending format’s shape (digits masked, per that file’s names-not-values rule), are the measurement that settles the offset from live data — a whole-fleet futureTime is FD sending local time, and the fix is then the FdFixTzOffsetMin parameter override rather than another guess in the code. Refusing is safe because the frozen-fix detection above reads the position and needs no timestamp at all: the asset page now says “the unit did not send a readable time” where it used to print a bare dash. Restoring the old expression reddens 28 of 39 checks in backend/tests-fd-fixtime.js, which runs itself under four timezones for the reason above.moveMin — a positive claim that the truck was driving — excludes it. It is not hidden: phantomMin carries exactly the time withheld and phantomMax the highest speed claimed while frozen, the two summing back to what moveMin used to be, and both are additive so every existing reader is untouched and a rollup written before this honestly reports zero. maxSpeed deliberately still carries the peak, phantom included: that is the rule recordSpeedEvents already set for the same data, and one derivation quietly disagreeing with its sibling about what a phantom does to a summary is worse than either answer. The sample itself is still written, position and all — it is a real thing the unit really said, and the day it started saying it is how anyone establishes when the unit failed — but at the idle cadence rather than the moving one, since the moving cadence was spending 720 points a day on the one asset least entitled to them. Speed exceedances were never affected: recordSpeedEvents has refused a run whose position did not move since #227, which is why no false speeding alert was ever filed against NF696.toLocaleDateString('en-AU', {month:'short'}), which current ICU writes as “Sept” — four letters — where the app's own month table says Sep. The same date therefore read two ways on two screens, and the string depended on the ICU build of whichever browser or Node drew it, so it was never something to assert on, log or diff. It surfaced when CI went red on 2 September across two documentation-only commits — nothing in the code had changed, the calendar had — and it would have healed itself on 1 October and broken again every September. All twenty-three now use named formatters (NF.fmtDate, fmtDayMon, fmtDowDayMon, fmtDateAt and the rest), each reproducing character-for-character what its site rendered before — comma placement included, captured from the live output and diffed — and each returning an empty string rather than ICU's "Invalid Date" for a value it cannot read. Guarded by a source scan that fails on the pattern anywhere in js/, driver/ or portal/, because the symptom shows one month in twelve and the pattern shows always; the scan strips comments first, and proves it can still see what it is looking for.SW pricing zone, its localities (Bunbury, Busselton, Collie, Margaret River and the rest) and the Perth → South West lane rates are untouched, so nothing about quoting or invoicing moves.js/data.js seeded an illustrative fleet unconditionally — 20 drivers, 20 trucks, 12 customers, 50 con notes, 65 job threads and a four-week roster, all of it invented, and all of it in every build including a deployed one. Two fake drivers on the board beside two real ones is not a fuller board, and because NF.Store autosaves the whole document every five seconds, a deployed build carrying the seed writes it into the shared database as fact. A new demoData flag in config.js now gates it, defaulting to off: absent, misspelled, or written by either deploy path all mean the same thing, which is the fail-safe direction. The removal is one auditable block at the foot of js/data.js rather than forty conditionals at the literals, because the seeds are read by each other at load time (NF.messages reads NF.drivers[0], and NF.reconcileJobs() derives from the jobs) — so it builds the sample data and then drops it, a few wasted milliseconds for a single list you can read to see exactly what counts as synthetic. Configuration is not sample data and stays: the seven real yards, the pricing zones, the published rate card and lane rates, the licence classes, the form and requirement definitions, the asset types and status vocabularies. Empty those and the app is broken rather than clean. Rate agreements are the one mixed collection, so the standard rows survive and the ones naming a customer who no longer exists do not — those would price a real quote off a fake negotiation.renderRunSheet(), the driver portal's initDriver() and the client portal's initAccounts() each read [0].id off an empty register and threw, killing the dispatcher's run sheet and both portals before their first paint. Each now says what is true instead — a run sheet is one driver's day, so with nobody on the register it says so and points at Drivers — and each re-picks when the register arrives after the first paint, which with no seed is the normal case rather than an edge one: the records land when the database does, on nf-state-synced. Both sign-in gates also refused to admit that an empty dropdown submits an empty value: in the client portal +sel.value is NaN, signing the customer in to an account that does not exist and leaving every screen behind the gate reading no shipments rather than no such account; in the driver portal it signed the tablet on to nobody. Both now refuse and say which empty it is, and both repaint when the records land. Verified by booting all three apps with the seed off and walking every route that does not need a record id: 41 of the 44 dispatcher routes (the other three take an enquiry, asset or depot id), 5 driver tabs and 4 portal routes — 0 page or console errors, and the same walk with the seed on to prove nothing moved for a demo. Also verified the deployed shape end to end, which is the case that had never been exercised: boot with no sample data and a real apiBase, serve one genuine driver, customer and con note from GET /state, and watch all three apps pick them up — the run sheet names the real driver, the driver portal signs them on, and the client portal’s account list repaints and admits a sign-in.localStorage document that was written with it in, and NF.Store.load() applies that cache over the now-empty dataset — so the sample fleet would have survived being switched off, in the one place nobody thinks to look. writeCache() now stamps its own key when the build is carrying demo data and loadLocal() drops a stamped cache exactly once, removing it so the next save writes a clean one. Keyed on the stamp and not on refusing every cache: an unstamped document predates the flag or mirrors a real database, and still loads.GET /state has returned. That was invisible while js/data.js seeded a fleet unconditionally, because the sample register was always there to answer; with no sample data the register is empty for the first second, every real session failed the check, and the driver or customer was left at the gate with their session still in localStorage. The check is now provisional until the database has been seen — retried on nf-state-synced, exactly as the client portal already treats a tracking link whose token cannot be verified before the first sync. It resumes the RECORDED person, never a convenient one: the obvious-looking fix is to pick a driver or an account when none is selected, and it is wrong twice over. A name is what a shift clock, a prestart and a work diary are attributed to, so signing a tablet on as whoever sorts first is worse than leaving it at the gate; and in the client portal a null account is deliberate in the by-link state, where a tracking or acceptance link covers one record and its holder must not be handed the run of somebody's account. That second point is not hypothetical — an earlier cut of this change did auto-pick in the portal's router, and tests/manual/portal-selfservice.js and portal-history.js caught it, 9 red between them, every one of them an assertion that a link-holder sees a named state instead of a signed-in portal.GET /state hands them straight back and apply() merges them in. backend/purge-demo-data.js is the other half — it reports by default, and --write does it. What counts as sample data is DERIVED rather than listed: it loads js/data.js twice, once seeded and once clean, and diffs. So the script and the app agree on the meaning of “synthetic” by construction, a collection added to the purge at the foot of js/data.js needs no edit here, and the one mixed collection (rate agreements — standard rows are configuration, customer-specific ones are sample) comes out right with no special case. A third load catches what the seed cannot reproduce: NF.messages stamps at: now−3h, form submissions ts: Date.now(), every job thread a freshly minted history entry, so two seeded loads are diffed against each other and the fields that disagree are excluded from the match. Without that, 73 rows — 65 job threads, 3 messages, 3 inbound requests, 2 form submissions — read as edited and survived every purge, which is how the smoke fixture caught it. 80 assertions (backend/tests-purge-demo-data.js, in CI), verified to fail: removing the volatile-field detection reddens 6, the orphan guard 2, the edited-seed rule 5, the old-API check 4, the blocking backup 2, and widening the derivation so configuration would be purged too reddens 11.replaceCollections: ['consignments', …] is the opt-in, shaped like its two precedents rather than as a third mechanism: opt-in with one spelling like replaceBoard, so no old build, rogue client or hand-rolled curl can get the destructive behaviour by accident or by not having heard of it; and the reconcile itself is assetService's — the array is the complete intended set, so a row whose id is not in it is deleted, found with the same cheap pk-only projected scan. jobs/schedule are excluded because replaceBoard already owns them, an absent collection stays no opinion even here (so it can never be read as “delete everything”), an unrecognised name is ignored rather than failing somebody's save, and the ack reports what actually went per collection — a collection whose scan failed is absent from that report, which is how “left alone” is told apart from “nothing to remove”. Because it goes through PUT /state, every deletion lands in the server-side change log against whoever the token belongs to. +17 assertions in tests/backend.js, the first of which is that a write naming nothing still keeps every row it omits.jsonb stores an object's keys in its own order, so a record comes back re-ordered — same values, different string — and comparing with a plain JSON.stringify reported 131 rows (all 50 seeded con notes, all 16 quotes, all 65 job threads) as seeded-but-edited and withheld every one. The purge would have left almost everything behind and blamed the operator for edits nobody made. Fixed with the same sortDeep/canon rule the audit diff in handler.js already needs, for exactly the same reason. (2) Some seed values are environment-derived, not clock-derived. A job thread's createdAt comes from parsing a fixed date string with no timezone in it, so it means a different instant in every zone: all 65 stored threads were off by exactly 28,800,000 ms — eight hours, AWST — because they were written on Perth time and compared on a UTC runner. A clock skew cannot find that, so the comparison load now differs on two axes, clock and timezone. (3) The seed's id set is not stable across builds. The roster generator mints job ids around whatever day it ran, so of 346 stored jobs only 86 matched this build's seed — and 259 of the rest sat on a seeded driver and a seeded truck. Calling them real was wrong; deleting them for looking generator-shaped would have been guessing. They now get their own reported class identified by connectivity: a record is stranded when every id it names resolves to a seeded record, it names at least one, and nothing surviving names it — a job whose driver, truck and con note are all sample data and that no invoice or POD refers to is not real work, while a real con note booked onto a seeded truck names a real customer and so is not stranded. Opt-in (--include-stranded), because it is one inference more than the match rule makes. On the live database: 290 records removable by the match rule, 556 with the stranded class included; the remainder is genuinely held, because real invoices are filed against seeded con notes and real PODs and course completions against seeded drivers. Also worth recording: one of the new guards was vacuous when first written — two assertions about the detection passed with the timezone axis removed from the code path they were meant to protect, because they built their own comparison load instead of exercising the one under test.backend/template.yaml beside its backup window, because leaving it to AWS broke a deploy. The backup window added moments earlier (17:00-17:30 UTC = 01:00–01:30 AWST) has to clear the cluster’s maintenance window, and that one was unset — so AWS had assigned it at random when the cluster was created, and the first deploy carrying the backup window was rejected outright: “The backup window and maintenance window must not overlap.” The stack rolled back cleanly and the live cluster was never touched. Both windows are now declared: maintenance runs sat:19:00-sat:20:00 UTC = Sunday 03:00–04:00 AWST, 90 minutes clear of the backups. The check that should have caught this existed and did not run. The overlap arithmetic was written with the backup window, but nested inside if (maint) — so the very absence that caused the failure was also what skipped the guard. The presence of a maintenance window is now asserted unconditionally, and the two windows are compared as minutes-of-day rather than by day-of-week, because the backup window runs every day and clearing it only on a Saturday clears nothing. What this is not: Sunday 3am AWST has not been measured as quiet, any more than 1am was — the fleet runs day and night shifts. It is a weekend hour, disjoint from the backups by inspection. And pinning it has a cost worth stating: AWS now applies engine patches, and takes the brief writer failover some of them require, in an hour we chose rather than one it chose for us.replaceCollections before its rows can be deleted, so the script named all of them — but assetService is reconciled wholesale unconditionally, which is exactly why the backend excludes it, so the acknowledgement can never carry its name. Filtering its array was already enough to delete the rows. The fixture is the reason nobody caught it: the test document listed every other collection and not that one, so the single sharded collection with special handling was the single one never exercised. It is in the fixture now, with guards that the purge does not ask for it, does still send the emptied array, and exits 0 against an acknowledgement shaped like the deployed backend’s. Nothing about what was deleted changes — the live purge was correct; only its verdict on itself was wrong.config.js had cautioned against setting demoData:true beside a real apiBase in a comment for weeks, and a comment is not a control: six hours after the sample dataset was purged from the live database, one signed-in session put the whole thing back — 223 change-log entries, 55 job threads, 29 con notes, 15 drivers, 12 trucks and the 23 roster lanes. Autosave writes the whole document every five seconds, so a single seeded tab pointed at production is all it takes, and nothing on screen said it was happening. Both write paths are now gated — the autosave and the keepalive flush a closing tab sends, because gating only the first still lets a tab seed the database on its way out — and the refusal is announced with the reason rather than left as a pill reading “saving…” forever. Sample data against a local backend still writes, because that is the pairing a developer actually uses and a rail that blocked it would simply be removed. “Local” is the host, not a substring: https://api.example.com/?x=localhost is remote. There is deliberately no override flag — one edit away from the accident recurring is not a guard, and seeding a remote database is a maintenance job that backend/refresh-roster.js --demo-data already does with a token and an audit trail. Two things this does NOT do, both found by trying: it does not use new URL() (this file is loaded into a bare Node vm inside the mail Lambda, where that global is not guaranteed — the first cut threw on every call and classified every address as remote), and it does not drop an unstamped localStorage cache. That second rule was written, and reverted: quoterealm.js injects the real document into that same key so the Lambda’s realm can apply it, and dropping it left the mail poller drafting quotes against an empty database.NF1346, NF1376, NF968 — but the board numbered them TR-052 and DL-011, which named nothing a scheduler could look up and collided outright with the requirement ids in docs/requirements, where TR-014, TR-052, TR-070 and TR-103 are all transport requirements as well as, until now, trailers. The trailer pool is on the register's numbering, the ids read off the register are marked as such, and every seeded run line, job, relay leg and hand-over that named one moved with it — asserted over every reference rather than a sample, because a rename that misses one leaves the board allocating a trailer nobody has. A trailer number is now printed the way a prime mover always has been (NF1346, not the stored NF-1346) on the board, the swap picker, the pools rail, the conflict panel, the utilisation table, the con note's rig line and the driver's run sheet, from one spelling of that rule (NF.assetNo) instead of three copies and a gap.NF.Pools to NF.trailers directly, so in front of a live 1,189-asset register they capped the count and the choice at the sample fleet's twenty-eight rows, and because that one list mixes trailers and dollies, a dolly counted as a free trailer and could be picked as one — two of them are what a triple road train needs, so a rig planned that way cannot be built. Both now read NF.Pools.members('trailer'), the same seam the dolly row and the pools rail already used, and the availability row names its source (asset register / sample fleet list) so a count off the sample fleet cannot read as the whole business.TR-052 pool and every run line, job, relay leg, hand-over and re-home pointing at it came straight back over the new numbering. NF.foldTrailerIds() runs on every sync beside the job reconciler, is idempotent (an id already on the new scheme is returned unchanged, so every later sync is a no-op) and matches through NF.assetKey, so TR-052, TR052 and tr 052 all fold. The mapping is not a guess: those ids only ever came from the sample fleet and a scheduler could only ever pick one off it, so TR-052 → NF-1352 says exactly what that row now says. A submitted form is deliberately left alone — an L&D check is a signed record of what a driver captured, and rewriting the trailer number on it would be editing evidence, so it keeps the number it was signed with.34439 where a scheduler expects NF1257. Shipped in v0.147.17 and spotted on the board the same day. The register keys a row by its unit id and carries the number people say in a separate field, so the trailer pool hands out id: vehicleId and name: plate — and allocation stores the id, correctly, because a unit id is the stable identity while the plate column doubles as a status scratchpad on a fifth of the live fleet. The defect was that the picker showed one string and the chip printed the other: NF.assetNo formats a string and cannot resolve one, so the stored id went straight to the screen. NF.assetLabel now resolves an id through the register first — plate || vehicleId, the same order the pools rail and the asset register itself use, deliberately, because a third rule would put a different number on the board than the rail beside it. The write is unchanged: this is a reader, so the pool, the conflict guard and the allocation model all still key on the identity they always did. Applied everywhere a stored id reaches a human — the lane's trailer chip, the rig chips read off the jobs, the allocated-asset chips and their ×, the trailer, dolly and prime-mover double-booked findings, and the rig line on the driver's run sheet, the in-cab screen and the internal con note, where "34439" named nothing a driver could find on the trailer. A row with genuinely no plate still reads as its unit id, and an id the register has never heard of is still formatted rather than blanked — neither is a guess.GOLDFIELDS, because every lane rate, rate agreement and stored quote references it and an id is not a label to be edited for wording. Its localities are unchanged and still reach past Kalgoorlie itself (Leonora, Laverton, Norseman). Renaming the South West zone instead would have been the destructive version of this — two zones called Kalgoorlie, with Bunbury and Margaret River quoting to customers as “Kalgoorlie” at $1,850 while a real Kalgoorlie job quotes at $3,600, on a name that prints on the quote.zones:{from,to} with that id stamped on them, so removing the row would turn every historical record’s region into the raw string SW. Instead it keeps its name with active:false, NF.zoneFor() skips it so no address can resolve into it (a Bunbury enquiry now reports no lane rate rather than quoting one we do not run), and NF.laneRateFor() deliberately does not — it is a pure lookup by id, so a historical quote re-renders at the price it was actually sold at. Rate agreement RA-1003 retires with its lane. The settings screen still shows the zone and its three lanes, dimmed and labelled retired with the reason, because a retired zone sitting unmarked among the others reads as capacity we have; and a retired zone is no longer offered when creating a new rate agreement, while staying selectable where it is the value already stored.MC (WA) exp 3/27. It records capability only — no expiry, no
issuer, no licence number; those stay on the compliance record where the
evidence is held and somebody other than the holder verifies them. Clearing a
set says out loud that the board goes back to inferring from the licence text,
which is not the same as recording that a driver holds nothing.POST /mail/send exists: staff sign-in only (never the shared site
token, which ships inside the public bundle), rate-limited per person, one
recipient, a cc refused rather than quietly dropped, and an enquiry
reference required so it cannot become a general mailer. Pressing Send now sends
— and where it cannot, it still hands the composed reply to your own mail
client exactly as before. Three outcomes, three different records: the mailbox
carried it, your client will carry it, or the send failed and nothing is
recorded as sent.
[E1234]-shaped token in a subject line was trusted, held or not
— so a customer quoting a typo, another supplier's number, or one they had
invented got their enquiry marked "On E9999" and dropped: not
quoted, not sent to anybody, attached to a reference that does not exist. It now
goes to a person, saying exactly that the reference is not one we hold. Checked
against every reference we have ever issued, not just the open ones, so a reply
about a finished job still threads onto it.Q--1068. The reference
prefix is configurable, and the code that mints the quote's record number added a
dash of its own on top of whichever separator had been configured. So a tenant
whose prefix is Q- got two, and the top of the quote screen
read Q--1068 two lines above a sentence correctly calling the same
quote Q-1068 — one number, two spellings, on one record. The
prefix now owns its separator and exactly one is used, whatever it is set to.
2026-08-30. They now read 30 Aug
2026 — day before month, month in words, so nothing can be read as 6 May
when it means 5 June. There are three answers, not two: a date we hold, a date
we were given and cannot read (shown as the customer wrote it, hatched, saying
it could not be read), and no date at all. A validity we cannot read also no
longer decides anything — a quote used to be able to refuse itself as expired
off a date nobody could parse.2026-08-24
mid-sentence. They were also capped at about 800 pixels, so on a wide screen a
notice a dispatcher is meant to take seriously sat in a narrow column beside a
band of empty space and read as a footnote.
New — Quotes nobody has priced yet. Not every enquiry received — an enquiry appears here once a quote is raised from it.Not one of the stated limits was dropped. That was the half worth keeping and the half at risk: it would have been easy to make these read well by quietly deleting what the counts do not include. Each still says what it counts, what it leaves out, and what has to happen before something appears — and the automation banner still says, in as many words, that quotes are being approved with nobody reading them. The tests were rewritten to match: several were pinning the issue numbers into the copy, so they are now written against what the sentence has to tell the reader instead of against a literal
Emails — The fleetview@ mailbox is read automatically. New messages appear under Inbound enquiries, and join this list once somebody raises a quote from one.
#322.terms.html now carries the real Northfleet rising-sun mark and wordmark and the app’s Raleway / blue-and-orange palette instead of the sidebar logo (whose white wordmark was invisible on the white page). The signature block (entity / signatory / signature / date) is removed — acceptance of a Northfleet estimate is by Purchase Order or written confirmation, never a signed T&C, the same stance the acceptance link takes (#244).js/data.js and
js/integrations.js — into the Lambda that does the answering,
copying them at build time from the single source rather than keeping a second
copy in git that could drift and quote a customer one price while the screen
showed another.
../../js would be the app's js/. It does
not — the build makes a scratch copy of the folder somewhere else entirely
and runs it there, so ../../js pointed at nothing. The step's own
guard caught it and stopped, exactly as designed, and backend deploy #99
failed in the build stage. Nothing reached AWS: the live API was never
touched, which is the only reason this was an inconvenience rather than an
outage. The folder is now found from a path the build actually provides, and the
deploy names it outright as well, so it does not rest on working out where the
app is.run: true instead of a command
made it crash rather than report — a wall of Python where a sentence
belongs. It already had a guard for an unreadable configuration file, added the
last time this happened, with a note saying "the check crashed" and "the check
found something" must not look the same; this was the same fault one step along.
It reports the finding in words now, whatever shape the step is in.CN1062-01, -02,
-03 — each part used to raise its own invoice, so one purchase
order came out as three. The whole family is now invoiced once, against
the head con note, covering every part at the one agreed price. The single
invoice is drafted once every load has landed, and anything a driver recorded on
an individual load (a demurrage wait, say) is carried onto it against the load it
happened on, so nothing is dropped. A relay was already one con note and so
already one invoice; nothing there changes. (A purchase order shared across
several unrelated con notes still produces one invoice each — that
is a separate case and still says so.)NF.dayKeyForDate
converts what a customer wrote into the day space the schedule is keyed by.
Australian day/month order, because reading 05/06 as 6 May instead of 5
June is a month of error on a delivery deadline; and a date that does not exist
(31 February) is refused rather than rolled forward into March as a
real-looking day. NF.dueWindowFor reads the two dates on a booking as
one scheduling window — the earliest it can be collected and the latest it
can arrive — because collapsing them to a single date throws away half of
what the customer said.Scheduling this for Sat 22 Aug does not fit: that day has already gone (Sat 22 Aug), the customer said it is not collectable until Mon 24 Aug. Schedule it anyway?It asks rather than refuses. Dispatchers schedule outside a stated window for real reasons — collecting early to stage a load, running late with the customer's agreement, back-filling a day that has passed to record what actually happened — and a hard block would have people working around the board instead of on it, which is how a schedule stops being the truth. Nothing is written until the question is answered.
Please quote 2 x pallets, collection from our Kewdale depot, delivery to Bunbury. Weight 1200 kg. Dimensions 1200 x 1200 x 1500 mm.The whole line went, so "2 x pallets" went with the measurements, and the enquiry came back as an information request asking what was being moved — which the customer had said in their first six words. The measurements are now removed from the line and what remains is read, so both come through and neither stands in for the other.
overflow setting was capturing it and stopping it from pinning, so
it scrolled away. It now sticks just below the top bar, keeping every section a
click away however far down the page you are.fleetview@ is now answered without anybody opening a browser. The scheduled mailbox poller used to land each message as a row and stop — the drafting brain sat in the browser and a person had to press a button. It now completes the loop server-side: read, resolve who it is from, run the matching cascade, take both readings (the pattern reader and Claude), price it, mint the quote thread, and in autosend email the customer — a priced quote carrying the acceptance link, or an information request naming exactly what is missing.js/data.js and js/integrations.js are loaded into a Node realm in the Lambda (measured: ~65ms, ~70MB) and fed the real state through the same apply() path a browser uses, so NF.Intake, NF.Rates.priceQuote, NF.Quotes and NF.QuoteAccept.messageFor are literally the functions that answer. Two implementations of a price would disagree and the one that disagreed would be the one the customer saw; there is one. The outbound quote body is NF.QuoteAccept.messageFor’s, asserted character for character.PUT /state writes those rows from the browser’s own list, so a tab that loaded the state before the reply and autosaved after it restores the row exactly as it was. The reply is therefore keyed on the minted thread, which a stale browser cannot restate, and the quote is read as a second witness so a quote drafted by hand is not answered again ten minutes later. One run at a time, an insert-if-absent lock, and the mint is persisted before the send so a crash mid-send leaves an honest record rather than a re-quote.accepted — it took the message, which is not delivery — and only that word counts: a transport answering anything else, or throwing, is recorded as not sent, with the reason, and reads as auto-sent-blocked rather than being folded into auto-sent. With no site address configured a priced quote is drafted and not emailed, because a quote whose acceptance link points nowhere is worse than one that waited for a person.off genuinely stops it. off, unset, or a value this build does not recognise, and not one Graph call is made, no reading is paid for and nothing is drafted. draft and automatic draft and leave the send to a person — the transport is never even asked. Only autosend sends, and the mode is re-read at the moment of sending so switching to off stops a run in flight. A message that is not a quote enquiry is classified out before the AI reading is paid for, so an out-of-office costs nothing however often it is polled.Reply-To, not a To: line in the quoted history, not a “please send this to…” sentence in the body, and nothing a browser supplies — the message body is untrusted text and this is the only path in the system that sends mail as our own domain.L4 is a row on the register, not
a name. Nothing changes on a session with no sign-on: the box is still there and
still carries the name over.NF.QuoteAccept.messageFor took the acceptance link as a
caller-supplied argument, so composing the email without one printed
"To accept, open this link and enter your purchase order number:" followed
by a blank line — and the Terms and Conditions of Cartage URL, derived by
splitting that same argument, vanished with it while the sentence claiming the
terms apply stayed. Measured on master before this: both URLs empty, both
sentences printed. The link is now minted from the site's own published address
when no caller hands one in (one resolver, NF.QuoteAccept.siteBase(),
which js/app.js's BASE_URL now reads rather than
computing a second copy), the T&C link comes off that address independently of
whether a token could be minted, and where there is genuinely no acceptance link
the email says how to accept instead rather than pointing at nothing.NF.Drafts.produce composed an outbound message only for an
information request; on the priced-quote branch it composed nothing, and
the send path read that same empty value — so autosend mailed a
priced customer an empty email and recorded it as sent. The quote branch now
sends the one quote email NF.QuoteAccept.messageFor builds, so the
automated quote and the one an operator pastes out cannot say different things.
An information request still carries no acceptance link — there is no
price in it to accept — and that is asserted on the draft (like the
never-produce-N/A rule) rather than assumed, with the send refused outright if a
request ever carries one.NF.QuoteAccept.readReply() reads whether a reply accepts
and which PO it names, and acceptFromReply() records it on the
same thread through the same writer as the link (both now funnel through
one land() into NF.Jobs.accept()), with
acceptedVia:'reply' distinguishing it from a link acceptance and from
one the office keyed in. What it refuses to do is the substance of it: the PO is
read only out of what the sender wrote and never out of quoted history (our own
quote email asks for a PO, and an older job's PO is routinely pasted in below); a
reply naming two possible POs records neither and names both; a reply that
plainly accepts and names no PO is an acceptance with none recorded — never
invented, and never a reason to refuse the win; and a reply the matching cascade
could not thread cleanly books nothing at all, in the cascade's own words,
because a purchase order on the wrong customer's job is worse than one on
nobody's. An acceptance reply is also no longer minted as a fresh enquiry, which
put the acceptance on a phantom quote and left the real one open in the pipeline.NF.QuoteAccept.messageFor) was a bare link and a total; it now lays out the same sections as the manual estimate — description of freight (goods and each item’s weight), the price broken to subtotal / GST / total inc GST, the current fuel levy read from the one NF.Rates.levyAt() reader, additional-charges policy, demurrage (the customer’s negotiated terms where they have them, the published per-combination rate card otherwise), the estimation terms and the validity. It keeps the honesty rules the rest of the app holds to: no N/A is ever printed, a structured field we do not hold is omitted rather than guessed, and no total is shown where there are no charge lines.terms.html at the site root renders the complete T&C, with the signed PDF beside it in assets/. The quote email links to it (a mailto: quote cannot carry an attachment), with the T&C page’s URL derived from the acceptance link’s own site root so there is no second base-URL resolver.NF.demurrageCard holds the per-combination list rates (ex GST) the estimation email quotes against, so the outbound quote and the estimation terms cannot drift apart. A customer’s negotiated demurrage always overrides it — the card is the published reference, not a customer’s agreed rate.3 x pallets" shape the goods reader looks for, so
it was read as the goods, and the Description of freight section of the
quote that went to the customer said:
DESCRIPTION OF FREIGHTThe measurements of the thing in place of the thing, in the one document a customer actually reads. A dimensions line is now excluded from the goods by the same test the dimensions field itself uses, so the two cannot disagree about which line is which. A real item list still reads every line of it, and a message that states only dimensions now names no goods and asks for them, rather than offering its own measurements as an answer.
Dimensions 2400 x 1200 x 1500 mm, weight 1800 kg.
Deliver
to: line, and the goods had to be written as "3 x pallets".
A place named after a collection or delivery verb is now read wherever it
sits in the sentence, up to five words long, because "Running With Thieves Port
Hedland" is five words and the suburb is the last two.
ingestSpeed, the #272 pipeline, measured
against each asset's posted/configured limit), depot arrivals off the live feed
entering a real geofence, PODs, and office rejections. Incidents start empty and
appear only when a driver files one from the field.#/clientcontacts holds who to ring at each client: name, role,
phone, mobile, email, which of those to actually use, and a free-text note.
Contacts nobody has ever confirmed are drawn hatched and say so, which is
not the same claim as "confirmed a long time ago" and is never drawn as current.MAJOR.MINOR.X, where X counts the merges since the
release, so the release deploys as x.y.0 and each later merge
increments. tools/release.js is still the sole writer of the
committed version and the changelog; this is a build number layered on top, and
because APP_VERSION now moves on every deploy, driver tablets pick up
a change on its own deploy rather than waiting for the next release.sam deploy whenever the quoting
mailbox was left blank. The Deploy backend workflow passed the
mailbox as a GraphMailMailbox= override, and an empty value renders
an argument the SAM CLI rejects as an invalid --parameter-overrides
format — so the deploy aborted before it ran, having verified the whole
suite and built the artefact. It broke every backend deploy from the point the
override was introduced. Removed the mailbox from the deploy entirely: the
template already defaults GraphMailMailbox to empty and
CloudFormation keeps its previous value on a stack update, so the email-to-quote
integration stays off without a deploy-time toggle that could crash the deploy.
Set the mailbox out of band when it is time to switch the integration on.tests/manual/node_modules symlink slipped into the repo
(.gitignore's node_modules/ matched only a directory,
not a symlink of that name), and on a fresh checkout aws s3 sync
stat'd the dangling link while walking the tree and exited 2 — which
uploaded every file to S3 but then skipped the CloudFront invalidation and the
live-site verification, so the edge kept serving the old build. Fixed three
ways: the symlink is removed, .gitignore now ignores a
node_modules of any kind (dir, file or symlink, at any depth), and
the sync runs --no-follow-symlinks so a static-file deploy can never
again be failed by a symlink it had no reason to follow./fleet/history read that names the assets, which the PostgreSQL adapter
served with a = ANY(:x) query bound as an RDS Data API
arrayValue parameter. The Data API rejects an arrayValue
parameter whenever formatRecordsAs: JSON is set (which every read
uses) with exactly that message, so the named read failed and leaked the raw backend
error to the driver — while Show whole fleet (a scalar range read) kept
working. The test double accepted arrayValue under JSON formatting, so
every suite stayed green. Fixed by binding the list as one scalar Postgres array
literal cast to text[] in the SQL (= ANY(:x::text[])),
so nothing array-shaped goes on the wire and JSON formatting is unaffected. The same
fix covers every other named batch read on Postgres — the asset trail, the speed
screens, and the posted-limit (zone) cache. The fakes now reject
arrayValue+JSON exactly as the live cluster does.NF1186) above the speed line, so telling one prime mover from
another meant opening the asset. The vehicle description — the same one the
asset page shows in its Identity panel (Freightliner CASCADIA
(145T)) — now sits directly under the number. An asset with no
description recorded gets no line rather than a blank, so the list stays clean.34401)
instead of the trailer's asset code (NF0002) — the code the
driver reads off the trailer and the yard knows it by. It now records the plate
(the register's asset code), falling back to the internal id only for a row that
has no plate at all. Live-GPS ordering of the picker is unchanged.L4, his row on the people register) rather than a name, and being
editable it could be changed to somebody else’s and recorded that way. It
now follows the same rule as the register card and the levy writers (#66): a
verified session shows the verified identity, read only, with
NF.StaffIdentity’s own sentence beside it; an unverified one
still types a name and says on screen that a typed name is not an identity. The
mode change is filed against the verified identity whatever the box holds, and a
read-only box no longer writes back into the name the registry, device and levy
writers share. Setting this mode governs whether FleetView drafts, approves and
— in Automatic, and sent — sends to a customer, so it is at
least as consequential as a levy change. Verified: reverting it reddens 9
of 106 checks in tests/manual/quote-automation.js.backend/src/graphmail.js ships send(), no route exposes
it yet, and which mode wants to send and whether it can are the model’s two
separate answers (sends / sendAvailable /
sendBlocked) that the screen prints. A comment asserting a send is
impossible, above code that can send, is how somebody later reasons wrongly about
whether a customer can be emailed. Verified: putting the stale count back reddens
2 assertions in tests/run.js.N/A anywhere, and no price — the
charges that could not be worked out are listed in the pricing engine's own
words, so the request and the quote screen can never disagree about what is
missing. Where the message contradicts itself — a weight stated twice a
thousandfold apart, dimensions headed in millimetres whose figures are metres
— the contradiction is named and neither reading is picked, because those
two answers are a ute and a road train.The cause was a shared form rule: text boxes and dropdowns inside a form field are given the full width of their row, which is right for the ~118 of them in the app and wrong for a tick box, and nothing had ever put a tick box in one before. Fixed as the class of fault rather than this one instance — a radio or checkbox in a form field is now sized as a control, so the next one works too. Measured after the fix: the radio is 13 pixels wide, the text has 715, and the blurb is two lines rather than a 381-pixel column.
Worth recording why it shipped: the screen had 85 automated checks and all of them passed. They asserted that every mode was present, correctly labelled and correctly selected — all true, and all of it illegible. The suite now measures the layout as well as reading the markup, because a control can be entirely correct and still impossible to use.
#/schedule printed the licence classes as class tags, again as the
driver's licence line under the name, and a third time as the qualification chips
— so HR or MC / HR read out on every row twice more
than it needed to. The board now shows the licence once, in the qualification chips
drawn from the skills register (NF.Pools.qualsOf), which is the one
source the allocation guard reads. The HAZ lane flag stays — it is a
dangerous-goods marker on the lane, not a licence class.css/core.css, plus the callout
panel in rvp.css), so every screen is lifted by one pass rather than
each being hand-tuned.CN1062-01 → LOAD-88231, -02 → LOAD-88232…), shown on
each part's row, and offered only for customers who use a reference. It is stored
on that part alone: setting one leaves its siblings and the parent untouched, and
a part with no load ID of its own still falls back to the consignment's reference.
Delivers FR-AC-11 (#335). Mapping a customer's multi-load
notification onto the parts automatically is the email-intake side (#323); this
is the manual entry that path will later fill in.FR-SP-07 (#335).Actioned folder in
the mailbox. Simon's request, and a better design than the one it replaces: a
cursor kept on our side is invisible — nobody can look at the mailbox
and see what has been handled. “Not in Actioned” is self-documenting,
idempotent, and readable by the sales team without opening FleetView at all.
The move is earned, never assumed. A message moves only when something was actually sent about it — never on a parse we chose not to act on, never on a message classified as not-a-quote, and never on a draft still awaiting approval. Getting that backwards means a real enquiry vanishing out of the Inbox into a folder nobody watches, which is strictly worse than a message being read twice. A move also has to state why: a move with no recorded cause is the kind of record that cannot be audited afterwards.
The folder is found or created, never assumed to exist, matched case-insensitively because Exchange does not promise the casing it stores, and its id is cached — not a lookup per handled email. And a move that cannot resolve the folder fails rather than falling back: there is no sensible second destination, and guessing one would put customer mail somewhere nobody asked for.
This needs the Mail.ReadWrite permission, which is broader than
the read it replaces. Two things bound it: the tenant's application access policy,
and this module — which can move a message and nothing else. Deleting,
modifying and setting a read flag are not one call away, they are absent.
fleetview@ mailbox, and send from it.
The backend half of email-to-quote: a GET /mail/fleetview route over
Microsoft Graph, plus the one write — sending as that mailbox — behind
its own function.
It can only ever touch the one configured mailbox, and that is enforced in
our code as well as in the tenant. Mail.Send means send as any
user until an Exchange application access policy narrows it, which is a tenant
control this repo cannot assert, test or own. So the Graph paths are built
around one address and no function accepts a path from a caller: another mailbox
is an unreachable code path rather than a refused request. A policy can be dropped
by a tenant migration or never applied at all; this holds either way.
A second app registration, deliberately. The directory credential added
for the People import holds User.Read.All; this one holds
Mail.Read and Mail.Send, on its own SSM prefix. One
credential holding both would be able to read every person in the business
and send mail as any of them — a far larger prize than two narrow
ones, for no gain beyond one less secret to rotate.
The route parses nothing. Headers and body cross the wire; every interpretation, above all which generation of a reply chain holds the request, happens client-side against a single shared splitter. Two splitters would eventually disagree about that, and the answer decides which job gets quoted.
And it never reports an empty mailbox it did not read. “We looked and there is nothing”, “we could not look” and “we have never looked” stay three different answers: only a genuinely unconfigured deployment gets the status code that makes the client stop asking, and a read that failed is retried rather than shown as zero enquiries.
northfleet.com.au.
Two things it now insists on. The application access policy is proved, not
assumed: an application Mail.Read grant reads every mailbox
in the tenant by default, and the integration works perfectly without the
policy that stops it — so the runbook requires
Test-ApplicationAccessPolicy to be run both ways, because a
Granted on the FleetView mailbox proves only that the app can read what it
should, while the Denied on an ordinary mailbox is the one that proves the
blast radius is two mailboxes rather than all of them. And the credential goes
straight into AWS Secrets Manager by the administrator, never into a chat, a
commit or config.js — the three apps are static files on a
public distribution, so anything the browser can read, a customer can read.
Also recorded: Mail.Send is deliberately not requested,
since a quote still leaves through the operator's own mail client; the choice
between a client secret and a certificate is about expiry rather than
secrecy; and FleetView will not mark mail read or move it, keeping a cursor
server-side so the mailbox stays usable by people.
It also now says where to run the one step that has no portal at all.
New-ApplicationAccessPolicy exists only in Exchange Online
PowerShell — and Windows PowerShell 5.1 fails outright, with an
error that looks nothing like an auth problem: “A window handle must be
configured”. The .NET Framework build of the v3 module signs in through
the WAM broker, which needs a parent window handle 5.1 cannot supply, so it
throws before a login prompt ever appears. PowerShell 7 is required rather than
preferred, and because it keeps its own module path the module has to be
installed again there.
Two corrections from running it for real. The policy now points straight at
the mailbox: -PolicyScopeGroupId takes a single mailbox as
happily as a group, and there is only one to scope until the test mailbox exists,
so the group was over-engineering — and its command did not even run
(New-DistributionGroup: A parameter cannot be found that matches parameter
name 'Type' in module 3.10.1, though it is documented on the cmdlet). And
a passing test is not yet proof Graph is refusing:
Test-ApplicationAccessPolicy reads the policy and reflects a
change almost at once, while enforcement on Graph calls can lag by up to an hour
— so a correct pair says the policy is right, not that the door is shut.
A warning that two fields are easy to confuse turned out not to be enough
— it was given, and the wrong one was stored anyway. Entra shows a
client secret's Value beside its Secret ID, the
Value displays exactly once, and picking the wrong one produces an
auth failure that reads as a permissions problem. So the runbook now carries a
shape check to run after storing, which reveals nothing secret — a
length, a GUID pattern match, and whether a tilde is present — with a table
saying what each result means. It caught the mistake on the first attempt.
The same section records that the region is not optional (a secret in the wrong
one is invisible to the Lambda while looking correct in the console), that a
stray space or newline from the paste fails auth while looking harmless, and that
CloudShell keeps $HOME for 120 days, so a secret typed on a command
line outlives the terminal.
Corrected: the credential belongs in SSM Parameter Store, not Secrets
Manager — this document said the wrong one until it was followed for
real. backend/src/credentials.js is the repo's one credential
reader and it reads SSM SecureString, the way Fleet Dynamics, Odoo, GPSWOX and
Anthropic already do; Secrets Manager holds only the Aurora master password,
which CloudFormation manages itself. That is not cosmetic:
check-ssm-grants.sh is a CI gate asserting every prefix in
credentials.js has a matching IAM read grant, so an SSM credential
is covered automatically while a Secrets Manager one would fail at runtime
instead of failing the build. The tenant and client IDs go in alongside the
secret, so nothing is hardcoded and rotating the app registration needs no code
change; the shape check now reads out of the store rather than out of the
clipboard, because the question is what the Lambda will find. Also recorded: the
“Member must have length greater than or equal to 1” rejection
means the file behind $(cat …) was missing, not that the secret was
bad.
LastEvaluatedKey with no keyset id);
the adapter used to silently drop the seek and restart the audit read from page
1, which read as forward progress — duplicated rows and pagination that
never advanced. It now rejects a foreign/legacy cursor with a clear error so the
caller re-fetches page 1 deliberately.GET /state read and the batch-get singletons bucket
now fan out concurrently. The whole-business read issued its
app_singleton, per-shard and per-board queries one after another
(~14 sequential RDS Data API round-trips on the hottest read path), and
getItems() awaited its singleton reads one at a time; both now use
Promise.all, with row contents and order unchanged. A redundant
dead col# branch in the pk router was also removed.migrate-to-postgres.js), the
telemetry and media backfills (backfill-telemetry.js,
backfill-media.js), the legacy-telemetry purge
(purge-legacy-telemetry.js) and their test suites — were deleted,
since they can no longer run against a store that no longer exists. The two
tests/backend.js groups that exercised the deleted backfills' pure
transforms were removed with them, and the runbook and CI comments that named the
scripts as runnable were adjusted to describe them as history.GET /state has returned, that is only the built-in sample
dataset, which holds neither the real quote nor its token. So a perfectly valid
token could not be recognised, and nothing ever looked again. A failed check is
now provisional until the database has actually been read: the link
re-checks itself once, on the first successful sync, and opens on its own with no
sign-in and no click.
Worth recording why nobody saw it internally: anyone here opening the same link has a cached copy of the database from signing in, so the record is present at page load and the link works. The customer it was emailed to has no cache at all. It therefore failed for every one of the people the feature exists for, and for none of the people testing it.
An invalid token still ends at the ordinary sign-in gate, unannounced — what changed is only that “not loaded yet” no longer looks identical to “not valid”.
PUT could permanently
drop a coaching record — who was spoken to about a speeding alert and what
was said, which is not re-derivable from telemetry. apply() now
merges coaching by record id (newest-wins on collision), the same rule
credentials and geofences already follow.GET /entra/people refuses it outright. That is stricter than the
change log, which stays readable by an unidentified request so it does not become unreachable where sign-on is off:
here there is no tenant to read without sign-on anyway.backend/set-graph-creds.sh; rotating it later needs no
redeploy. Where it is absent the import reports that there is no directory to read, and everything else carries on
exactly as before.NF.EWD seam to a certified provider is
left in place, only the driver-side surface is retired. The dispatcher scheduler's
own "fatigue" rest-day cell is unrelated and unchanged.> quoting or Outlook divider blocks, sometimes both in one
message. Also covered: a stray reference from an unrelated older enquiry sitting
in the quoted history, the subject-line family that differs only by a trailing
space, a leading tab and the case of one letter, the internal authoring notes
that leaked from 49 real quotes, an enquiry that cannot be priced as it stands,
a message that contradicts itself on units by a factor of a thousand, and an
invoice query carrying quote vocabulary that must still be classified out.measured and estimate
apart because an estimate later read as a measurement is how the expensive
mistakes here have happened, and a harness is the worst place to blur them.
Fixtures are files rather than inline data, so a redacted real message is
dropped in with no change to the loader..gitattributes marks the
fixtures so git cannot normalise them on checkout either. Building it found two
real faults in the loader: caret and divider quoting shared one depth counter,
which silently folded an unrelated enquiry's reference into a new request, and
a divider's own header lines were being read as body prose.E reference — E1062 — that goes out on the quote,
appears on the quote screen an operator reads it out from, and comes back up when a customer quotes it at us.
It is a fourth prefix on the ONE number, not a second sequence: E1062 · Q1062 · B1062 · CN1062
are one record read four ways, so "punch in 1062 and see the whole thread" now reaches one stage further
back. That is deliberate rather than convenient — #308 is open because two sequences in one digit space
collide, so this adds no counter at all: allocating a reference advances neither
NF.Jobs.nextSeq() nor the con note counter, measured in both directions.
The reference is stored on the thread rather than derived, which is what makes it never reissued:
it does not move when the record becomes a booking and then a con note, and changing the configured prefix
does not renumber anything already on paperwork. It goes out in three of the four places the issue names — a
Our reference: line under the greeting, a footer line in the signature block, and a
[E1062] subject token that appends and alters nothing else: the corpus holds two subjects
with trailing spaces, one starting with a tab, and two live BHP Jimblebar jobs distinguished only by the case
of one letter, so trimming or case-folding any of it would merge two different jobs. The fourth place is an
X-Northfleet-Enquiry header and there is no path for one — the quote leaves by
mailto: in the operator's own mail client — so the header name is held once for the intake and
threading work to read, and nothing claims to send it.
Stated consequence: the reference is as wide as the sequence, so it is five digits while
seqFrom is 10000 rather than the six of the issue's E###### example. Lifting that
floor is #308's decision and a data one; padding here would print E010042 beside
Q010042's Q10042 — two spellings of one number.
What this is not: the enquiry record itself. An enquiry still cannot exist without a quote and one
enquiry cannot yet carry many quotes — that is increment 2b. No new collection, no schema change and
no backend deploy: the reference is a field on the already-sharded tmsJob# record and
tms_jobs.doc is jsonb, so it rides the existing write in both stores.
Settings →
Default fuel levy % was editable, saved, and read by nothing in the
pricing path — so an operator correcting it changed no quote and no
invoice. Two further copies sat beside it, both with zero readers: a
fuelLevyPct on all four depot rate cards, and a
backloadLevyPct carrying 9.95 on Hazelmere against 24.47
everywhere else. Five places were free to disagree about one number. There is
now one table, NF.levyRates, read through one function,
NF.Rates.levyAt(), and the three dead sites are gone.q.status mirror, so Draft and Quoted are two positions rather than
one. They are the same stored state — quoted has
always meant “priced, not necessarily sent” — and the filter
row was keyed on the hand-kept mirror, so choosing Quoted selected both,
which is exactly the distinction the send stamps were added to draw. Every
position, count and sentence now comes from one reader,
NF.Jobs.quoteBoard(), which calls the same
NF.Jobs.lifecycle() the badge does. New, Awaiting info,
Lost and Expired can be filtered to for the first time — the
last two were real thread states that had silently vanished from the row —
and anything else genuinely on file is appended rather than enumerated, so no row
can be unreachable. Measured on the seeded dataset: three threads had moved on to
allocated while their quotes still read booked in the mirror, so
Booked selected nothing and nothing else selected them either.0 there would say no customer has ever asked for a change, which is
not something this screen knows. It is shown with no number and selecting it
explains what it is waiting on, rather than showing the same “nothing
here” a real empty shows. The four empties are four different sentences
— nothing raised yet, nothing at this position, nothing matching the
search, not tracked — because a filter matching nothing and a stage nothing
can reach are different facts.FR-SP-09).
The surviving part keeps its run; the folded part is retired saying where its
load went ("Merged into 01") rather than reading as a plain
cancellation, and its number is never issued again. A part a driver has already
captured work on is not merged — it is handed over from the schedule board
instead, so no stamp is ever moved onto a driver who did not earn it (#280). A
merge across two different con notes is refused. Growing the plan is still
add-a-part and shrinking it is merge; moving the freight quantity between
parts waits on itemised consignments (#284).CN1062-01, -02, -03 — so each load
can go to its own driver (#281). Those parts are how we allocate the work;
to the customer it is still one booking, to one place, under one purchase order.
The client portal now reflects that: the family head is the single
shipment on the tracking list and in the document cabinet, its status is
rolled up from the parts (Delivered only once every load has landed), and
the card reads “6 of 8 loads delivered” in place of a single ETA.
Punching in a part number reaches the whole family. A split part is never shown
to the customer on its own — not as a separate card, not as a separate
delivery record, and its POD is not served on its own URL. Delivers
FR-SP-05 and the visibility half of FR-SP-08 (#335).FR-PD-07
(#335). Invoice consolidation — one invoice per purchase order across a
split — is deliberately left to #285; each part is still invoiced
separately for now, and relabelling them as one consignment without
consolidating would misread as being billed several times.> quoting or Outlook divider blocks, sometimes both in one
message. Also covered: a stray reference from an unrelated older enquiry sitting
in the quoted history, the subject-line family that differs only by a trailing
space, a leading tab and the case of one letter, the internal authoring notes
that leaked from 49 real quotes, an enquiry that cannot be priced as it stands,
a message that contradicts itself on units by a factor of a thousand, and an
invoice query carrying quote vocabulary that must still be classified out.measured and estimate
apart because an estimate later read as a measurement is how the expensive
mistakes here have happened, and a harness is the worst place to blur them.
Fixtures are files rather than inline data, so a redacted real message is
dropped in with no change to the loader..gitattributes marks the
fixtures so git cannot normalise them on checkout either. Building it found two
real faults in the loader: caret and divider quoting shared one depth counter,
which silently folded an unrelated enquiry's reference into a new request, and
a divider's own header lines were being read as body prose.E reference — E1062 — that goes out on the quote,
appears on the quote screen an operator reads it out from, and comes back up when a customer quotes it at us.
It is a fourth prefix on the ONE number, not a second sequence: E1062 · Q1062 · B1062 · CN1062
are one record read four ways, so "punch in 1062 and see the whole thread" now reaches one stage further
back. That is deliberate rather than convenient — #308 is open because two sequences in one digit space
collide, so this adds no counter at all: allocating a reference advances neither
NF.Jobs.nextSeq() nor the con note counter, measured in both directions.
The reference is stored on the thread rather than derived, which is what makes it never reissued:
it does not move when the record becomes a booking and then a con note, and changing the configured prefix
does not renumber anything already on paperwork. It goes out in three of the four places the issue names — a
Our reference: line under the greeting, a footer line in the signature block, and a
[E1062] subject token that appends and alters nothing else: the corpus holds two subjects
with trailing spaces, one starting with a tab, and two live BHP Jimblebar jobs distinguished only by the case
of one letter, so trimming or case-folding any of it would merge two different jobs. The fourth place is an
X-Northfleet-Enquiry header and there is no path for one — the quote leaves by
mailto: in the operator's own mail client — so the header name is held once for the intake and
threading work to read, and nothing claims to send it.
Stated consequence: the reference is as wide as the sequence, so it is five digits while
seqFrom is 10000 rather than the six of the issue's E###### example. Lifting that
floor is #308's decision and a data one; padding here would print E010042 beside
Q010042's Q10042 — two spellings of one number.
What this is not: the enquiry record itself. An enquiry still cannot exist without a quote and one
enquiry cannot yet carry many quotes — that is increment 2b. No new collection, no schema change and
no backend deploy: the reference is a field on the already-sharded tmsJob# record and
tms_jobs.doc is jsonb, so it rides the existing write in both stores.
Settings →
Default fuel levy % was editable, saved, and read by nothing in the
pricing path — so an operator correcting it changed no quote and no
invoice. Two further copies sat beside it, both with zero readers: a
fuelLevyPct on all four depot rate cards, and a
backloadLevyPct carrying 9.95 on Hazelmere against 24.47
everywhere else. Five places were free to disagree about one number. There is
now one table, NF.levyRates, read through one function,
NF.Rates.levyAt(), and the three dead sites are gone.q.status mirror, so Draft and Quoted are two positions rather than
one. They are the same stored state — quoted has
always meant “priced, not necessarily sent” — and the filter
row was keyed on the hand-kept mirror, so choosing Quoted selected both,
which is exactly the distinction the send stamps were added to draw. Every
position, count and sentence now comes from one reader,
NF.Jobs.quoteBoard(), which calls the same
NF.Jobs.lifecycle() the badge does. New, Awaiting info,
Lost and Expired can be filtered to for the first time — the
last two were real thread states that had silently vanished from the row —
and anything else genuinely on file is appended rather than enumerated, so no row
can be unreachable. Measured on the seeded dataset: three threads had moved on to
allocated while their quotes still read booked in the mirror, so
Booked selected nothing and nothing else selected them either.0 there would say no customer has ever asked for a change, which is
not something this screen knows. It is shown with no number and selecting it
explains what it is waiting on, rather than showing the same “nothing
here” a real empty shows. The four empties are four different sentences
— nothing raised yet, nothing at this position, nothing matching the
search, not tracked — because a filter matching nothing and a stage nothing
can reach are different facts.CN1062-01, -02, -03 — so each load
can go to its own driver (#281). Those parts are how we allocate the work;
to the customer it is still one booking, to one place, under one purchase order.
The client portal now reflects that: the family head is the single
shipment on the tracking list and in the document cabinet, its status is
rolled up from the parts (Delivered only once every load has landed), and
the card reads “6 of 8 loads delivered” in place of a single ETA.
Punching in a part number reaches the whole family. A split part is never shown
to the customer on its own — not as a separate card, not as a separate
delivery record, and its POD is not served on its own URL. Delivers
FR-SP-05 and the visibility half of FR-SP-08 (#335).FR-PD-07
(#335). Invoice consolidation — one invoice per purchase order across a
split — is deliberately left to #285; each part is still invoiced
separately for now, and relabelling them as one consignment without
consolidating would misread as being billed several times.people table rather than
separate drivers and learners tables (#378).
Design only — no code, no migration, no behaviour change; it settles the
shape the decompose slice will build. A driver and a non-driver are the same
kind of thing, and v0.139.0 already made them one in memory, so the schema now
follows that model instead of contradicting it. The deciding argument is
referential integrity: credentials.person,
course_subs.driver, quiz_attempts.driver and
form_subs.driver all cite a person id that may be either kind, and
against two tables not one of them can carry a foreign key. Taken now, while
people are still one blob row per collection and no per-record rows exist, it
costs a paragraph; taken after the decompose slice it would have been a
live-data table migration.StateStore deploy
parameter were removed. Every Lambda pins the Postgres backend as a fixed value
rather than a sticky deploy parameter, so a deploy that accepts the defaults can
no longer revert the storage layer. There is no longer a one-flip rollback to
DynamoDB; the tables were exported and deleted. The DynamoDB storage adapter is
retained only as the in-memory test double the handler suites run against
(#415).#/learning, #/compliance, #/crew) are unchanged — only where they live in the menu — and the new division folds and auto-opens like every other.oid) is immutable, so a
surname change or a mailbox move no longer risks quietly attaching a signed
check or a ticket to the wrong person. It is captured whenever an account is
imported or linked on the People screen; email matching still works for records
that have not been linked yet. No data migration — the id rides the person
records that already sync.16.6, which is not a standard engine version offered in ap-southeast-2 — the backend deploy failed creating DbCluster. 16.6 exists in-region only as 16.6-limitless (Aurora Limitless Database, a different product), so RDS returned 400 “Cannot find version 16.6 for aurora-postgresql” and the stack rolled back (no resources kept; the running app was unaffected). Repin the DbEngineVersion default to a standard minor the region offers (16.14), and expose dbEngineVersion as a Deploy backend workflow input wired into --parameter-overrides, so a future version deprecation is a deploy-time choice rather than a code change. Template + workflow only.day date generated columns in the business DDL used a non-immutable expression, so CREATE TABLE audit_log (and the sharded tables after it) failed with 42P17 “generation expression is not immutable” when the schema was applied to real Aurora PostgreSQL. A text::date cast is STABLE (it depends on the DateStyle setting), which Postgres forbids inside a GENERATED … STORED column. Rebuilt each day column with make_date(substr…) — an immutable equivalent, still guarded by the same ^\d{4}-\d{2}-\d{2}$ regex and still typed date — so no adapter or query change is needed. The gap was invisible to the test fakes, which don't enforce Postgres's immutability rule; only applying the DDL to a live cluster surfaces it. DDL only.db.serverless instance, a generated Secrets Manager master credential, and a minimal isolated VPC across two AZs for the cluster — the Lambdas stay outside it and reach the database over the Data API). A new StateStore parameter wires STATE_STORE onto the state-persisting Lambdas, which backend/src/store.js reads to pick a storage backend; DB_CLUSTER_ARN, DB_SECRET_ARN and DB_NAME carry the Data API connection. The default is dynamo, so a deploy changes nothing about persistence until it is set to postgres — and, like recorder and telemetryMode, the value passed on a deploy is the one that sticks, so the cutover must be re-passed on every subsequent backend deploy. Idle capacity defaults to 0 ACU (scale-to-zero) so the cluster costs only storage until it is used. Template and docs only — no route, code or behaviour change.@aws-sdk/client-rds-data but it was never declared as a dependency, so both the migration tool and the deployed Lambda (once switched to postgres) would crash at runtime with MODULE_NOT_FOUND, and npm ci/sam build would not provide it. The gap was invisible to the backend suites, which stub every @aws-sdk/* require via the test loader, so the real module was never resolved. Added @aws-sdk/client-rds-data to backend/src/package.json and the lockfile.AWS::EC2::SecurityGroup GroupDescription (and the RDS DBSubnetGroupDescription) only accept ASCII, so CloudFormation returned 400 InvalidRequest creating DbSecurityGroup, cancelled the subnets and rolled the whole stack back — no resources were created and the running app was unaffected (StateStore stayed dynamo). Replaced the em dashes in those two resource descriptions with ASCII hyphens; the CloudFormation and parameter Description fields (which accept Unicode) are unchanged. Template only.docs/modules/backend.md (and the CLAUDE.md index line). The backend module now leads with the store.js seam and that PostgreSQL (Aurora Serverless v2, via the RDS Data API) is the live store with DynamoDB retained as the rollback — the normalised doc jsonb + promoted-column schema (backend/SCHEMA.md, backend/sql/), the STATE_STORE default, the Data-API quirks handled (jsonb-as-string parse, the 1 MB result cap paging, DEFAULT partitions, resume retries), and the migrate-to-postgres.js data move + parity harness. The existing DynamoDB-mechanics bullets are reframed as the store-dynamo.js adapter's internals. Docs only.stateStore input (dynamo / postgres) wired into the SAM --parameter-overrides, so the storage backend can actually be flipped from CI. Without it the template's StateStore parameter had no deploy-surface control and stayed dynamo permanently. Defaults to dynamo (the safe direction), and—like recorder and telemetryMode—the value passed is the one that sticks, so once cut over it must be re-passed postgres on every subsequent backend deploy. Setting postgres is the cutover and has pre-conditions (Aurora deployed, schema bootstrapped, data migrated and parity-verified per AWS_DEPLOYMENT.md § 2f). Workflow input only—no code or route change.#/config) holds the business-wide defaults for the
Transport side in one place: the default fuel levy, a reference per-km rate, the
default load time (hours) and demurrage defaults. Every field writes
NF.settings, which already persists, so there is no parallel config
and no backend deploy.NF.clientTermsFor, so editing it on the new
Transport Configuration screen flows through pricing; a client who has negotiated
their own levy still keeps it.--verify reports document keys that differ, it dumps the first record whose canonicalised form differs between DynamoDB and PostgreSQL (both sides, truncated) so a systematic representation gap — number/type coercion, an extra or missing field — is diagnosable without guesswork. Diagnostic output only on an already-failing verify; no change to the migration or its exit status.double→jsonb→double round-trip
unchanged at the 15th significant figure but perturbed at the 16th–17th
(measured live: a GPS lng 115.80993764849521 read back
…4952, ~1e-14°) was reported as a genuine document
difference — so --verify could never report OK on a real
coordinate dataset. Numbers are now canonicalised to 12 significant figures
before comparison, which absorbs that last-digit noise while preserving every
meaningful value (coordinates to ~0.01 mm, money to cents, weights,
durations); a difference at or above the 12th significant figure (a price 768 vs
769, a coordinate at the 6th decimal) is still reported. Integers — ms
timestamps, counts, ids — are left byte-exact and never rounded. A clean
pass now also prints the number of documents/rows compared, so a green parity is
visibly a real comparison, not a disabled one.backend/migrate-to-postgres.js gains a second pass,
migrateTelemetry(), that copies the day-partitioned time-series
(hist#/roll#/spd#/geo#) out of the DEDICATED telemetry table and the
cell-keyed zone# posted-limit cache out of the state table into the
Aurora tele_* / zone_cache tables through the Phase 2
adapter — still no per-collection SQL of its own. Because telemetry is high
volume and kept for good, it NEVER holds the whole set in memory: a memory-bounded,
keys-projected page-scan discovers the day-partitions present (only the partition
set + counts live in memory, never the rows), then each <kind>#<day>
partition is drained one at a time through queryPartition and
zone# is streamed page by page. It stays dry-run by default,
idempotent (upsert on the (day, s) / cell PK) and
resumable — the checkpoint is per day-partition and per zone#,
so a huge run resumes at day granularity and a crash re-does only the partition in
flight. Telemetry parity (--verify) checks per-table row counts
DynamoDB-vs-Postgres and content-samples the day-partitions (queryPartition on both
backends, byte-for-byte; --sample N compares every Nth), since
assembleState never reads telemetry and so cannot round-trip it. The
migration runs only when a telemetry table is named
(--tele-table / TELEMETRY_TABLE); with none it migrates
the business set alone, unchanged. backend/tests-migrate.js grows from
58 to 108 assertions against a faithful fake telemetry table (real pagination,
projection, begins_with filter and p=:p Query): a dry run
writes nothing, --write loads every prefix incl. the NULL-limit
zone# cell, a second run is a no-op, parity passes clean and reports
both an injected count mismatch and an injected content-only mismatch, and a
checkpointed resume skips the day-partitions already done. AWS_DEPLOYMENT.md
§ 2f gains the end-to-end cutover runbook (bootstrap both DDL files →
dry-run → --write business then telemetry → --verify
→ flip StateStore=postgres → roll back to dynamo),
stated plainly as the user's step needing production credentials and irreversible
for the data load.backend/migrate-to-postgres.js copies every DynamoDB item that
belongs in the /state document — every col#
singleton, every sharded prefix (consignments, jobs, lanes, credentials, quiz
attempts, session records, form/course submissions, locations, rate agreements,
devices, tmsJobs, assetService), the three legacy col# board/service
blobs, and the audit# log — into Aurora Serverless v2 behind
the store.js seam, reading through the DynamoDB adapter and writing
through the PostgreSQL adapter that Phase 1 built. It contains no
per-collection SQL: a scanned item is handed to dest.batchWrite() as
the same {PutRequest:{Item}} shape the handler already assembles, so
the adapter's own routing decides where every field lands. It is
dry-run by default (reports per-prefix counts), performs the load under
--write, is idempotent (every write is an ON CONFLICT
… DO UPDATE upsert, so a re-run writes nothing new) and
resumable (a per-prefix checkpoint skips completed prefixes; a crash
mid-prefix re-does that prefix harmlessly on resume). The parity harness
(--verify) checks per-prefix row counts DynamoDB-vs-Postgres and runs
an assembleState round-trip — it reassembles the whole
/state document from each adapter through the untouched handler's
GET /state and asserts they are byte-for-record identical modulo
id-keyed array order, so a field the store silently dropped (or reordered) is
caught. Telemetry (hist#/roll#/spd#/geo#/zone#) is out of scope and
never read, written or counted (Phase 4b). New suite
backend/tests-migrate.js (58 assertions, in CI via the backend glob)
drives the tool against a fake DynamoDB and a faithful fake RDS Data API: dry-run
writes nothing, --write loads every prefix, a second run is a no-op,
the harness passes on a clean load and reports both a count mismatch and a
content-only document mismatch when one is injected, and a checkpointed resume
skips the prefixes already done.ExecuteStatement result is capped at 1 MB, so
the adapter's unpaginated SELECTs threw
UnsupportedResultException once a collection grew past it —
GET /state (the whole-business read, e.g. assetService# at
~3,540 rows), the audit page read, a busy telemetry day-partition and a large
batched getItems could each exceed it, and the migration
--verify failed on exactly this. Those reads now paginate by keyset
and accumulate the full row set transparently — no change to what callers or
assembleState receive — halving the page size and retrying when a
page still trips the cap. The test fakes now model the 1 MB cap (the reason this
shipped green), so the guard is proven by breaking it.DbMinCapacity: 0 the
cluster pauses when idle, so the first GET/PUT /state
after a quiet window hit a resuming cluster and surfaced as a 500. The adapter's
exec/execBatch helpers now wrap the Data API in a
bounded exponential backoff (equal jitter, ~10 attempts covering a 15-30s
resume) on the transient class ONLY — resume, throttling and
5xx/transport — mirroring store-dynamo.js. SQL, constraint,
validation and conditional-check errors surface immediately and unchanged, so
#269's conditional-put and lock-CAS semantics are untouched. A batchWrite
retries the WHOLE begin→commit transaction (a failed transaction rolls back,
so a retry cannot double-apply), never a mid-transaction statement. Leaving
DbMinCapacity: 0 is intentional — this retry is what makes
scale-to-zero safe.doc jsonb column on every read.
The RDS Data API returns a jsonb value as a JSON string inside
formattedRecords, not a nested object, so the Postgres adapter was
reassembling every record as an unparsed string with no top-level fields —
assembleState could key nothing, GET /state returned
garbage in postgres mode, and the migration parity check reported every record
(absent) even though the rows were stored correctly. A null-safe
parseDoc now deserialises doc at every read site
(records, boards, telemetry, zone cache, singletons, snapshot, audit). The stored
data was always correct; no re-migration is needed. The three adapter test fakes
were made faithful — their formattedRecords now return
doc as a JSON string exactly as the service does, which is why the
bug had shipped green.GET /audit with a malformed date (?from=not-a-date)
no longer 500s: a value that is not a real YYYY-MM-DD calendar date is
kept off the ::date cast and compared as text instead, so Postgres
raises no invalid input syntax for type date and the route returns the
same degraded/empty page the DynamoDB backend's lexical string compare gave;
a valid date still uses the indexed ::date path.
(B) a targeted scan of a prefix that is neither a board, a shard,
nor a col# singleton now THROWS instead of silently returning
[] — a sharded collection added to collections.REGISTRY
but not to the adapter's SHARDS list would otherwise read empty in
delete-reconcile (silent data-invisibility); a known prefix with zero rows still
returns [].
(C) translateAuditFilter now verifies every value placeholder in
the source FilterExpression was consumed and throws if any is left
unmapped, so a differently-SHAPED future clause (BETWEEN,
contains(), a new operator) that the regex cannot match surfaces
loudly rather than being silently dropped into an over-broad audit read.
(D) the col# targeted scan escapes LIKE metacharacters (with
ESCAPE '\'), so a _/% in a
collection prefix matches literally rather than as a wildcard that could
over-match a sibling singleton (latent — no current col# name has
those chars).
(E) the dead whereCol parameter is dropped from
boardSelect (all callers passed null) — pure cleanup,
no behaviour change.
The fake RDS Data API was tightened so each guard reddens a regression assertion:
it now models the ::date cast error and real LIKE wildcard semantics.
Measured: reverting fix A reddens 4 assertions in
tests-store-postgres.js, fix B reddens 2, fix C
reddens 2, and fix D reddens 1.fleet#snapshot (the live-fleet position cache and refresh lock)
from BOTH the business scan and the parity counts — DynamoDB holds one at
rest but the Postgres /state scan structurally cannot return it (it
lives in refresh_snapshot, out of /state), so counting
it produced a guaranteed false mismatch that made --verify fail and
main() exit 1 on a byte-perfect migration. (2) claimRefresh
and the board legacy-migration conditional put now detect success from a
RETURNING row, not numberOfRecordsUpdated: the RDS Data
API returns 0 for a plain INSERT even when the row lands, so on a fresh
Postgres backend the refresh lock never succeeded and the live map never
populated. (3) batchWrite chunks each same-SQL group into
batches of 25 across multiple BatchExecuteStatement calls within
the one transaction — DynamoDB's 25-item cap had been dropped, so a large
PUT (e.g. the ~3,500-row assetService set) exceeded the Data API request
limits and the whole transactional PUT 5xx'd; atomicity is unchanged.
(4) GET /audit pagination is now keyset/seek on
(ts, id) DESC rather than LIMIT/OFFSET, so a
concurrent audit append (which lands at OFFSET 0) can no longer shift the
window and repeat a page-1 row on page 2; the cursor stays opaque, so the
handler is unchanged. (5) translateAuditFilter now fails CLOSED,
throwing on an audit-filter clause it cannot map instead of silently dropping it
— a future or renamed clause surfaces loudly rather than as a silently
over-broad read. Two of these (2 and part of 1) were hidden by test doubles more
permissive than the service; the fake RDS Data API was tightened to report
numberOfRecordsUpdated: 0 for a plain INSERT (records only via
RETURNING) and to model the keyset seek rather than an offset, so
each bug now reddens a regression assertion — measured: reverting fix 1
reddens 4 assertions in tests-migrate.js, fix 2 reddens 2
in tests-store-postgres.js, and fix 3 reddens 3.backend/src/store-postgres.js implements the whole
store.js interface over Aurora Serverless v2 (PostgreSQL) via the
RDS Data API, for the business collections and the audit log; it is selected by
STATE_STORE=postgres and registered in the factory, with DynamoDB
still the default so nothing deployed today changes. backend/sql/001_business.sql
is the schema: each sharded collection and the board are per-record tables with
doc jsonb as the source of truth plus generated projection columns
and the board’s rev/ord metadata, the audit log is
its own indexed table, and the col# collections and the refresh
snapshot are single rows. The handler is untouched: the adapter returns the same
item shapes it already consumes, so the /state document reassembles
identically — proven by a parity test that runs one PUT /state
through the handler over both the DynamoDB and Postgres stores and asserts
GET /state comes back byte-for-record equal. The non-uniform write
rules (asset-service delete-on-absence, the board’s partial-vs-replaceBoard
and its reported-not-rejected conflicts, configured-0-vs-absent) all
carry through, and the refresh lock’s compare-and-set becomes a genuine
conditional upsert. Telemetry is Phase 2 and is refused rather than mis-stored.
New suite backend/tests-store-postgres.js (56 assertions, in CI via
the backend glob), with a fake RDS Data API that models PostgreSQL’s
semantics faithfully rather than permissively.backend/src/store-postgres.js now serves the day-partitioned
telemetry and the posted-limit cache over Aurora via the RDS Data API, so
queryPartition and the telemetry getItem/getItems/putItem/batchWrite
paths — which had refused with a Phase-2 error — are implemented.
backend/sql/002_telemetry.sql is the schema: tele_hist,
tele_roll, tele_spd and tele_geo are
PARTITION BY RANGE (day) with a DEFAULT partition as the write-time
catch-all (so the recorder never fails a write for a missing partition; a
documented maintenance step rolls named monthly partitions off the default), and
zone_cache is cell-keyed with a swept TTL. Each row is stored
(day, s, doc) — the exact analogue of DynamoDB’s
(p, s) — so queryPartition returns the same
{p, s, …record} shape store-dynamo.js does and the
handler’s bounded readers (the 128 day-part cap, complete/unread/windowDays/future)
reassemble identically; the handler is untouched. A sort-key
begins_with(<asset>#) narrow is a byte-ordered range on
s COLLATE "C" after partition pruning. Telemetry stays out of
GET /state by construction (the state scan reads only the business
tables), a NULL posted limit is kept as a settled answer rather than a cache miss,
and a legacy pre-new-mode pk-shaped row is refused loudly
rather than mis-stored. New suite backend/tests-store-postgres-telemetry.js
(40 assertions, in CI via the backend glob): row-shape parity against the real
store-dynamo.js, the bounded reads driven through the untouched
handler (/fleet/speedevents, /fleet/geofence,
/fleet/rollup, and POST→GET geofence), the /state
exclusion, and zone_cache round-trips — against a fake RDS Data
API that models the telemetry tables and throws on any statement it does not model.backend/SCHEMA.md maps the whole-document /state contract onto a
normalised PostgreSQL (Aurora Serverless v2, RDS Data API) schema: one table per
col# singleton and sharded prefix, real columns and indexes for
everything queried, joined or ordered (the board’s rev/ord,
day keys, asset ids, customer/consignment ids), day-partitioned time-series
tables for the telemetry, and a per-key document mapping that proves the
round-trip is lossless. Design only — no code and no behaviour change.docs/modules/. The guidance file (968 lines, ~600KB) was the single most-conflicted file in the repo — 26 of 39 measured branches touched it, 42.5% of pairs sharing it conflicted, higher than js/integrations.js, css/app.css and tests/run.js combined — because a change to any one subsystem meant editing the same one file everyone else was also editing. Its 44 ## sections now live one-per-file under docs/modules/, moved verbatim, and CLAUDE.md itself is a short spine: a linked index naming every module and what it covers, plus the handful of rules that recur across nearly all of them (active !== false never a truthiness test, hatched rendering for indeterminate/unknown states, names and counts never a percentage on a leadership report, archive-never-delete, the {ok, error, message} writer shape, three places to persist plus a fourth to be readable, verify-to-fail numbers measured not estimated, and a stale test matcher being a third kind of red). Verified byte-for-byte: the spine's original intro plus all 44 module files, concatenated in the original document order, are identical to the pre-split file — nothing was lost or reworded in the move, only relocated and indexed. No runtime code changed; nothing in the app or its test suites reads CLAUDE.md.backend/src/handler.js's COLLECTIONS whitelist was a single 402-character
line hand-listing 31 collection names, plus ten more shard-prefix constants spread across
the file — the same shape that let carriers and devices both ship
missing the audit-viewer step. backend/src/collections.js (#361) is now the
alphabetical, one-entry-per-line answer to "what collections exist, how is each stored, and
is it audited" for all 44 of them. handler.js's SVC_PREFIX,
SVC_LEGACY_PK, DEVICE_PREFIX, JOB_PREFIX,
SCHED_PREFIX, JOB_LEGACY_PK and SCHED_LEGACY_PK now
genuinely read their value from it at runtime; COLLECTIONS itself and six other
prefix constants (CRED_PREFIX / QUIZ_PREFIX /
TMSJOB_PREFIX / LOC_PREFIX / RATE_PREFIX /
SESSION_PREFIX) stay hand-typed literals — an existing dependency-free test
suite parses their exact source text with regexes, so a computed value there would parse as
garbage — but backend/tests-collections.js now pins both against the registry so
the two cannot silently drift apart. The Learning screen's own change-log filter
(LRN_AUDIT_COLLECTIONS) is cross-checked the same way against the registry's
lmsAudit flags, bidirectionally, so a training-record collection can no longer
go missing from that dropdown the way two of the nine already had once (#237). No behaviour
changes and no schema changes; needs a backend deploy before the derived constants run in the
live Lambda (their values are unchanged, so this is a no-op deploy for anyone reading the
API). The general #/audit viewer's own, larger AUDIT_COLLECTIONS
array has pre-existing drift of its own (two names matching nothing, several audited
collections missing) that this stage found but did not fix, to avoid changing what a user can
select in that dropdown — left for a follow-up.css/app.css became core.css (tokens, reset, shared components), 44 module stylesheets named after the feature each one styles, and overrides.css for the handful of responsive and print rules that genuinely span several screens. A brief can now say "you may touch the compliance stylesheet and nothing else" and that is a checkable statement rather than a promise. Verified by rendering, not by reading: eight screens at five widths, on screen and in print — 480,600 computed style values compared against the old build, with a control run of the old build against itself to establish the noise floor. The only differences were the dashboard's live bar widths, which move between any two runs. It also caught a real bug on the way: putting the shared responsive rules in the first file loaded silently killed them, and Live Fleet's map came out two columns wide on a tablet instead of one — which is why those rules now load last, and why a CI check refuses any stylesheet that restates a selector another one owns.StateStore to postgres — the DynamoDB→PostgreSQL cutover is complete, so Aurora is the live, authoritative store. The template parameter and the Deploy backend workflow input both default to postgres, and the sticky-input safety wording is inverted: a deploy that accepts the defaults now keeps the app on the store that holds current data, and selecting dynamo is a deliberate ROLLBACK that serves state frozen at the flip and loses writes made since (AWS_DEPLOYMENT.md § 2f, step 7). Config/docs only — no code or schema change.backend/src/store.js) with a DynamoDB implementation behind it
(backend/src/store-dynamo.js), so a future SQL backend can be
dropped in without touching the routes. A pure refactor with no behaviour change
— DynamoDB stays the only and default backend, and the same test suites
pass unchanged (6,273 assertions in tests/run.js, all 20 backend
suites).tests-store-postgres-telemetry.js speed-events case dated a run now + 1h and let its day-partition derive from that timestamp, so near midnight the run landed in tomorrow's partition — which the 48-hour window read does not scan — and the future-count assertion failed (it was 23:52 UTC when this surfaced). Pin that run into today's read partition while keeping the future timestamp, so the future classifier is exercised deterministically at every hour (and it now models the realistic case: minor clock skew within the same day). Test only.NF.WetHire model,
the imported bus roster (js/wethire.js), the whCharters
and whOverrides persisted collections and the wet-hire assistant
cards are all gone. The Wet Hire nav entry stays as a division placeholder
and the Wet Hire asset business unit is unchanged, so assets can still be
tagged and filtered by it.parent and splitLetter, not a new collection, so the dispatch board, the schedule, the driver run sheet and the traffic light all read it with no change. The PO, the customer reference and the price are inherited from the parent, and the parent’s status rolls up from its parts..cmp-sr-axis already carries.Q/B/CN broke three checks in the suite that guards “one record, one number” — and the product was correct in all three: the checks matched the old format literally, requiring a hyphen, so Q1042 / B1042 / CN1042 was reported as three different numbers when it is plainly the same digits at three stages. They now test the claim rather than the format, and two further holes were closed while in there: one check was satisfied by the raw database id, so it would have passed on a screen that threw away the whole point of the feature, and another compared only neighbouring pairs, so a numbering where the con note reused the quote’s prefix passed. That suite goes from 37 checks to 40. Across this release: 5,642 checks in the dependency-free suite (up from 5,277), all 18 backend suites green, and 57 browser suites — one of them new — all measured by running them on this build.master. Its clock is now pinned to local midday — skewed rather than frozen, installed before the code under test loads — so no fixture can reach a day boundary in any timezone, and one added later inherits it. Folded into this release rather than shipped separately. That suite goes from 186/159 checks per storage layout to 199/172, which is why the backend figure in the developer notes moves to 1,211 and the release total to 6,853.env: block on the understanding that this kept it out of the log and that a masking directive covered anything left over. Neither is true of the log: the runner writes that env: block into the step’s header before the script runs, so a mask registered by the script cannot take back a line already printed — and a repository variable, which is where the token lived, is never masked at all. The token appeared in the clear in the logs of three real backend deploys while the file asserted it could not. Both workflows now read the token from a repository secret, which the runner masks from the start of the job, and each says in its own log which source was used — because “this run printed the token” is something the person reading the run needs told, not left to infer. The two files make deliberately different calls on the old variable: the site deploy still falls back to it, since an empty token there publishes a site that cannot authenticate to its own API on every merge to master; the backend deploy drops it outright and skips its smoke test instead, because an unauthenticated call gets a 403 whether the API is healthy or broken, and a check that cannot fail is worse than no check. The same run also stops echoing half the Fleet Dynamics login. Two things are Simon’s to do and this release cannot do them: create the API_TOKEN repository secret, and rotate the token, since the old value has been in public logs. The issue stays open for both.GET /audit API. Honest by design: an on-screen note states the log is of net persisted changes (not keystrokes), identities are unverified until SSO, and — with sign-in/roles not built yet (#66) — the screen is visible to anyone with dispatcher access for now. Needs a backend deploy to serve the data; until then the screen says so rather than showing nothing.NF.areaFor) that returns the most specific area — a user fence over a depot, the smallest fence among overlaps. A front-end change, live on the next site deploy.col#assets) came to ~397KB — over the 380KB per-item guard on save (DynamoDB caps an item at 400KB), so the asset detail was rejected (413) and never persisted. It now keeps only the fields a screen actually reads — make, model, VIN, year, acquired, stage, odometer and its unit, plus the Odoo id needed to match the service history — and drops the redundant weight (the composite name, a duplicate of the plate, a constant company, a record-updated timestamp, and the near-empty driver / location / fuel / seats fields, all 0–7% filled). That takes the register to ~255KB, comfortably under the cap, with no visible change to the asset page. The register is a fixed-size fleet, so one trimmed record stays fine long-term — unlike the ever-growing service history, which had to be split up (v0.88.0). A front-end change: it takes effect on the next site deploy, after which re-importing from Odoo makes the asset detail stick.backend/import-odoo.js, does exactly what the Settings → Asset register → Import from Odoo button does, from the command line: node import-odoo.js <apiBase> reads Odoo and the live register and reports what would change (how many vehicles matched, which are in Odoo but not the register, which matched more than one row, and the service-record counts), and --write applies it. It uses the same merge logic the screen uses (NF.mergeOdooAssets / NF.mergeOdooServices, loaded out of js/data.js), not a second copy that could drift: Odoo detail lands in each asset’s own odoo namespace, type / depot / business unit and every other collection are left untouched, unmatched vehicles are reported rather than added, and hand-entered service records are kept. It reads the document back after writing to confirm, is safe to re-run (idempotent), and never writes if a fetch fails. Like the other maintenance scripts it is a whole-document write, so it’s run when the board is quiet; the operator reviews the dry run before using --write. Tooling only — no site or backend change is required to use it.4,300 came out labelled and forecast as if it were 4,300 km — a meaningless figure. The asset page, the service-due badge and the Service due & forecast report now read each asset’s unit and count it down in the right one: kilometres against the settled 25,000 km road interval as before, or engine hours against an assumed 500-hour plant interval, labelled “hrs” and marked assumed on screen because a genset and a crane service on very different hours. Plant is grouped into its own section of the forecast rather than dropped into a “within 5,000 km” bucket it doesn’t belong in. Where no hours interval is known, the asset’s last reading and last service still show but no due point is invented — the same honesty the report already keeps for an asset with no odometer. Road vehicles are entirely unchanged.FLEET_OTHER_LEAD) is raised from 10 to 20, giving the rotations ~26 calls per cycle: prime movers now refresh over about three cycles (~35–45 seconds, still far better than the six minutes before v0.75.0) and more of the trailer / bus / light-vehicle fleet stays live on the map. Backend only; needs a backend deploy.FLEET_OTHER_LEAD) sets the other-tracked floor; the exact split will be tuned by re-measuring the feed after deploy. Degrades safely as before — the last good snapshot is always served. Backend only; needs a backend deploy.backend/purge-legacy-telemetry.js is a careful, one-off clean-up an operator runs by hand. It reports only by default — it counts the leftover rows and their size and shows a few examples, changing nothing — and only deletes when run with --write, after which it reads the table back to confirm the rows are gone. It can only ever delete tracking rows: a hard safety check confirms every row’s key before each delete, so an operational record can never be touched, and re-running it is safe (it simply finds nothing). Deleting the tracking history is a deliberate, reviewable step — the operator runs the report first and decides. Backend maintenance only; nothing in the apps changes and no deploy is required.GET /state and GET /fleet/rollup are gzipped on the wire — both are read as JSON and neither was compressed. /state is fetched at boot on every sign-in (measured 333 KB, ~8.9 s) and /fleet/rollup is pulled by Overview, Assets and Events (measured 930 KB, 4.8–8.8 s). The Lambda now gzips each response when the caller advertises Accept-Encoding: gzip (every browser does, automatically and transparently) and returns byte-identical plain JSON otherwise — the same opt-in helper /fleet/history already uses. /state drops to roughly 50 KB on the wire; the response body is unchanged, so a client that can’t inflate (and the driver service worker, which never caches /state) still gets exactly what it did. These are the two deferred transfer-size follow-ups from the #108 live-API perf audit.FD_PRIORITY_TYPES setting (default prime, ~51 prime movers) are swept whole every cycle, ahead of the two background rotations, so the trucks dispatch watches most closely stay live. The rest of the tracked fleet and the untracked fleet keep rotating behind them — each cycle still advances both, so nothing ages off the map or stops recording history — and a guard makes the top tier itself rotate if it is ever configured larger than one sweep can hold, so it can never consume the whole cycle and starve the rest. FD polling concurrency was raised from 5 to 10 (still well within a gentle rate) so the top tier fits in one cycle with slots to spare; both knobs are tunable and the effect is measurable on the feed (new priorityTop / topSwept diagnostics). Degrades safely: a too-aggressive setting can only cause more harmless deadline aborts — the last good snapshot is always served — never an error. Backend only; needs a backend deploy.#/assets link now lands on Fleet Overview, so existing bookmarks still work.NF1000 · Prime mover · Port Hedland), a blank-plate row falling back to its unit id.#/depot/NWA and the live in-yard list all generate themselves from the one row. The in-yard list uses the default 4 km depot circle around the airport coordinates until a drawn yard boundary lands.API and failed with “API is not defined” the moment it was pressed. The fault was latent until Odoo was connected (the importer had never reached a live backend before); the Odoo module now resolves the backend address the same way every other integration does.fleet.vehicle.log.contract) and maps them onto FleetView’s existing document-expiry outlook, alongside the hand-entered registrations, permits and inspections. Each contract is classified as registration, insurance or lease/finance and shown on the asset page’s new Documents & expiries section, on the fleet-wide expiry outlook in Reports, and in the compliance snapshot.?wall yard-monitor view.?wall like the other monitor screens.node backend/tests-fd-session.js) — that the sign-in is carried on every request, that it never appears in any output or log, that a lapsed sign-in recovers on its own, and that a refused sign-in degrades quietly rather than breaking. Confirmed the checks catch the original fault. Added to every build and to the deployment gate.node backend/tests-media.js — 23 assertions) covering upload, permitted file types, oversize rejection, a byte-exact round trip, unknown keys, path traversal and the not-yet-configured case. Wired into CI and the backend deploy gate.node tests/run.js also exercises the backend save/load endpoint with a stubbed DynamoDB (no AWS account or network needed): batch splitting at the 25-item limit, scan pagination, retry of throttled writes, refusing to ack a partial write, and route/error handling.sam.cmd, and Git Bash only recognises .exe commands, so a working installation was invisible and the script sent you off to reinstall something you already had. It now asks Windows where the tool is, confirms it actually runs, and makes it available for the rest of the deploy../deploy-guided.sh replaces a sequence of commands that had to be edited before running. It checks the tools and credentials are working, finds the website's storage and CDN settings by itself, asks for the Fleet Dynamics login (password hidden, and not left behind in the terminal history), deploys, and then tests the live GPS feed and explains what came back. If it can't confirm where the website lives it stops rather than updating half of the system.NF.version from the shared data layer instead of the number being typed into each page, so a bump can't half-land and leave an app claiming the wrong build.cd tests/manual && npm install && node drive-screen.js runs ~45 checks over the one screen the main test suite can't reach: layout at tablet and phone sizes, touch-target sizes, the map (right tiles for the GPS fix, following, the whole-leg toggle, sizing after tab changes and rotation), over-limit warnings, losing GPS, one-tap time capture and the HQ banner. It fakes the GPS and generates its own map tiles, so it needs no internet and can gate a deploy. Optional and kept out of tests/run.js, which stays dependency-free.fleet_dynamics_unavailable response just before the deadline no matter what hangs beneath it. The Live Fleet map now always gets a proper answer — positions, a partial sweep, or a clean "GPS offline".node tests/run.js runs ~110 assertions over the business logic (pricing zones and lane rates, demurrage including midnight crossings, document expiry, job-form applicability, client agreement rules, invoicing totals, messaging unread counts, incidents, and the integration inbox). Dependency-free and exits non-zero on failure, so it can gate a deploy.NF.Integrations) is swappable for a real API, EDI or mailbox feed.#/schedule?wall puts the weekly run plan (with its conflict warnings) on a mounted depot screen, and #/jobs?wall / #/events?wall do the same for the dispatch board and incident feed. Wall mode gets a branded header with a live clock, enlarged type for reading at distance, interactive clutter removed, and a 60-second self-refresh so the monitor never goes stale. Launch links are on the Settings screen./fleet/live now works to a strict ~12-second wall-clock budget and returns whatever vehicle positions it has gathered so far (flagged partial) if the FD server stalls mid-fleet, instead of hanging past the Lambda's 15-second limit. The map degrades gracefully and the next poll fills in the rest.maxspeed data via the free Overpass API (new swappable NF.SpeedLimit adapter). Falls back to manual limits where the map has no data; an Auto toggle switches between map-detected and manual./driver/ only; the dispatcher app and client portal are unaffected.infra/site.yaml provisions a private S3 bucket + CloudFront (HTTPS, directory-index rewrite for /driver/ & /portal/, correct PWA headers). New-account runbook in infra/DEPLOY-NEW-ACCOUNT.md.nodejs24.x (Node 20 is past end-of-life); deploy.sh no longer needs Node to write config.js.loginxml/selectshowxml operations correctly (operation as a URL path segment; vehicleId casing), instead of POSTing to the base URL (which the server tried to parse as SOAP and rejected). A valid login with no vehicles now returns an empty fleet (200) rather than an error.NF.Store now tracks unsynced state and replays the full snapshot until the API accepts it (idempotent, last-writer-wins). No behaviour change when no backend is configured.#dash-map was inheriting height:100% from the full-page map rule and collapsed to zero once its card stopped stretching. Mini-map now has a fixed 340px height._leaflet_pos console crashes caused by state-sync re-rendering a torn-down map.NF213, not NF-213).