Skip to content

bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test - #323

Merged
nyblnet merged 138 commits into
mainfrom
worktree-bento-dash
Aug 30, 2026
Merged

bento/dash: the release blockers, two kinds of sheet, and an Excel bounce test#323
nyblnet merged 138 commits into
mainfrom
worktree-bento-dash

Conversation

@nyblnet

@nyblnet nyblnet commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Everything in bento/dash since dash-v0.2.0: the release blockers, the two sheet
kinds, and every finding from an Excel bounce test. 60 rigs, all green.

Why it is one PR

The changes reference each other. The type-conversion fix, the validator check
that catches the same state arriving from a file, and the import gate that
depends on defined names existing are three parts of one argument; split across
PRs each one half-explains a bug. The commit history is granular and each
message says what was measured.

The release blockers are done

§0 of docs/dash-release.md is clear, and ten further items closed here: file
write-back, print/PDF, grid accessibility, the 400k-row crash, per-cell
formatting on the dataset kind, pivot/canvas rename, reorder-undo cost, an
undoable version restore, the dropped-workbook read-only gap, and the grid that
ended at the data.

Three remain and none is code: no pack channel (deliberate), the update path
cannot be exercised until something is published, and releaseFileHandle()
belongs in the serialised kernel zone.

Two kinds of sheet

kind:'table' is a DATASET — typed by column, columnar, exactly the rows there
are. kind:'canvas' is a SPREADSHEET — typed by cell, sparse, unbounded. The
bridge between them (promote a range, open a copy as a spreadsheet) is the part
neither Excel nor a BI tool does well. Design in docs/dash-sheet-kinds.md.

On top: the step engine the format has described since commit one, a SQL surface
that compiles to Step[] rather than embedding an engine (docs/dash-sql.md
explains why DuckDB-WASM cannot load from file:// at all), named ranges,
array-formula spill, Data Validation, paste special, text-to-columns, and 12
more functions.

The bug worth reading about

Import lands a mixed column as text and advises "set the column type once you
have decided what it is". Doing that moved col.type and nothing else — so the
grid right-aligned and number-formatted the values while the footer totalled
them as SUM 0, against a true total of 10,308.85 that dash returns
correctly from =SUM() on a spreadsheet copy of the same rows.

A confident wrong number, at the end of a path the product recommends. Found by
doing a real job with a real .xlsx, not by inspection — a capability audit I
ran the same hour was wrong twice in twenty lines. Fixed in store.ts (convert
the storage, refuse what will not convert, and carry the pre-conversion bytes on
the inverse so undo restores them) and in validate.ts, because store.ts can
only stop the state being CREATED — it still arrives from files saved by the
build that had it.

Offline mode

dash's share of GHSA-5c3x-xqp6-g94r closes by merging #305 rather than by
fixing anything here: one chokepoint, kernel/src/net.ts, with a CI rig banning
raw fetch/WebSocket outside it. dash was converted in that PR rather than
exempted from the scan. The UI half was branch-local and is fixed here —
setOffline returns whether the preference persisted, and the dialog says so.

What the rigs are for

38 new ones. The failure they exist to catch is the one this branch hit four
times: a green rig over a feature that is invisible on screen, because the
check proved a function correct and nothing proved the caller called it. Each of
those now has an explicit assertion on the call site, and
scripts/test-ci-registered.ts fails if any rig is not actually run by CI —
which it found five of on its first run.

Known and open

  • A convergence divergence at 5 concurrent editors. Pre-existing — verified
    by running the new fuzzer against the pre-branch engine and getting the
    identical failure. ACTORS=4 is clean at 27,667 checks.
  • SUBTOTAL inside a cell formula does not yet see the viewer's filter. The
    engine half is done; wiring it makes the formula engine view-aware, which is a
    decision rather than a hook (docs/dash-excel-gap.md finding 18).
  • AVG over an empty view reports 0 (finding 17).

Full record, including what was declined and why:
docs/dash-excel-gap.md, docs/dash-release.md, docs/DECISIONS.md.

nyblnet added 30 commits August 4, 2026 01:25
Seven modules, each with its own rig (790 checks), plus the grid and
store changes that put them on screen.

  select.ts   ranges, the keyboard map, TSV clipboard, fill series
  a1.ts       A1 references and the cell-formula grammar
  rowcol.ts   insert/delete/move/resize/freeze/hide
  filter.ts   predicates, multi-sort, top-N
  condfmt.ts  colour scales, data bars, rules
  gl.ts       a WebGL2 renderer in 3.7 KB deflated
  viz3d.ts    surfaces, scatter and bars over a sheet

What changed in the feel, rather than the feature list:

TYPE TO EDIT. A printable key on a selected cell replaces its contents
and seeds the editor with the character typed, and Enter/Tab commit and
move (down/up/right/left). Before this every edit needed a double-click
first, which is the single gesture that made the grid read as a viewer
rather than a spreadsheet. It routes ahead of the key map, which returns
null for bare printable keys precisely so typing can reach it.

A SELECTION OUTLINE AND A FILL HANDLE. A per-cell tint alone does not
read as a selection; the eye wants the rectangle. The handle in its
corner is how a spreadsheet user expects to fill — not a menu item.

FROZEN COLUMNS. Columns only: rows are positioned by index so a subset
cannot simply stick, and the header is sticky already, so the gap a
reader actually hits is losing the labels when scrolling right.

setSheetProps grew a `drop` list and lost its duplicate implementation.
Deletes were spelled `props: {k: undefined}`, which `JSON.stringify`
erases — over collab the delete would land locally and reach no other
replica, and the same trap sat on the inverse, so undoing a newly-set
key was a no-op everywhere else. Deletes are now a listed `drop`,
undefined is refused rather than accepted as a second spelling, the
structural-key guard is enforced on the path that actually writes, and
the store delegates to rowcol.ts so the forward and the inverse cannot
drift apart.
=A1+B1 in a cell. Column formulas stay the better tool and the default —
they name columns by identity, so they survive every structural edit —
but a spreadsheet that cannot put a number in ONE cell is not a
spreadsheet, and nobody is going to be argued out of typing =B4*1.2.

cellformula.ts owns no engine. a1.ts already decides what a reference is
and where it points; formula.ts already parses, evaluates and owns the
error values. The bridge is that formula.ts resolves a `ref` node by NAME
against its context, so a reference needs no parser support: rewrite `B4`
to a generated name bound to a one-element vector. A range binds the same
way to a longer one, which is why SUM(A1:A5) works without `:` ever
becoming an operator.

Order is the correctness problem, not evaluation, so it is Kahn over
cells and anything left when the queue drains gets #CYCLE! — never a
plausible number. The rig's first draft passed on ordering by LUCK: every
fixture was written with dependencies to the left of their dependents, so
a plain document-order walk got the right answer. Sabotaging the sort
caught one check out of thirty-three. The fixtures now run backwards
against the document, and it catches them.

A1 counts CANONICAL positions, never the visible grid. Sorting and
filtering are view state, so a formula must not change meaning when a
reader sorts: verified in the browser — SUM(D1:D6) reads 72,350 before a
sort and 72,350 after one.

setOverrides had the same wire bug setSheetProps did: a delete was
`v: [undefined]`, and JSON.stringify turns that into `[null]`, so over
collab the delete arrived as a null override instead. null deletes too
now. runEdit takes several patches as ONE undo step (writing a value and
clearing the formula it replaced is two), with the inverses REVERSED —
which the obvious test cannot show, because two patches on different
structures commute and pass either way. The check that discriminates
writes the same key twice.

THE HEADER. Every column read "A R", "B O", "C :" — the letter, name,
type badge, filter caret and resize grip were sharing 130px and the name
lost. Two lines now: letters on their own strip where Excel puts them,
with the type badge riding along in the space going spare, and the name
below with room to be a name. Moving the letter cost two `parentElement`
reads that silently became NaN — column select and click-to-sort both
died, and both are `closest` now.

Type is a popover rather than window.prompt. Import refuses to guess a
type it cannot decide, and that refusal is only honest if fixing it is
one click — not a native dialog asking for the NUMBER of the type you
wanted.
Copy/paste and insert/delete now maintain A1 references. translateRef
and shiftRefsForInsert were both written and tested and called by
nothing; this wires them to the two events that need them.

They are DIFFERENT rules and conflating them is the classic spreadsheet
bug, so the rig proves they differ on the same input:

  COPY   the formula moved, the cells did not. References follow it,
         except the $-pinned ones. =$A$1+B2 copied two rows down is
         =$A$1+B4.
  INSERT the cells moved, the formula did not. EVERY reference moves,
         $ included, because the cell it names physically moved. The
         same input shifted by two rows is =$A$3+B4.

A reference into a deleted row becomes #REF!, never the row that slid up
into the gap. A range spanning the hole shrinks instead.

TWO BUGS FOUND WHILE WIRING THIS, both silent, neither mine:

⌘V NEVER REACHED THE GRID. The document paste listener routed every
pasted block into the CSV importer, so pasting cells created a whole new
sheet instead of filling the selection — and grid.pasteTsv, fully
written, had no callers at all. ⌘V pastes into the cells now; importing
a file is what the Import button is for.

DELETE ROW DELETED THE WRONG ROW. Every structural op in rowcol.ts takes
a CANONICAL row index — it must, since a document edit cannot be
expressed in one reader's view — but the context menu passed the VISIBLE
one. Identical until somebody sorts. Measured on a Value-sorted grid:
right-click the row showing £22,750, choose Delete row, and £12,400 is
destroyed. The re-sorted view then rescrambled over the evidence. Both
indices are converted through grid.canonicalRow now.

The order vector was stale after a structural edit too — it holds row
indices and insert/delete renumber the rows underneath them, so the grid
drew blanks and rows in an order matching nothing. It rebuilds on a
structural invalidation.

Copy keeps an internal clip so a copied formula stays a formula, while
the system clipboard still carries VALUES — paste into another app and
£37,200 is what is wanted there, not =D1*3. The clip is only used when
the pasted text still matches what we wrote, so copying something else
in between can never paste a stale clip in place of it.
Copying makes a SECOND formula that should mean the same thing in its
new place, so its references move. Cutting moves the ONE formula, and a
formula that travels with its cells still means exactly what it did —
so a cut clip pastes untranslated. Excel agrees, and reversing it
silently re-points a moved formula at the wrong data.

Cut also did not clear what it cut: clearSelection wrote null VALUES
through setCells and never dropped the `f` override, so the formula
stayed and simply recomputed. Cutting a formula cell appeared to do
nothing, and Delete on one was a no-op. It drops both now, in a single
commit so one ⌘Z puts back the values and the formulas together.

Known limitation, documented at the call site: formulas ELSEWHERE that
referenced the cut cells do not follow them to the new location — they
keep pointing at the old, now-empty positions.
About reaches the same places slides' does: this file (version, counts,
size against the budget, docId, write-in-place state), document
properties, updates, language, password, version history, and the
take-it-elsewhere set (copy JSON, replace from JSON, duplicate as a new
workbook, save a copy). Nothing was writing the version timeline before,
so the history it restores from would have been permanently empty —
rememberVersion now feeds it, and refuses an encrypted workbook for the
same reason putRecovery does.

The splash is now dismissed rather than removed on sight: held briefly,
faded, then taken out of the DOM because while it exists it is
position:fixed inset:0 and eats every click on the grid. The two gates
(password, unreadable file) dismiss it immediately — an error surface
must not wait behind branding. A failsafe timer covers a boot that
throws before it ever reaches the dismissal.

parseDoc NEVER MINTED A docId. The field is declared required and was
not enforced, so a document block without one parsed happily and booted
with `docId: undefined` — and everything keyed off it (autosave
recovery, the version timeline, every future merge) shared ONE slot
called "undefined" across every such workbook. Verified before and
after: minted when absent, preserved when present, different on two
parses. `newDocId` is promoted to model.ts, where two modules had
independently grown private copies of it.
The suite's shared chrome, matching slides to the pixel: 5px resizer
strips with chevron tabs that dock flush when a panel is shut, [ and ]
to toggle, widths and collapse state remembered, both panels shut on a
phone unless the reader has said otherwise. The right panel is the same
accordion, open state persisted per section title.

Left is the sheet list; right is Column / Sheet / Workbook. Two things
that had no route back before: hidden columns can be un-hidden (Hide
this column was one-way), and the freeze can be set from the panel.

Mounting threw and took the whole boot with it: `resizer()` writes into
a `chevrons` const declared further down the same closure, so it was in
its temporal dead zone when the strips were built — and because the
panels had already emptied `.dx-body` by then, the grid vanished too and
the page came up as bare chrome. Only reachable by actually mounting,
which is exactly what standalone markup rendering does not do.

setSheet gained a real onSheetChange rather than the instance being
monkey-patched from outside, and it now resets the selection, sorts and
filters — they belonged to the sheet being left, and carrying a
column-3 filter onto a two-column sheet would have hidden every row.
…ts CSS

The save button is a split menu now: Save · Save a copy… · Save as new
workbook… · Save as template… · Save read-only copy…. Templates and
read-only copies write WITHOUT retaining the file handle, so a later ⌘S
cannot overwrite an export with the full workbook — slides learned that
one the hard way. All exports go through serializeAuto rather than
serializeFile, so an encrypted workbook's template is not shipped in
cleartext.

registerPreview gives the file a real thumbnail: the first sheet drawn
as a table, so a workbook shows its data in Finder instead of the boot
splash. Four rules the kernel cannot make — formula columns past 5,000
rows draw EMPTY rather than a number nobody computed; totals past 100k
rows are omitted rather than summed over the drawn window only; theme
colours reaching a stylesheet are allowlisted as colour syntax, where
escaping `<` is not the relevant defence; and an encrypted workbook
never gets one. The provider NARROWS its argument rather than casting —
it runs inside serialization, so a throw there fails the save, not the
thumbnail.

AND THE BUILD LOST THE ENTIRE STYLESHEET. postbuild-compress.mjs found
the app CSS by taking the first big `<style>` in `<head>` — but vite
inlines the module script into the head too, so the moment an app's
SOURCE built a `<style>` string (the thumbnail does) that string was
matched first. The real stylesheet was left uncompressed and
unregistered and the app booted with no CSS whatsoever. It matches
vite's `<style rel="stylesheet">` now, with the old scan as a fallback,
verified against both shapes.

Not fixable in the app source, which was the first attempt: esbuild
constant-folds `` `<${'style'}>` `` straight back to a literal, so the
`</script>` trick from save.ts does not transfer. slides and spaces
never emitted a literal `<style>`, which is why this waited for dash.
A background or freshly-created tab reports window.innerWidth === 0
before its first layout, so `0 < 700` matched the phone rule and booted
a desktop with BOTH panels collapsed — with no stored preference to
explain it, which read as the panels not having shipped at all. Found
by smoke-testing the built artifact rather than the dev server.
Lookups (XLOOKUP, VLOOKUP, INDEX, MATCH, LOOKUP), multi-criteria
(SUMIFS, COUNTIFS, AVERAGEIFS, MINIFS, MAXIFS), finance (NPV, IRR, PMT,
FV, PV), statistics (VAR, VARP, STDEVP, PERCENTILE, QUARTILE, CORREL,
RANK, MODE, COUNTUNIQUE), logic (IFS, SWITCH, XOR, IFNA, TRUE, FALSE),
text (PROPER, REPT, TEXTJOIN) and dates (DATE, EOMONTH, EDATE, DAYS,
WEEKDAY).

Three deliberate departures, each because the Excel behaviour is a trap:

VLOOKUP DEFAULTS TO EXACT. Excel's 4th argument defaults to approximate,
so on unsorted data it returns whatever row the search lands on and
reports nothing wrong. That default has produced more quiet spreadsheet
errors than anything else in the product.

IRR IS BISECTION, NOT NEWTON-RAPHSON. Newton is faster and diverges on
the cash-flow shapes people actually have, returning a number rather
than failing. Cash flows with no sign change now give #NUM!.

PERCENTILE TAKES A FRACTION. Passing 90 is #NUM!, not a clamp to the
maximum — clamping answers a question nobody asked.

Two bugs found by the rig rather than by reading:

VECTOR FUNCTIONS WERE BROADCASTING. SCALAR dispatch calls a function
once per row, which is right for ROUND(value, 2) and wrong for
CORREL(a, b) — that ran per row and correlated two single numbers. They
now take their arguments unbroadcast.

CELL FORMULAS COULD NOT SEE THE COLUMNS. Only the COMPUTED columns were
passed to the cell evaluator, so `SUMIFS(Value, Region, "North")`
matched nothing and returned 0, XLOOKUP gave #N/A, TEXTJOIN gave #NAME?
— while PMT, which references no column, worked perfectly. One cause
wearing four faces. Verified in the browser against the starter data:
SUMIFS over North AND Won is 50,750, and PERCENTILE(Value, 0.5) is the
true median 10,750.

Ten negative controls on the rig, all caught — including ANDs flipped to
ORs, an approximate VLOOKUP default, IRR inventing a rate, and dates
computed in local time instead of UTC.
openFilterMenu built `{op:'greater', value: …}`; filter.ts spells the
payload `v`. The bound arrived undefined, so the predicate matched
nothing — measured before the fix: "Value greater than 10000" left 0 of
8 rows, and a text `contains` matched every row, which made the feature
look inert rather than wrong.

`as never` on the call is what let it compile. Removing the cast is the
actual fix; correcting the key is just what the compiler then demanded.
After: 4 of 8 rows, and they are the right four.
THE HOOK. bento/slides earns its keep through morph: both frames live in
the document, so a shape animates model-driven with no DOM measuring.
This is that idea applied to data. `doc.story` holds a sequence of
steps; each step is a saved VIEW — a filter, a sort, a chart binding, a
3D camera, a caption. Stepping between them morphs the chart. Tableau
needs a server for this; Excel has nothing like it.

A step stores a view and NEVER numbers, so the series are re-derived on
every render and a story cannot disagree with the table behind it. Edit
a cell and every step moves at once.

The interpolation refuses to invent data. A gap tweens as a gap:
morph(null, 100, 0.5) is null, never 50 and never 0 — one character
different from the obvious `a ?? b`, and the difference is whether an
absent quarter grows out of the floor as though it were a small one.
Series are matched by NAME, because matching by index tweens Revenue
into Cost while the legend still reads Revenue.

Where it cannot animate honestly, it CUTS and says so. charts-lite reads
every cartesian value through num(v, 0), so a null in a line series is a
dive to the axis and back — a lie about the data. Bars morph (a null bar
has no height, which is what absent looks like); lines cut. The 3D
camera cuts rather than flies, and highlight renders as chips rather
than painted bars, both because the honest version needs kernel changes
that are listed in the module's KNOWN GAPS block.

Supporting work: `setDocProps` is the document-level twin of
setSheetProps — same discipline, deletes are a listed `drop`, and it
refuses sheets/docId/format so an unbounded props write cannot rewrite
structure or identity. The story types moved into model.ts as an
additive field.

106 rig checks; 11 deliberate mutations, all 11 caught — including the
kernel's own leaf fallback, series matched by index, and a filtered line
diving through zero.
Tiles over one workbook — KPI, chart, table, text — and clicking a bar,
a slice or a row filters every other tile. Selections compose: values OR
within a column, columns AND across them. Verified in the browser
against the starter data: North gives £50,750 over 3 rows, then North
AND Priya gives £28,000 over 2, and the chips read back what is applied.

The LAYOUT is document data; the SELECTION is not. A cross-filter click
goes through store.view() — no checkpoint, no dirty flag, no op — so
exploring a dashboard never dirties the file. Measured: the dirty
indicator stays off across a two-column selection.

Series are derived through chart.ts's own optionFor over a PROJECTION of
the surviving rows, so there is one group-by in the app and a tile
cannot disagree with the table behind it. A side-effect worth having:
charts now honour hand-corrections in `sheet.cells`, which chart.ts's
own column reader does not.

NULL IS A CATEGORY, NOT AN EXEMPTION. Blank, empty string and whitespace
collapse to one "(blank)" category before the group-by, so selecting
North excludes blank-region rows and selecting "(blank)" selects exactly
them. The categories of a column therefore PARTITION the sheet — union
is every row, pairwise disjoint, asserted both ways. The tempting
alternative, letting a null match every selection so unknowns are never
hidden, makes the totals stop adding up.

`setView` writes one view by id and carries its POSITION, because views
is an array and the order is the tab order: without it, undoing the
deletion of the first dashboard silently reordered the tabs — a change
nobody asked for, arriving inside the operation meant to undo one. Found
by its own rig check, not by reading.

Ten negative controls, all caught. One of the agent's own was initially
vacuous — it mutated the fixture rather than the parser, comparing a
document to itself — and was fixed by parameterising it.
Multi-level rows × columns, seven aggregations, subtotals, grand totals,
and drill-down from any cell to the source rows. Verified in the browser
against the starter data: North/Priya is 28,000, South/Sam 35,600, and
the cross-foot agrees both ways at 97,050.

It never materialises a row object. Each grouping field becomes a code
plane — for a dictionary column the codes ALREADY exist, so preparation
is one pass over the distinct values, not over the rows. 200k rows across
two row fields, one column field and two measures: 74 ms, 18 MB, 2 ms to
drill down.

SUBTOTALS ARE COMPUTED, NOT SUMMED. Every row accumulates into every
ancestor, so a subtotal average divides by its own values and a subtotal
distinct-count is a distinct count. The rig proves the average-of-averages
answer differs (16.31 against 22.5), and that per-region distincts sum to
8 where the truth is 5.

A pivot is a DOCUMENT, not a view. Sorting belongs to the reader; hiding
a column is editorial and travels in the file. "Revenue by region by
quarter" is the second kind — somebody built it, and "look at the pivot
on sheet 3" has to name something that exists. The sheet stores the SPEC
and never the numbers.

AND parseDoc DESTROYED EVERY UNRECOGNISED SHEET KIND. Anything not
'canvas' fell through and came back as `kind: 'table'` with empty
rids/columns/data/steps bolted on — the sheet rewritten into a different
thing, and what made it one dropped. That is not a pivot problem, it is
PLATFORM §3 failing for every future sheet kind: old builds are frozen
code, and a file from a newer dash has to survive a round trip through
them untouched. Now preserved verbatim, with a rig check that carries a
kind from a hypothetical future build through intact.

Twenty negative controls, all caught. Two of the agent's own checks were
vacuous and were fixed rather than kept: an unguarded min/max of an empty
group, and a header defence no test could distinguish from its absence —
that one was traced, shown unreachable, and deleted.
Import and export .xlsx on DecompressionStream/CompressionStream —
a minimal ZIP reader/writer and the SpreadsheetML actually needed. +12.2
KB deflated, about 10% of the shell. SheetJS alone is several times the
whole product; JSZip roughly doubles it.

PROVEN END TO END THROUGH THE SHIPPING PATH, not only in node. The
browser's CompressionStream produced a workbook, and LibreOffice opened
it and CALCULATED the SUM() we wrote — 97,050 is its arithmetic, not a
number we shipped. Percent renders 15%, not 1500%; dates land on the
right day. The archive passes `unzip -t`, `zipinfo` and python
zipfile.testzip, and our inflate of three real Excel files matches
`unzip -p` byte for byte.

Both date epochs, including the 1900 leap-year bug at serials 59/60/61 —
getting that wrong shifts every date by four years, silently.

Three refusals, each preserving the file rather than guessing:

A CROSS-SHEET FORMULA IS NEVER MADE LIVE. `Sheet2!A1` would silently
rebind to THIS sheet's column A, because a1.ts correctly declines to
rewrite a sheet-qualified name but does rewrite the A1 after the `!`.
The cached value is kept and the source preserved verbatim on the
override as `xlsxF` — now a named field in model.ts — which the exporter
writes back, so xlsx → dash → xlsx does not delete somebody's model.

COLUMN FORMULAS EXPORT AS VALUES. A dash column expression names columns
by identity and evaluates over vectors; Excel has no equivalent. A
per-row translation is right for simple arithmetic and stops being right
the moment the semantics diverge, and 95%-correct is exactly the silent
wrong answer this codebase is organised against. Reported in findings
either way.

MERGED CELLS KEEP THE VALUE WHERE THE FILE PUT IT. Filling the range
turns one spanning heading into N repeated values and one total into
several.

Fourteen negative controls, all caught. Two were initially MISSES
against LibreOffice, which happily opens a package Excel would refuse —
untyped worksheet parts, a styles part with no fills. With no Excel on
this machine, the requirements are restated as an explicit OPC
conformance block rather than trusting the one reader available.
Groundwork for collaboration, and two real bugs reachable without it.

INSERTING PAST THE END CORRUPTED THE SHEET. `splice` beyond the array
APPENDS, but the writeCell below it writes at the literal index — so the
row landed at position 3 and its value at position 10, leaving the column
longer than the sheet has rows with a hole of nulls between. Measured on
a 3-row sheet: rids said 4 rows, the column held 11 entries. rowcol.ts
clamps before building the patch so the UI never produced it, but
`window.bento.commit` is a public API and a remote op is another
producer. applyPatch is where the invariant has to hold.

RIDS WERE BEING REUSED. The floor derives from the current maximum, so
deleting the last row lowered it and the next insert minted that rid
again — measured: rid 3 deleted, then handed to a different row. The
model's own header says rids are never reused, and overrides, comments
and a peer's CRDT node all assume a rid names one row forever. Under
collaboration two replicas would mint the same rid for two different
rows and merge them into one. `nextRid` is now raised on insert AND on
delete — the delete half is the one that matters, since nothing else
remembers that a deleted rid ever existed — and it is never lowered, not
even by undo. That makes insert and delete the only ops whose apply→undo
is not byte-identical, and both rigs now say so explicitly rather than
comparing a field the format requires to survive.

AND A MONOTONIC COUNTER DOES NOT CONVERGE. Putting the watermark in the
document made two replicas disagree on it — 4002 against 4001, and on
nothing else. A monotonic counter's join is MAX, and the engine already
knows every rid that ever existed for a sheet, so the watermark settles
to one past the largest of them at every settle point. Both replicas
compute it identically once their states agree.

The collab engine and its 23,062-check convergence rig land here too,
with docs/dash-collab.md and its frank list of ten cases that lose data.
NOT WIRED INTO THE APP: one convergence bug is open (below), and the
session, transport and People panel are unmounted.
Both wore one symptom — identical sync state, workbooks differing by one
or two cells — which is why they looked like a single problem in the
dead-window value path. Neither was.

A DICTIONARY TRUNCATED BY SOMEBODY ELSE'S UNDO. `setCells`'s inverse
carries `dictLen`, and applyPatch honours it by cutting the dictionary
back. Exact for one writer. Under collaboration a peer's op landing
between a local commit and its undo interns ITS strings into the same
shared dictionary above that watermark, and the undo strands them: every
index pointing at one reads back null. Only the undoing replica
truncates, only values it did not author are lost, and NO register moves
— so the states stay identical while the documents drift. The sync layer
disarms `dictLen` on the patch the store is about to apply, because it
is the only layer that knows a session is live.

A PARKED VALUE THAT NEVER WEIGHED ITS COLUMN'S REBIRTH. A cell sits
under two whole-node assignments, but the stash replay compared the
parked authority only against the ROW's birth. Every replica that
reached the same rebirth by the other path applied the column rule and
blanked the value; the one that arrived through this path replayed it
unconditionally. The rule already existed — this path just never
consulted it.

Each fix closes exactly one seed, verified by reverting them
independently. Both are covered by hand-built deterministic checks with
the interleaving written out, rather than by a random seed nobody can
read, and each check was confirmed to fail when its fix is removed.

STILL OPEN, found at higher settings and reported rather than tuned
away, both failing identically on the unmodified engine:
  · row ORDER diverges at six actors (seed 116) — the fractional-key
    path, not the value path; values track their rows correctly
  · a row resurrected TWICE by two different actors drops a parked
    value on the receivers (seeds 374, 184)

Measured reach: clean across 300 seeds at four actors and 400 at five;
three failures across 200 seeds at six. Collab stays UNMOUNTED — these
lose a cell value at realistic editor counts, and a sync that silently
drops one number is worse than no sync.
SAVE WAS OFF THE SCREEN. Thirteen buttons in a flat row needed 1436px in
an 802px window, so Import CSV, Undo, Export CSV, both Excel buttons,
Save, its dropdown and the version chip all sat past the right edge. The
most important control in the application was unreachable, and About was
only openable by clicking the wordmark — which nobody guesses is a
button.

The bar is groups that degrade now, the way slides does it: every
control carries an icon AND a label, and the label is what shrinks.
Import/export fold into one Data menu at every width — four buttons for
something done twice a session is what pushed Save off the end. The
insert group becomes a + menu below 900px without any JS reparenting,
so no listener is ever rebound. Each breakpoint comes from a
measurement rather than a round number, and Save keeps its word longest:
it is the control people name when it goes missing.

Measured after, at 320/390/700/802/900/1100/1281/1440: the bar's
scrollWidth equals its clientWidth at every one, the body never scrolls
horizontally, and nothing is clipped — including at 320px with both
drawers open. An explicit ⓘ About button exists; the wordmark and
version chip stay as shortcuts.

THE GRID HAD NO HORIZONTAL RULES AT ALL — only verticals and zebra
striping, which is a web table, not a spreadsheet. Every cell has a
bottom rule now, the zebra is gone (Excel and Sheets have neither), the
header and gutter are a real tinted band with a seam, and the selection
tint is strong enough to find.

Seven bugs found by measuring rather than looking, including a boot
crash introduced and caught in the same pass — `esc` was a const while
the dispatcher calls boot() during module evaluation, so every load died
with the splash still up and NOTHING in the console. Also: `--surface`
and `--sel-bg` are declared nowhere in dash, so frozen cells resolved to
a transparent background — the exact bug the rule exists to prevent.

Row height stays 30px and is NOT Excel density: grid.ts positions every
virtualised row at `top: i * ROW_H` inline, so shrinking the cells
perforated the grid. Both have to change together; recorded at the site.
Excel's default row is 20px at 96dpi and Google Sheets' is 21px, both
against a ~13px face. dash sat at 30, which is a third fewer rows on
screen and part of why the grid read as a table on a web page rather
than a spreadsheet. 22px against dash's 13px text is a ratio of 1.69,
beside Sheets' 1.62.

THE REASON THIS FAILED THE FIRST TIME was that the number is declared
twice: grid.ts positions every virtualised row at `top: i * ROW_H`, and
the height comes from the stylesheet's `--row-h`. Changing one alone
perforates the grid — the cells shrink and the row boxes do not. So the
grid now WRITES the custom property from its own constant when it
mounts, and the stylesheet value is only the fallback for the moment
before the grid exists. They cannot drift in a running app at all.

A rig check guards the fallback anyway, and asserts the write is still
there; both halves were sabotaged and both were caught.

Verified in the browser: row boxes butt exactly (no gaps), the selection
outline lands on the cell to the pixel, the sticky header sits flush
against row 0, and virtualisation is unaffected — 20,000 rows render 30
DOM nodes with a sizer of exactly 440,000px.
The 'dense pro' direction was chosen, and 20px at 96dpi is precisely
Excel's default row. 22 was the intermediate step taken while proving
the two declarations could be made to agree; this is the target.

The stylesheet fallback moves with it, and the guard added last round —
which asserts the two declarations match AND that grid.ts still writes
the property at mount — passes unchanged.
The chosen direction. Lattice kept on both axes but lightened to
#edf0f4, header band quieted to #fbfcfd with hierarchy carried by type
rather than fill, 12.5px grid type, and the selection moved off amber to
#eef4ff with a #2563eb ring. Selecting a column now lights its HEADER as
well as the row gutter — on a wide sheet the header is the only thing
still on screen once the cursor has scrolled away.

The amber selection was not just dated, it was ambiguous: conditional
formats are red/amber/green, so an amber tint sat on top of amber data
bars and you could not see where the bar ended.

A DARK THEME across every surface — grid, top bar and menus, formula and
status bars, both panels, pivot, dashboard, story editor, save menu,
About, popovers, splash. One palette declared once as `light-dark()`
pairs, so there is no second copy in a media query to drift out of step.

AND THE THEME NEVER REACHES THE FILE. The obvious `data-theme` on <html>
silently wrote itself into every save — `capturePristine()` clones the
LIVE document, so the serialized shell came back carrying the author's
preference, and everyone opening that workbook would have inherited it.
It is a `<style data-bento-transient>` now, which the kernel strips from
every serialized shell. Verified end to end: switch to dark, save, open
the bytes cold with no stored preference, and the workbook renders in
the READER's theme. Same rule as locale and reduced motion.

Data-driven colour does NOT move: scales and bars are computed from the
numbers and two readers must see the same encoding. What changes is the
ink on top of them, and the bar opacity drops on dark, where amber at
0.55 measured 3.9:1.

~40 element specs measured across both themes: zero contrast failures.
Every var() referenced now resolves — the class of bug that left frozen
cells transparent.

The KPI tile clipped its own figure: measured at 30px type, the value
needed 34px of height in a 20px box and 166px of width in 125, so
"£97,050.00" rendered as "£97,05" with the descenders sliced. A KPI that
truncates its number is worse than one with smaller type, because the
reader cannot tell it happened.
The sheet list labelled everything non-table "canvas sheet — not
editable in this build". True until pivots existed — and then a pivot,
generated by the app's own + Pivot button and now surviving a round trip
since parseDoc stopped coercing sheet kinds, sat in the list confidently
describing itself as something else. A label that states the wrong thing
is worse than a vague one.

The Bar/Line toggle also stayed beside the panel title while a pivot was
mounted there, where it controls nothing that is on screen.

Both were flagged by the agents that found them as needing files they
did not own.
The last two classes closed, and a third the sweep turned up.

ROW ORDER DIVERGED AT SIX ACTORS. A row resurrected by two actors
yields two inserts. A replica that received the LOSER first already had
the row in the document when the winner landed, took the already-here
branch, recorded the winner's key and left the row physically where the
loser put it. Same registers, same births, same tombs — two different
sequences. Worse, that replica's rids were then no longer sorted by
(key, rid), and the row lookup is a BINARY SEARCH that assumes they are,
so every later insert landed somewhere arbitrary. That amplification is
why it took six actors to see it. The branch relocates the row now.

A VALUE SURVIVED ONLY ON ITS SECOND RESURRECTOR. The first insert names
a column, the second — from an actor who has not yet heard of that
column — does not, so the row's named set is overwritten and the parked
value's authority becomes unrecognisable. The MINORITY replica was the
correct one: an insert makes no claim on a column it does not name, so
nothing superseded the value. Everyone else was dropping it.

AND A ROW INSERT CLOBBERED A NEWER PARKED VALUE (seed 163, found by the
sweep and confirmed against the unmodified engine, so not a regression).
Every replica but one recovers, because the write is still queued on the
buried column — but its AUTHOR applied it into a live document and never
queued it anywhere, so the parked copy was its only record. Five
replicas with a number and its author with a blank.

Each fix has a hand-built deterministic check with the interleaving
written out, not a random seed nobody can read, plus a TRACE_ORDER
invariant asserting the sort the binary search depends on. Verified
independently: with the relocation removed, seed 116 returns 10
failures and 7 of the new checks trip.

Sweeps, on this machine: 1000 seeds × 250 steps × 6 actors, 300 × 400 ×
8 with the order invariant on, 250 × 350 × 7 — all clean, plus the
agent's own thirteen configurations. No known failing seed remains.

Still NOT wired into the app: `nextRidFloor` derives from the current
maximum, so two replicas can still mint the same rid concurrently. The
watermark half landed; partitioning the rid space has not, and that is a
correctness precondition rather than a nicety.
THE LAST CORRECTNESS PRECONDITION. The watermark made minting monotonic,
which stops a rid being reused after a delete. It could never touch the
concurrent case: two people insert at the same moment, both compute "one
past the highest I know about", and both get the same number. rid is
IDENTITY — the CRDT keys a row node on it, overrides and comments attach
to it — so two different rows sharing one merge into a single row and
one is silently lost.

Each replica now mints from its own block: rid = base + counter, 30 bits
of block by 23 bits of counter. 1.07e9 blocks, 8.4M rows per replica —
past docBudget, which stops a document long before then — and the top
block ends exactly on MAX_SAFE_INTEGER.

BLOCK 0 IS RESERVED FOR SOLO DOCUMENTS, so an unshared workbook still
numbers 1, 2, 3 and run-length encodes to [[1, 4200]]. Nothing about the
common case changes; the cost is paid only once a second person is
editing.

The residual risk is stated rather than hidden: a block is derived from
the actor id, so two replicas collide only if their derived blocks do —
the same assumption every register comparison in the engine already
makes. About 5e-8 for ten concurrent editors.

Within a block the floor comes from an in-memory high-water mark, not
the durable watermark: that watermark is a maximum across every actor's
rids and would push a replica straight out of its own block into
someone else's.

PROVEN IN TWO REAL TABS, not only in the rig. Both open one document;
each inserts a row at the same moment; A mints 3000369571430400 and B
mints 7443666155077632, and both replicas converge on ten rows with
BOTH inserts alive. Unpartitioned, both mint rid 9 and one row vanishes.
The rig's own check is blunter: with partitioning off, 25 of 25 minted
rids collide.

The session is constructed for every workbook and connects to nothing
unless the file arrived with credentials or the reader opts in, so the
starter still never phones home. mountPeople takes over its host, and
handing it `app` erased the whole application on boot — grid, panels,
everything — leaving only the panel behind, with nothing in the console
because nothing threw. It has its own container now.
Mark the insertRows clamp done — it landed with the rid watermark work
and the doc still listed it as owed.
The sync session used to decorate `commit` and `runEdit` on the store
instance. That covers every edit the UI makes and CANNOT see undo or
redo, which apply their inverses through a private path — so undo fell
back to broadcasting a whole state snapshot. Correct, and enormously
heavier than the two ops it stood in for.

`store.beforePatch(fn)` is called from commit, runEdit AND invert with
the patches about to be applied, and may replace the list.
`store.afterPatch(fn)` is the other half: the CRDT mints ops from the
PRE-state, because a delete has to be read before it displaces anything,
and settles once the document has moved. Registering only the first half
would have left parked remote ops permanently unlanded.

`invert` passes substitute:false. A hook may REFUSE a patch it cannot
express, and dropping one there would apply half an inverse and leave
the undo stack describing a document that no longer exists. An undo
lands whole or not at all.

Measured in two tabs on one document: the edit broadcasts one `ops`
frame, the undo broadcasts one `ops` frame — not a snapshot — and the
peer follows both, back to "Priya".
Four things the store owed the sync engine.

A DELETED ROW NOW TAKES ITS OVERRIDES WITH IT, and the inverse carries
them back. `insertRows`' own inverse is a bare `deleteRows`, so undoing
an insert went through the raw patch and stranded any hand correction,
note or per-cell formula added to that row in the meantime — on the
undoing replica only, because the engine strips them everywhere else.
An invisible divergence. `insertRows` gained an `overrides` field, which
is the honest shape: it already carried the row's values.

`setOverrides`' INVERSE CARRIES `dropEmpty` BOTH WAYS. Undoing the
removal of the last override left `cells: {}` behind on the undoing
replica while every peer received it through a path that drops the
container. Twelve bytes, and a document that no longer matches its own
collaborators.

`store.changedRemotely()` is a public verb for "the document changed
underneath you" — stamp modified, invalidate, emit what an edit emits,
without touching the undo stack, because a collaborator's change is not
an entry in your history. The session reached through `unknown` for the
PRIVATE `emit` before, which works until the event names change and
nothing tells you.

AND SHEETS ARE A PATCH. Adding or deleting one went through
`replaceDoc`, which CLEARS THE UNDO STACK — so creating a pivot, or
adding a sheet, silently threw away every edit you could previously take
back, and deleting a sheet could not be undone at all. Verified in the
browser: edit a cell, create a pivot, undo twice, and the edit is still
there. The inverse carries the sheet's POSITION, because sheet order is
the tab order.

One rig check flipped rather than being deleted: it asserted that a bare
deleteRows stranded the override, which was true and was the bug.
A spreadsheet's grid does not stop where the data stops — Excel and
Sheets both rule the whole window, and that continuing lattice is a good
part of what makes a grid read as a sheet rather than as a table someone
put on a web page. An eight-row workbook ended in a large white
rectangle.

Painted as a BACKGROUND on the scrolling element, not as filler rows.
Empty rows would be real DOM, would have to be virtualised, and would be
selectable and editable — a grid you can type into a thousand rows below
your data is a different product decision and not one to make by
accident. The background costs nothing and cannot be clicked.

It is as WIDE as the sheet and no wider. Excel rules to the window edge
because its columns go on forever; dash's do not, and ruling past the
last one draws cells that cannot be typed into.

AND THE PEOPLE PANEL WAS DOCKED WRONG, twice. mountPeople renders a full
card — heading, toggle, member list, empty-state line — and dropped into
the topbar that was 103px of block content which pushed the bar from
56px to 118px. In the bar it is a chip now: the live dot, the state, the
toggle, a swatch per collaborator. "You" and "nobody else is here" are
not news to the person reading them.

Then it went in AFTER the right-hand group rather than inside it — and
the responsive ladder measures that group, so anything appended past it
sits outside the arithmetic and pushed Save off the screen again, the
third time this session. Inside the group now, and on a phone the chip
stands down entirely unless somebody else is actually present: measured
at 390px it cost 20px more than the bar had and clipped About.
#236 added the formula and chart rigs. There are twenty-three now, and
until this the other twenty-one only ran when somebody ran them by hand
— roughly 24,600 checks, including the 23,100-check convergence suite
that has caught every ordering bug in the collab engine.

They land HERE rather than on main because eighteen of the rigs do not
exist on main: they arrived with this branch. Adding the CI steps first
would have failed every one of them on a missing file. A step that runs
a rig belongs in the same commit as the rig.

Each line says why it gates a merge rather than what it runs — the rigs
argue their own cases in their headers.
THE ROW NUMBERS SCROLLED AWAY. `.dg-gutter` declares `position: sticky`,
and `.dg-cell { position: relative }` thirty lines later has the same
specificity and silently won. Measured: scroll 600px right and the
gutter sits at left −495, entirely off screen. A spreadsheet whose row
numbers leave when you scroll sideways has lost the thing they are for.

The header's corner had never been sticky at all, so a scrolled column
header slid underneath and showed through where the row numbers'
heading belongs — the corner read "age", the tail of "Stage". It is the
one cell that must outrank both axes.

And the gutter now wins its overlap with a frozen column: equal
z-index left it to DOM order, and the gutter comes first.

CHART TEXT IS THEMED BY VALUE, NOT BLANKET. charts-lite writes `fill` as
a presentation ATTRIBUTE, which loses to a stylesheet — that is what
lets a theme reach chart text, and CSS is the right layer, because the
theme is a VIEWER preference while the chart option is derived from the
DOCUMENT. Baking a colour into the option would put one reader's
preference into everyone's chart.

But the rule matched every `text`, so it would also have flattened a
colour somebody CHOSE — legend.textStyle.color, an axis label, a future
per-series colour — and done it silently. It now matches only the
kernel's own fallback (#6B7280). Verified both ways in the browser: a
default-coloured label themes to 8.78:1 on dark, and an explicitly
coloured one stays exactly the colour it was given.
nyblnet added 26 commits August 18, 2026 16:53
The filter work's hook, applied. It was NOT one line, as reported: routing the
caret orphans `openFilterMenu`, which orphans `frozenTo`, `readCellOf` and five
rowcol imports, and leaves `Greater than` and `Contains` as dead catalog keys in
seven languages. tsc found the first four, the i18n rig found the last two — I
did not have to remember either, which is the point of both.

BOTH CALL SITES IN ONE CHANGE. The caret in the column header and the column
context menu's "Sort and filter…" — added hours ago by the gutter work, and
routed through the same old function — are two ways to one thing. This codebase
has now been bitten three times by giving one door a capability the other lacks:
import findings rendered as bullets through the menu and as one paragraph
through the drop door, defined names carried by one importer and not the other,
and this menu, which only ever existed on the caret at all.

So the check is on the count: at least two call sites, and no `openFilterMenu`
left anywhere. Negative-controlled by stubbing the second door — "both doors
call openColumnMenu (found 1)". Comments are stripped before matching, because
one of my own checks this week was satisfied by a comment I had written
explaining the very thing it was meant to guard.
Two of the three things the gap report says print lacks were already
there, and this fixes the third by moving what existed rather than adding
a mechanism.

REPEAT HEADER ROWS were half-built: `thead { display: table-header-group }`
already repeats the COLUMN names on every page, but the sheet caption was
a <div> above the table, so it printed on page one and was never seen
again. The caption is now the first row of that same thead — one <th>
spanning the table, so a long workbook title is not hyphenated into the
row-number gutter — which is the whole of Excel's "rows to repeat at top",
built out of the one mechanism that behaves across engines. Page 27 of a
budget now names the sheet, the workbook, the view it is a view OF, and
the DATE it was printed.

The date is an argument to buildPrintable, not a `new Date()` inside it: a
rig that cannot pin the date cannot assert on the markup at all.

MARGINS existed but were fixed at 12mm; they are now Normal 12 / Narrow 10
/ Wide 20. 10mm is the floor because consumer printers clip under it. This
is not decoration — pageBox shrinks with it, so the choice feeds the
column planner, changes the shrink factor and moves the page estimate. It
is the one knob a reader has when a sheet is one column wider than the
paper.

PAGE NUMBERS: the existing reasoning is re-argued and stands. CSS Paged
Media margin boxes are unimplemented in every browser, and a second set of
numbers disagreeing with the ones the system dialog already prints is
worse than none. What was wrong was not the decision but the silence
around it — a reader who wants "Page 3 of 40" was left to conclude the
feature is missing rather than that it lives one dialog along. The Print
dialog now says where they come from.

Page header is an option (default on, since the caption it replaces was
unconditional and a printout that lost its sheet name in an upgrade would
be a regression). A preference file written before this option existed
carries no key, so only an explicit false turns it off.

Negative controls run and confirmed red: caption back to a <div> (2 checks
fail), date never reaching the markup (2 fail), margin choice ignored for
a constant (3 fail).
…n purpose

The formula work reported this as a mechanical hook — four lines plus a
CellSource field. It is not. cellformula.ts takes a DOCUMENT, and store.order is
view state that store.ts:952 declares 'never in the document, never synced,
never undoable'. Wiring it makes the formula engine view-aware and two
collaborators with different filters compute different numbers for one cell.

Checked rather than assumed before writing that down: computed values are never
persisted, so nothing diverges in the file, and Excel does the same thing — its
filters are just shared state where dash's are not. So the answer is probably
yes. It is still a decision, and taking it silently while wiring a hook is how a
boundary gets crossed without anybody choosing to cross it.

The case that mattered — every imported Excel table arriving with a dead total —
needs none of it and works now.
All of 8-13 are done or deliberately declined, each with where and why. Two
notes worth keeping rather than deleting: two of the three print complaints were
already fixed when the report was written, and ROW/COLUMN are declined for a
reason that has nothing to do with cost — registering a function is what admits
it through the xlsx liveness gate, so a ROW() that cannot answer would import
live and paint #VALUE! over a number Excel had cached.
The list had gone stale in the direction that matters — it described intentions
where there were now observations, which is the rot its own header warns about.
Ten of thirteen open items are done; each now says where and what was measured.
Three remain, and none of them is code: the pack channel (deferred on purpose),
the update path that cannot be exercised until something is published, and
releaseFileHandle, which belongs in the serialised kernel zone.
…t notice

main gained #313 — the transport and session layers lifted from slides into
`kernel/src/sync/` — plus four spaces PRs. Two conflicts, both appends:

- `.gitignore` — BOTH rules kept. main ignores `.worktrees/` (per-session
  worktrees, kept inside the repo on purpose so the zsh hook picks the right gh
  account and /tmp cannot reap them); this branch ignores `.claude/worktrees/`.
  Different directories, both real.
- `docs/DECISIONS.md` — append-only by design, so a union.

THE INTERESTING PART IS WHAT DID NOT BREAK. `scripts/test-relay-protocol.ts`
guards that dash's transport and its twin agree on everything that goes on the
wire — signature texts, curve, hash, ?tok=, keepalive, room template — because
one deployed relay verifies both and a drift locks one app's users out of the
other's rooms.

It was written to FOLLOW the twin rather than pin a path, with the kernel lift
named in its header as the move it expected. That paid today with no
intervention: it now reports "comparing dash against kernel/src/sync/online.ts"
and passes 15/15. A guard that pinned `slides/src/sync/online.ts` would have
gone quiet on the exact day the code was most likely to drift.

60 rigs green, tsc clean, shell builds.
Two defects, both found by looking at the app and neither visible to a rig,
because each rig mounts one menu on one sheet.

1. ESCAPE DID NOTHING ON THE COLUMN MENU. `filterui.ts` builds its own element
   — `el.className = 'dx-pop dfx'` — so it took the STYLING of a popover and
   none of the behaviour. `gridmenu.popover()` owns the four ways out (an item,
   a click outside, Escape, a replacement) and keeps them private, so the second
   builder could wear the class without them. That wiring is now exported as
   `dismissable()` and the column menu calls it: one class name, one behaviour,
   one implementation.

2. A MENU SURVIVED A SHEET SWITCH. A column menu is about a column, and after
   the switch that column may not exist — going from a dataset to a spreadsheet
   left "Sort A → Z / Hide this column / Freeze up to this column" hanging over
   a sheet with no columns at all, still wired to the sheet behind it.
   `setSheet` closes any open popover.

Both are seams rather than mistakes: two agents built two menus in parallel, and
each one's rig was right about its own. The check that catches them is on the
source, asking which path a builder took, with comments stripped so prose about
`.dx-pop` cannot satisfy it.

Negative-controlled: styling-without-behaviour restored gives 2 failures,
the sheet-switch dismissal removed gives 1.
…'s zone

Seven commits, one conflict — `dash/package-lock.json`, regenerated rather than
hand-merged.

THE ONE THAT MATTERED IS #338. "Copy document JSON" put the room's read key,
writer key and owner key — which can also revoke — on the clipboard. A
SyncSession is constructed at boot, so `collab` is minted for every workbook and
every copy carried them.

slides hit this first, fixed it, and wrote `scripts/test-export-secrets.ts` so
it could not come back. Every path in that rig was hardcoded to `slides/`, so
when spaces and dash each grew a "Copy document JSON" of their own, both
reintroduced it and the guard was structurally incapable of seeing them. The rig
reads every app now and found dash on its first run — which is the same lesson
as `test-ci-registered.ts`: a guard that cannot see a whole app certifies a
smaller set every time the repo grows.

That session crossed into dash to fix it and said plainly it could not verify —
no node_modules in its checkout, so no tsc, no rigs, no build. It flagged it for
whoever owns dash. Verified here rather than assumed:

  · tsc clean, 61/61 rigs, shell builds
  · docForExport driven on a document carrying a read key, a writer key, an
    owner key and an invite: none of the four appears in the output, and the
    sheet data does. The rig asserts the SHAPE (strips by removing, so a
    credential added later is covered without anyone remembering); this asserts
    the OUTCOME on real values.

Their fix is correct and their reasoning for crossing zones was right — leaving
a live key leak in place to respect an ownership boundary would have been the
worse call.

Also inherited: zopfli shell packing (#326), which takes dash from 433KB to
417KB with no format change.
Found by asking what #338 changes for the person using dash, rather than
whether the leak was closed. It was — and the strip turned a rare path into the
ordinary one, where the round trip does something nobody asked for.

MEASURED, both directions:

  · a STRIPPED paste — now the everyday AI round trip, because "Copy document
    JSON" no longer carries credentials — silently ENDED the live session.
    Sharing on / room `w-abc` before, sharing off / room gone after; the edit
    landed, the docId was kept, and nothing on screen said the workbook had
    left the room while peers went on editing it.
  · a paste carrying SOMEBODY ELSE'S credentials silently JOINED their room.
    `w-MINE` became `w-THEIRS`, my next edit went out under their key, and they
    hold the owner key that can also revoke. That one pre-dates #338; the strip
    is what made the first case common enough to go looking.

One rule fixes both, and it is the line #338 draws itself: a saved FILE
carrying its own capability is the design, and pasted text is not a file. So
the room belongs to the open workbook and a paste replaces content only.
Dropping or opening a shared workbook is untouched and still adopts its room.

DELIBERATELY NOT IN `replaceWorkbook`, though all three paste-ish paths go
through it: "Duplicate as new workbook" mints fresh credentials on purpose, and
folding the rule in there would make a fork keep its ancestor's room — the
opposite of what it is for. Both wrong shapes are negative-controlled.

And it SAYS SO. A live workbook shows a line above the paste box explaining
what is kept. Silence is what made both directions bugs: the session ending and
the session moving are equally invisible, and this app states limits rather
than leaving them to be discovered.
dash has TWO buttons labelled "Copy document JSON". #338 fixed About's. This is
the other one — on the screen shown when a file cannot be parsed — and it was
missed by the fix AND by the rig, because it copies the raw embedded block
rather than a stringified document, so a check looking for a document reaching
a clipboard could not see it.

It also PRINTED that block on screen, which is the worse half: an error screen
is the thing people screenshot and send to somebody, and the page invites it in
so many words — "You can take the contents out below."

Which files land here matters, and I had it wrong before checking. A NEWER dash
version does not refuse: format additivity means it opens. What refuses is a
wrong `format` string — a slides deck, a space, a hand-edited file — and that
block is perfectly good JSON with a live read key, writer key and owner key in
it. Measured across all three refusal shapes:

  format  (wrong format string)   stripped, no keys, data kept
  shape   (valid JSON, not a doc) stripped, no keys, data kept
  json    (truncated)             not stripped, keys present, WARNING shown

The last one is deliberate. There is nothing to strip safely from text that is
not JSON, and this screen exists to get somebody's data out of a file dash
cannot open — so the raw text stands and the page says plainly that the keys are
still in it and to be careful where it goes. "Save an untouched copy" sits
beside it and is byte-exact either way, which is why stripping the other two
costs nothing.

One stripper, not two: `docForExport` is the same function About uses, and it
removes rather than allow-lists, so a credential field added later is covered
here as well without anyone remembering.

Three negative controls, each red: clipboard back to raw, screen back to raw,
warning removed. The warning takes `--accent-ink`/`--accent-wash` rather than a
bare colour — an undeclared token drew nothing for a week once, and the theme
rig is what catches that.
It was one table of eight sales rows. Honest, and it taught nothing: a stranger
saw a grid, concluded "a worse Excel", and had no way to find out otherwise.
This is what bento.page shows and what every downloaded copy opens with, so it
is the one document in the repo a stranger is guaranteed to see, and everything
dash does that a spreadsheet does not was invisible in it.

It opens with ONE SHEET OF EACH KIND, because the difference is the lesson.

  · Pipeline — a DATASET. A COLUMN FORMULA (`value * prob`, one expression for
    the column rather than =D2*F2 filled down eight times and falling out of
    step on the next insert), a totals row that is a column PROPERTY so it
    cannot fall out of range, a validation list on Stage, a conditional format,
    and the provenance step that answers "where did this come from?".
  · Scratch — a SPREADSHEET. Labels beside numbers, `=SUM(` under the numbers
    where a hand puts it, and `=SUM(Pipeline!D1:D8)` reading ACROSS into the
    dataset — which is what makes the two kinds one workbook rather than two
    apps.

TEACHING BY DOING. Every feature is doing a job somebody would want done. Chart,
pivot, dashboard, story, 3D and SQL are deliberately absent: each would make it
a demo reel, and a starter has to survive being edited by somebody who does not
care what we were trying to show them. 2,739 bytes.

TWO THINGS FOUND BY BUILDING IT, which is the argument for a starter that uses
the app rather than illustrating it:

  · At a 60,000 target the weighted pipeline already beat it and the sheet read
    "Gap to target −£17,245" — arithmetically right, reads as a mistake, in the
    only ten seconds this document gets. The target is 100,000.
  · A WRAPPED CELL IN A TALL ROW HID MOST OF ITS TEXT. The grid sets
    line-height to the row height so one line sits centred; on a wrapped cell
    that becomes the height of every line, and the sentence measured 165px of
    content in a 55px box with lines two and three simply not on screen. It did
    not look broken — the first line rendered and stopped mid-sentence, which
    reads as text that was too long. Row heights predate wrapping, each is
    correct alone, and they had never met.

`scripts/test-dash-starter.ts`, 31 checks, registered in CI. The one that will
actually catch something: the Scratch sums address the dataset by POSITION, so
reordering a column over there silently re-points two totals on another sheet
and nothing about it is visible in a diff. The addresses are pinned by column
NAME and every total is computed through the shipping code and compared with
arithmetic done in the rig. Negative-controlled by reordering the columns (4
red), dropping the second sheet (1), restoring the negative gap (1) and
restoring the line-height collision (1).
At 1440x900 the starter workbook's first sheet occupied the top 220px and
the other 594px of the grid area was blank paper — the same white as the
sheet, bleeding to both window edges. Nothing distinguished "the table
ended here" from "the app stopped drawing", and readers took the second
reading. Measured on the shipped starter: the object is 890x220 in an
1440x814 grid area, 16.7% of it; the rest used to be indistinguishable
from the sheet.

The fix is NOT more rows. The frontier decision stands untouched — the
lattice stops at the data, one appender sits below it, and every check in
test-dash-frontier.ts still passes. What changes is that the dataset is
now DRAWN as a thing with an extent:

  • --desk, a new palette token, is the ground the sheet lies on. One rule
    fixes both halves: the desk is one step further from the paper than
    the chrome band is, in whichever direction "further" runs on that
    ground — darker on light, lighter on dark, the same inversion
    --grid-line and --panel already make because at #0e1319 there is no
    room left below the paper (a darker desk measured 1.04:1, invisible).
    MEASURED: 1.13:1 and 1.18:1 against --bg, with --panel at 1.03 and
    1.09 in between, so the three grounds are monotone on both themes.
  • .dg-table shrink-wraps (width: max-content) and draws its RIGHT and
    BOTTOM edges in --line-strong. Those are the two sides where a
    dataset's extent is a claim; the top-left is the grid's origin, where
    the gutter and heading strip pin in every spreadsheet ever shipped and
    where there is no desk to be beside.

REJECTED: banding, or a lattice that fades below the data — any ruled
treatment past the last row draws rows that are not there, which is the
lie the frontier work removed, retold one layer out. And a rounded card:
the corners need overflow:hidden on .dg-table, which makes it the nearest
scrollport, which is what the header and totals row stick against. A
rounded sheet is a sheet whose header does not stick.

THE TWO KINDS NOW LOOK DIFFERENT, deliberately. A spreadsheet's extent is
a frontier, not a fact — it grows with the cursor and the window and
reports its size to ARIA as unknown — so .dg-canvas puts the full bleed
back. A border round a frontier would be a bound the kind does not have,
and ruling to the window edge is honest there. That the dataset is bounded
and the spreadsheet is not is the product's whole argument; this is the
cheapest place to see it.

THE THREE EMPTY STATES now say which kind they are and what to do next,
which is exactly the two facts a reader is missing and the two that differ
between the kinds. A dataset with no rows points at the appender already
on screen; one with no columns explains that the kind is typed by column
and where the + is; a new spreadsheet says it is typed by cell and that
=SUM( works anywhere. In a read-only workbook the invitation is REPLACED
rather than offered — the same rule frontierRow already follows.

The note is ONE node, built once and updated in place: a 5,000-row sheet
still paints 46 rows and one hidden, empty note.

Also fixes a defect found while negative-controlling this work.
test-dash-theme.ts's undeclared-token check scanned the stylesheet WITH
its comments, and its pattern (`--name:` after whitespace) is satisfied by
a sentence as readily as by a declaration. A comment reading "See --desk:
an unbounded sheet…" therefore declared the token, and deleting the real
declaration left the rig green — verified. Comments are stripped first now,
and the control fails as it should.

New rig: scripts/test-dash-surface.ts (58 checks), registered in CI. The
CSS half reads the stylesheet as text and MEASURES the palette rather than
requiring tokens to be present, because a desk 1.02:1 from the paper
satisfies every structural check and renders as the original defect.
The dialog behind the ⓘ button held eight sections — what this file is,
document properties, updates, language, appearance, password, version
history and the JSON round trip. Measured in the running app: 1361px in a
429px viewport, 3.2 screens, and a reader looking for what language the
interface is in scrolled past their own password to find it.

Nothing in it was wrong. They were not one thing. The seam was already in
the codebase: language, theme and the update check follow the READER and
are kept in this browser, never in the document (PLATFORM §8), and
everything else travels in the file.

  · settings.ts — language, appearance, updates, Offline. The reader's.
    Opened by the version chip (a version raises exactly the question
    Settings answers), by About's footer, and by a top-bar button if the
    bar grows one.
  · about.ts — identity, size, docId, properties, version history, the
    document as JSON. The file's. Still the ⓘ button and the wordmark.
  · saveui.ts — the password, beside the other standing instructions about
    how this workbook gets written. It is not a fact about the file and it
    is not a preference of the reader's; rule 3 of that file already made
    every export path reason about encryption.
  · dialog.ts — the modal chrome both surfaces share, so the two
    document-level handlers a dialog must survive (main.ts's bare-key grid
    typing, its CSV paste sniffer) cannot be got right in only one of them.

"Save a copy…" and "Duplicate as new workbook…" left About: the Save menu
offers both already, the first under the identical label — and the two
forks had drifted, the menu's keeping `template: true` so a fork of a
template was another template. One implementation now, `duplicateWorkbook`,
which is the one with a rig on it.

Measured at a 560px card: About 635px (700px with a full version
timeline), Settings 548px. Neither needs more than 800px of viewport;
together they are shorter than the one dialog they replace.

scripts/test-dash-surfaces.ts holds the seam and the size — it mounts both
surfaces and runs them through a box model of about.css calibrated against
that 1361px (it puts the original at 1380px, 1.4% high). Eleven negative
controls, each verified to have applied.
The surface work shrink-wrapped the dataset horizontally — `width: max-content`,
so the table ends where its columns end and the desk begins. Verified in a
browser and the right-hand edge is right.

The VERTICAL half was silently cancelled. `.dg-table { min-height: 100% }` sits
700 lines further down styles.css and still forced the full height of the
scroller, so the object ran to the bottom of the window: MEASURED at 1440x900
with the starter open, `.dg-table` was 761px against 241px of content — 521px of
white paper past the last row, with the "bottom edge" drawn at the foot of the
viewport rather than under the data. `elementsFromPoint` 120px below the totals
returned `dg-table` painting white, not the desk. Half a fix, and from one side
it looked like a whole one.

That rule outlived its reason. Its own comment says it exists so
`paintEmptyGrid`'s lattice can reach the bottom when there are few rows — which
is behaviour the FRONTIER work deliberately removed from the table kind,
because rows that are not there must not be drawn. It is still correct for the
canvas kind, whose grid genuinely does not end, so it is scoped to `.dg-canvas`
rather than deleted.

After: dataset ends 1px past its last row with desk below it; spreadsheet still
fills the scroller. Both measured.

THE RIG DID NOT CATCH IT — 58 checks green with the bug restored. They assert
the declarations the change WROTE, not the outcome, which is the fourth time
this week a suite has been green over something wrong on screen. The new check
walks every rule whose selector names `.dg-table` without `.dg-canvas` and
fails if any sets a minimum height, because `dash-dom.ts` is a parser and a
node tree and cannot measure a rendered box. Restoring the bug now gives 2 red.
The spacing has been reported three times and fixed twice, so this time it
was MEASURED first — in headless Chrome, against the built shell, at every
rule kind of every section, on both sheet kinds, at panel widths 200 through
440. Two things came back that reading the CSS would not have told anyone.

The first is that at the shipped 250px the panel was already in rhythm: one
26px row, one label column, one 8px gutter, everywhere. The earlier report of
94/125/172/185/270px rows was taken with the panel COLLAPSED, where a
container query stacks every row and a width of zero wraps each label one
character per line. That reading measured the instrument.

The second is that slides — the reference this panel is told to copy — is the
ragged one. Measured, `.ed-row` is a space-between flex with no floor: rows of
22, 25, 26 and 27px, pitches of 30/33/34/35, and a checkbox that lands its box
91px right of every other control. So what is copied here is slides' SPACING,
where the numbers are arbitrary and the suite should pick the same arbitrary
value twice — 14px padding, an 18/8 section, an 8px pitch and gutter, a 110px
control, a 6px radius — and not its row, which would import the raggedness.

What was actually wrong, all of it below the row and none of it visible in one
row at a time:

  * Section titles began 14px right of every label under them — the
    disclosure triangle took its room from the text. The padding is cancelled
    now and the triangle hangs back into the panel's own padding, so a header
    starts where its rows start.
  * Buttons were 30.1px against a panel of 26px controls, with a 7px corner
    against a panel of 6px ones. `.dc-preview`, the swatch and the clear
    button read the app's --radius (7px) while every field beside them used 6.
  * A dragged width was not the width taken: `--dp-w: 200px` laid out at
    277.4px and 320px at 303.8px, because a flex item floors at min-content.
    The panel stored a number in localStorage it had never been.
  * Stacked, the panel had THREE row heights — 36.6 read-only, 44.6 field,
    46.6 checkbox — across six pitches. A 2px nudge on the checkbox and an
    18px-tall printed value were the whole of it. Both now take the row's box.
  * ~90 lines styled a sheet list, a left panel and a left-hand chevron that
    left for the tab strip. Measured before deleting: every one of those
    selectors matched zero elements.

Five numbers decide the panel now and nothing restates any of them, so a change
moves every section at once — which was already true of the KIT and is now true
of the stylesheet. No section was bypassing the KIT; the drift was all in CSS
that had grown a second spelling of a shared measurement.

scripts/test-dash-panelrhythm.ts pins it: the numbers, declared once and equal
to these values; a refusal of any px literal where a token is the answer, in
all four panel stylesheets; and every section mounted — both kinds, every
validation and conditional-formatting kind, from the modules' own rule
factories — asserting the markup is KIT parts only and every row is exactly a
label and a control. 100 checks. All 18 negative controls fail it, including
two that first passed and exposed real holes: an allowlist that forgave a whole
selector rather than one property, and an inline-style check reading an
attribute the DOM shim never writes.

Nothing here changes what a control does. Verified after: one row height in
every state, light and dark, and the collapse chevron still shuts to 0, stays
on top of its drawer and reopens.
…file

The About dialog held everything — identity, properties, updates, language,
appearance, password, version history and the JSON round trip. Eight sections,
1361px in a 429px viewport, 3.2 screens of scroll. Nothing in it was wrong; they
were not one thing, and somebody hunting for the language picker scrolled past
their own password to find it.

The split follows a seam the codebase already had, which is why it is a line
rather than a taste: language, theme and the update check follow the READER and
live in this browser (PLATFORM §8); everything else travels IN THE FILE. The
password is neither — it is a standing instruction about how every save from now
on is written, which is what every other item in the Save menu already is, so it
went there beside "Save as template…" where the relationship between them (the
template of an encrypted workbook is encrypted) is visible instead of being a
paragraph in another dialog.

MEASURED in a browser at 1440x900, after: Settings 529px and About 628px, both
fitting the viewport with no scroll — 0.79 screens on an 800px laptop against
3.2 before, and the two together shorter than the one dialog they replace.

It also removed a real duplication rather than moving it: About's "Save a copy…"
was the Save menu's item under an identical label, and "Duplicate as new
workbook…" was its "Save as new workbook…" — and the two spellings had already
DRIFTED, the menu's fork keeping `template: true`, so forking a template gave
another template. One implementation now, the one with a rig on it.

The topbar line it could not make itself, made here. Adding a `data-act` is not
free and the rigs said so twice, correctly: `test-dash-actions` refused an action
with no row in the ACTIONS applicability table, then refused again because its
hand-written matrix — deliberately hand-written, an independent statement of
intent rather than something derived from the source — did not mention it. Both
now do: `settings: { on: 'workbook' }`, because none of language, appearance or
the update check is a fact about a sheet, so no sheet kind can make them
inapplicable.
Found by the panel-rhythm audit while walking every section, and it is a crash
rather than a blemish. `rule.colors[2]` on a `colorScale` with no `colors` threw
out of `buildCondFmtSection`, so the properties panel went blank for the SHEET —
not for the rule, for everything.

Reproduced before fixing, through the real builder:

  colorScale WITH colors     OK
  colorScale with NO colors  THROWS  Cannot read properties of undefined (2)

`blankCondFmtRule` always writes the array, so nothing this app creates can
reach it — which is exactly why it survived this long. The format is additive
and PLATFORM §7 makes hand-edited and model-generated JSON a first-class way in,
so "we always write it" is not the same as "it is always there". And the panel
is where somebody would go to REPAIR such a rule, which makes losing the panel
the worst available answer to a malformed one.

Three shapes pinned — no `colors`, `colors: null`, a one-entry array — and the
control restores 2 red.

(My first probe reported BOTH cases throwing, which would have made the finding
look bigger than it was: I had the calling convention wrong and was measuring my
own harness. The signature takes one ctx object.)
The worst finding of the polish sweep, and it is about control over your own
data rather than about looks. Driven at 880px with a session running: `collab.on`
true, the room live on `wss://sync.bento.page`, and the string "Stop sharing"
NOWHERE in the document. The only trace of a live session was an 8px green dot.

`sync.css` hid `.dx-people-toggle` outright below 900px to buy width in the top
bar, and that toggle is the only control that stops sharing. Unlike slides,
which demotes crowded controls into `⋯`, dash simply removed it. The nearest
remaining escape was About's "Offline mode — block every network feature", so
stopping one workbook being shared meant switching off the app's networking,
including the signed update check.

THE RULE IS NARROW ON PURPOSE. A control that REPORTS something may stand down
at a narrow width — that ladder exists for a reason, and the 390px measurement
behind these rules (the chip cost 20px the bar did not have and pushed About off
the edge) is real and still honoured. A control that STOPS SOMETHING ALREADY
HAPPENING may not. So every collapse is scoped `:not(.dx-live)`, and `.dx-live`
marks the chip exactly while there is a session to stop — on the host as well as
the button, because the 700px rule hides the whole chip and a class on a child
cannot save a parent that is `display: none`.

Guarded by walking every width query in sync.css that hides part of the chip and
failing any that does not exempt a live session. The first version of that check
was too broad and flagged `.dx-people-title` — the word "Live" beside the dot,
which is decoration and whose collapse is the ladder working correctly. Narrowed
to the toggle and the chip that contains it. Control confirmed red.
`.gitignore` was the only conflict: main renamed `tray/` to `home/`, this branch
had added the `.claude/worktrees/` rule. Both kept.

Then `test-ci-registered.ts` went red on two rigs of main's — which is the whole
point of that guard, and the first time it has caught somebody other than me.

  · `scripts/test-doc-index.mjs` was unregistered AND BROKEN. It imports
    `../tray/doc-index.mjs` and reads `tray/fixtures`, both of which moved in
    the rename; on origin/main today it dies in module resolution. It is the
    shared corpus the Kotlin and Swift hosts run the same cases against, so a
    silent break here is three implementations drifting with nothing to say so.
    Repointed at `home/` — the exports it wants are all there, unchanged — and
    it passes 11 cases. `gen-doc-index-expected.mjs`, which regenerates its
    fixtures, was broken the same way and is fixed with it.
  · `scripts/test-spaces.mjs` was merely unregistered: it passes 90/90 and then
    exits 2 wanting `slides/node_modules` to bundle its second half. Registered
    inside the spaces job, where those are already installed.

I crossed out of dash to do this. The alternative was two more entries on
test-ci-registered's exemption list, which is a list that is supposed to shrink
— and the doc-index one is a REPAIR of something already broken on main rather
than a new opinion about someone else's code. Two other files still say `tray/`
(`test-tray-index.mjs`, `test-dash-theme.ts`); both are prose in comments, both
run, and both are left alone.

64/64 dash rigs, tsc clean, test-ci-registered 247/247.
I repaired scripts/test-doc-index.mjs and registered two non-dash rigs in
ci.yml. Both were right, and both were the OPS zone's — dash's brief says
plainly that scripts/ and .github/ are not mine — and PR #399 does the same
work properly. Two sessions editing ci.yml is what made #323 CONFLICTING and
blocked it behind #399.

So the repair and the registrations are withdrawn: test-doc-index.mjs and
gen-doc-index-expected.mjs go back to main's bytes, and the two steps come out
of ci.yml. #399 owns them.

One correction to the board while I am here, verified rather than argued: the
repair was NOT 'never pushed and in no branch'. It is on
origin/worktree-bento-dash — #323's own head — as 64bb4a8, and reads
'../home/doc-index.mjs' there today. Checking main would show it missing, which
is likely what happened. It existing was never the problem; it existing in a
DASH pr was.

The exemption list grows by two, so the mechanism that keeps it honest grows
too. An entry whose rig no longer exists already failed. Now an entry whose rig
IS registered fails as well — the hazard of this list has always been that
entries outlive their reason, so the reason is checked rather than trusted, and
these two expire the moment #399 lands. Control confirmed red.

248/248.
One conflict, in ci.yml, and it was a union rather than a choice. Mine is the
dash release-channel rehearsal; main's is the shell-size step, which its own
comment says was deliberately moved to sit AFTER all three builds because the
rig skips a shell that is not built — while it sat in the spaces section it
silently measured nothing for dash. Both kept, main's placement respected.

Verified every `run: node scripts/...` in the merged file still points at a
file that exists, since a union merge of a workflow is exactly where a step
survives with a path that does not.
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

Build size

main (7422f4f) → worktree-bento-dash (43b3b6b)

app base PR change
bento/slides 669.3 KiB 669.8 KiB +0.5 KiB (+0.08%)
bento/spaces 267.6 KiB 268.1 KiB +0.5 KiB (+0.20%)
bento/dash 161.3 KiB 423.0 KiB +261.7 KiB (+162.21%)

Updated: 2026-08-30T19:26:02Z

Two things in one push, because splitting them turns `main` red in between.

FILTER.TS, AGAINST #316. Git reported `UU` with ZERO conflict markers — a
BINARY conflict, because the file carried a literal NUL byte and git had been
treating it as binary, which is the very defect #316 landed to fix. So there was
nothing to read: the sides had to be compared by hand.

#316's whole change to this file is ONE LINE — a literal NUL inside the blank
sentinel replaced by its escape. Verified by forcing the diff through `cat -v`,
and verified as the ONLY commit touching filter.ts on main since this branch
diverged. This branch had already made the identical change: the filter work hit
the same binary-diff problem from the other side and fixed it the same way. Line
147 is byte-identical on both sides and neither carries a literal NUL any more.
Ours is a strict superset — the same fix plus 73 lines of filter work — so it is
resolved to ours on that evidence rather than on "ours usually wins", and the
file is UTF-8 text again instead of binary.

NOT_RUN IS EMPTY. #399 (48945fb) registered all three exempted rigs, so all
three reasons expired at once and the rig failed by design: 288/291, exactly
those three, with failure text naming the fix. Deleted together with the comment
blocks that justified them, because a reason for an entry that no longer exists
is the same rot the list warns about. 288/288 now.

Verified after: tsc clean, 64/64 dash rigs, build:single builds, shell-gate
passes, and test-doc-index.mjs — which this branch briefly and wrongly repaired
inside ops' zone — passes on main's version of it. test-spaces.mjs needs
slides/node_modules, which CI has and this checkout does not.

I did not rebase. This branch is a PR head with 100+ commits and merge commits;
replaying it would rewrite published history and force-push under an open PR to
avoid one three-line resolution. Merged instead. Say if the integrator wants a
true rebase and I will do it deliberately rather than as a side effect.
b829dae says "and empty NOT_RUN" and does not. The three entries are still in
it; this commit is the deletion, and the correction to that message.

HOW IT HAPPENED, because the mechanism is the interesting part. The call that
was to stage both files and commit them was REJECTED WHOLE by the harness — the
commit message contained a control character, which is a fitting way to fail a
commit about NUL bytes. The rejection took the `git add` with it. My next call
ran only `git commit -F <file>`, which committed what was already staged from an
earlier call: filter.ts, but not the rig. So a tool-call rejection silently
un-did a staging step, and the commit that followed used stale staging.

WHAT MADE IT INVISIBLE is worth more than the fix. I verified 288/288 — the
number I had predicted — and reported it. The measurement was true of my working
tree and false of the pushed ref, and nothing I ran could tell the two apart.
That is the same shape as a rig passing against a corpus it is not reading,
which this session has now hit in four different costumes: verify the artifact
that ships, not the one under your hand.

So this is verified against the PUSHED ref rather than the working tree: fetch
origin/worktree-bento-dash, merge-tree it onto main, build that tree, run the
rig there.

Not amended. b829dae is published under an open PR, and I argued against a
force-push to save a three-line resolution an hour ago; the same reasoning holds
for a wrong sentence in a message. The record reads better as a claim followed
by its correction than as a history quietly rewritten to have never been wrong.
@nyblnet
nyblnet merged commit 7be9ab4 into main Aug 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant