| 1 | # Sheets — the next three |
| 2 | |
| 3 | **Status:** revision 2, 2026-09-03. |
| 4 | **§Review (spec):** revision 1 reviewed by **Codex / GPT-5.6-sol**, a different |
| 5 | family from the author — verdict **REQUEST CHANGES**, eleven findings. |
| 6 | Every one is answered in §7, including the four where revision 1 was |
| 7 | simply wrong. Revision 2 needs a fresh pass before it is approved. |
| 8 | **§Review (branch):** the implementation was reviewed by the same model |
| 9 | — verdict **REQUEST CHANGES**, eight findings, all answered. Six were |
| 10 | fixed in the branch; the four that are grammar changes, migrations or |
| 11 | new subsystems became rows in §7 with triggers. |
| 12 | |
| 13 | Written after an independent brainstorm by two models |
| 14 | (`docs/superpowers/brainstorms/2026-09-03-{gpt,fable}.md`), briefed |
| 15 | identically and separately and told not to hedge toward consensus. They |
| 16 | agreed on exactly one item and disagreed on everything else. |
| 17 | |
| 18 | This is a slice spec, not a redesign. Everything here sits under the |
| 19 | 2026-08-30 design; where the two disagree, that one wins and this one is |
| 20 | wrong. |
| 21 | |
| 22 | --- |
| 23 | |
| 24 | ## 0. What changed in revision 2 |
| 25 | |
| 26 | The review was right about the two things that mattered most, and both |
| 27 | were the same mistake in different places — **taking a claim from the |
| 28 | brainstorm and writing it down without checking it against the code.** |
| 29 | |
| 30 | - Revision 1 asked Paul to rule on insert/delete semantics. **§4.2 |
| 31 | already rules on them** ("Insert / delete row or col — No rewrite. Ids |
| 32 | are stable. **This is the case stable ids buy.**"). Asking again is |
| 33 | exactly the drive-by relitigation `AGENTS.md` forbids. Removed. |
| 34 | - Revision 1 called tabs "moderate" because the storage is tab-keyed. |
| 35 | The storage is; **the product is not.** Twenty-one HTML routes exist |
| 36 | and not one carries a tab id: every screen resolves the first tab |
| 37 | through `SheetSummary.TabID` or `openTabFor`. Re-priced in §5 as the |
| 38 | subsystem it is, and moved out of this slice. |
| 39 | |
| 40 | The three that ship are now §2, §3 and §4. Tabs is §5, specced and |
| 41 | priced and **not built**, because a subsystem shipped in a slice is how |
| 42 | §13's warning gets ignored for the fourth time. |
| 43 | |
| 44 | --- |
| 45 | |
| 46 | ## 1. Settled by this document |
| 47 | |
| 48 | | # | Decision | Rejected alternative | Who / when | |
| 49 | |---|---|---|---| |
| 50 | | A | **The grid's window is sized to the sheet, not to a constant.** Default as a floor, the cells route's own maximum as the ceiling, and words when the sheet runs past it. | Copy alone ("rows past 100 are in the table view"), which discloses the problem without fixing it | This session, 2026-09-03 | |
| 51 | | B | **New functions are a judgment-based starter set, and the spec names them.** No claim is made that they are the most common in real workbooks, because no inventory of real workbooks exists. | Claiming measured coverage without measuring; leaving the list to the implementation | This session, 2026-09-03 — revision 1 claimed both and the review was right to reject it | |
| 52 | | C | **Every new function's expected values come from LibreOffice headless**, through `tools/gencorpus`, never from our reading of a spec. | Hand-written expectations | Restates 2026-08-30 §5 | |
| 53 | | D | **One format route, two decoders, converging on one `formatPatch` before any validation runs.** | A second form-only endpoint beside the JSON one | This session, 2026-09-03 | |
| 54 | | E | **A form expresses "off" explicitly, never by omission.** | Reading an absent checkbox as `false` | This session, 2026-09-03 — see §4.2 | |
| 55 | | F | **Tabs are specced and not built in this slice.** | Shipping add/switch against twenty-one first-tab routes | This session, 2026-09-03 | |
| 56 | |
| 57 | --- |
| 58 | |
| 59 | ## 2. The grid's window reaches the sheet — **BUILT** |
| 60 | |
| 61 | ### 2.1 The bug |
| 62 | |
| 63 | The grid page opened a constant 100×26 window. The cell route accepts a |
| 64 | write below row 5,000 (`inCellRange`, ten times the request maximum), |
| 65 | the table view lists whatever rows exist, and the export carries all of |
| 66 | them. So a cell at row 151 returned 200, appeared in the table view and |
| 67 | in the .xlsx, and had no row on the grid to scroll to — with no copy |
| 68 | anywhere admitting a window existed. |
| 69 | |
| 70 | Measured in Chromium, 2026-09-03. It is **invisibility, not loss**: the |
| 71 | first reading of this bug was that the cell was unreachable, and that |
| 72 | was wrong — the table view lists populated rows and the API returns it |
| 73 | for a wider window. Recorded because the difference decides its |
| 74 | severity, and because the corrected version is the one that goes in a |
| 75 | commit message. |
| 76 | |
| 77 | ### 2.2 What shipped |
| 78 | |
| 79 | `extent()` reports one past the furthest cell that exists. |
| 80 | `windowFor()` adds a screenful of room to keep typing into, floors at |
| 81 | the default so a one-cell sheet still opens a workable grid, and |
| 82 | ceilings at the route's own maximum — a window wider than the server |
| 83 | will serve paints rows that scroll and never fill. |
| 84 | |
| 85 | Past the ceiling, `data-window-truncated` on the surface and a sentence |
| 86 | in the status line. `note.js` is the status line's own module: the |
| 87 | 300-line cap caught it being written inside `clipboard.js`, which is how |
| 88 | a shared thing acquires two copies and two timers that clear each other. |
| 89 | |
| 90 | Four tests, each seen failing first, including the one that catches |
| 91 | `clamp` being misused for this — `clamp`'s `def` is what a request of |
| 92 | **zero** gets, so a sheet with one cell asked for a one-row grid and |
| 93 | clamp returned one row. |
| 94 | |
| 95 | ### 2.3 Why it is first |
| 96 | |
| 97 | The review argued this belongs ahead of new features and it is right: |
| 98 | the app's principal screen could not show data the app itself had |
| 99 | accepted, and every other item here is an addition rather than a |
| 100 | correction. |
| 101 | |
| 102 | --- |
| 103 | |
| 104 | ## 3. Function coverage |
| 105 | |
| 106 | ### 3.1 What is there |
| 107 | |
| 108 | The engine registers **32** callable names across three dispatch tables |
| 109 | — 22 ordinary `Register` calls, 2 clock registrations (`NOW`, `TODAY`), |
| 110 | and 8 node registrations (the aggregates and lookups): |
| 111 | |
| 112 | ``` |
| 113 | ABS AND AVERAGE CONCAT COUNT COUNTA IF IFERROR IFS INDEX LEFT LEN LOWER |
| 114 | MATCH MAX MID MIN MOD NOT NOW OR POWER RIGHT ROUND ROUNDDOWN ROUNDUP SQRT |
| 115 | SUM TODAY TRIM UPPER VLOOKUP |
| 116 | ``` |
| 117 | |
| 118 | §5 of the design says "~120 functions to start". `xlsx.Import` |
| 119 | preserves a formula's input and writes it through our engine, so a |
| 120 | workbook using a name we do not register imports faithfully and then |
| 121 | shows `#NAME?` in every cell that used one. |
| 122 | |
| 123 | ### 3.2 What is claimed, and what is not |
| 124 | |
| 125 | **Claimed:** the gap is real, the functions below are genuinely absent, |
| 126 | and an imported workbook using one of them evaluates to `#NAME?`. All |
| 127 | three verified against the registration sites and `xlsx/import.go`. |
| 128 | |
| 129 | **Not claimed:** that these are the most common functions in real |
| 130 | workbooks. Revision 1 said conditional aggregates were "the largest |
| 131 | cause of `#NAME?` in an imported workbook" and there is no evidence for |
| 132 | it — `tools/gencorpus` holds a hand-maintained case list, not an |
| 133 | inventory of anything anybody imported. Decision B: this is a |
| 134 | judgment-based starter set, named here rather than left to the |
| 135 | implementation, and the honest ranking waits for §6's inventory. |
| 136 | |
| 137 | ### 3.3 The list, named here and not deferred |
| 138 | |
| 139 | **Shipped — fifteen**, taking the engine from 32 to 47: |
| 140 | |
| 141 | - **Conditional aggregates** — `SUMIF`, `COUNTIF`, `AVERAGEIF`, |
| 142 | `SUMIFS`, `COUNTIFS`, `AVERAGEIFS`. |
| 143 | - **Date parts** — `YEAR`, `MONTH`, `DAY`, `DATE`, `EOMONTH`. |
| 144 | - **Predicates** — `ISBLANK`, `ISNUMBER`, `ISTEXT`, `ISERROR`. |
| 145 | |
| 146 | **Not shipped, and named so the list is honest rather than |
| 147 | aspirational:** `VALUE`, `SUBSTITUTE`, `FIND` and `SEARCH` were in |
| 148 | revision 2's list and are not in the branch. They are the next four and |
| 149 | need no ruling; they were cut for time, not for a reason. A spec that |
| 150 | claims four functions the code does not have is the same failure as a |
| 151 | menu item that does nothing. |
| 152 | |
| 153 | `TEXT` is **out**, and this is a change from revision 1. Its argument is |
| 154 | a number-format grammar; shipping a `TEXT` that handles four patterns |
| 155 | and silently mangles the fifth is the quiet wrong PRODUCT.md forbids, |
| 156 | and §6 gives it a trigger with a threshold rather than a wish. If it |
| 157 | ever ships it must call `internal/sheets/format`, never grow a second |
| 158 | formatter — two implementations of one truth is the bug class ruling 2 |
| 159 | exists to prevent. |
| 160 | |
| 161 | `DATEDIF` is out for the same reason: its unit argument has documented |
| 162 | Excel quirks the corpus must settle before we copy them. |
| 163 | |
| 164 | ### 3.4 Cost and risk |
| 165 | |
| 166 | `internal/sheets/calc` only. No migration, no route, no template, no |
| 167 | client code, no ruling. `Register` panics on a duplicate at init. |
| 168 | |
| 169 | The risk is agreeing with ourselves: a function tested against |
| 170 | expectations we wrote matches our reading of Excel, not Excel. Decision |
| 171 | C is the whole mitigation, and a function whose corpus vectors cannot be |
| 172 | generated does not ship. |
| 173 | |
| 174 | ### 3.5 First slice |
| 175 | |
| 176 | The six conditional aggregates. A sheet that computes `SUMIF` and does |
| 177 | not know `EOMONTH` is coherent. |
| 178 | |
| 179 | --- |
| 180 | |
| 181 | ## 4. Formatting in the table view |
| 182 | |
| 183 | ### 4.1 The gap, stated exactly |
| 184 | |
| 185 | Revision 1 said the table view has "nothing", and the review was right |
| 186 | that this is false. What is true: |
| 187 | |
| 188 | - The table **already** computes each display string through the cell's |
| 189 | stored number format, and right-aligns numeric computed values. |
| 190 | - It **already** surfaces imported fill/ink pairs that fail the contrast |
| 191 | floor (§11.1). |
| 192 | - It does **not** paint bold, italic, fill, ink or explicit alignment. |
| 193 | - It offers **no control** for any format or size. The only per-cell |
| 194 | form edits `raw`. |
| 195 | |
| 196 | §14 records this as debt in its own words — the scriptless path is the |
| 197 | product, not the fallback, "and an affordance in the grid and not in the |
| 198 | table view means the table view is unfinished" — with the trigger "the |
| 199 | next slice that touches the table view". This slice touches it. |
| 200 | |
| 201 | **Scope: controls and visual parity, both.** A control that sets bold on |
| 202 | a screen that cannot show bold is half a feature and the worse half. |
| 203 | |
| 204 | ### 4.2 The form protocol, designed rather than assumed |
| 205 | |
| 206 | Revision 1 said `POST .../format` is "scriptless-ready" and the work is |
| 207 | "form decoding". The review is right that this is false as written: |
| 208 | `handleFormatPost` decodes JSON only and always answers with a JSON grid |
| 209 | payload. Three things have to be designed, not assumed. |
| 210 | |
| 211 | **Content-type dispatch.** One route, two decoders, converging on one |
| 212 | `formatPatch` *before* `validate`, `normalize` or `maxFormatCells` runs. |
| 213 | JSON callers keep the JSON response; form callers get **303** to a |
| 214 | server-built return URL. The target is derived by the server from the |
| 215 | sheet and page, never taken from the request — a caller-supplied return |
| 216 | is an open redirect, and this repo has already shipped one fix in that |
| 217 | family (`fix/safe-return-backslash`). |
| 218 | |
| 219 | **Decision E: "off" is explicit.** `formatPatch` distinguishes absent |
| 220 | ("leave unchanged") from `false` ("clear it") by pointer presence, and |
| 221 | an unchecked checkbox is simply absent from a form submission — so a |
| 222 | naive form can never turn bold *off*. Each toggle posts a tri-state |
| 223 | value (`set` / `clear` / omitted) rather than a checkbox. This is the |
| 224 | one place the two decoders' inputs genuinely differ, and it is why they |
| 225 | must converge on the patch and not on the wire format. |
| 226 | |
| 227 | **Tab addressing.** The format route is tab-addressed; the table page |
| 228 | and its redirects are sheet-addressed and resolve the first tab. Until |
| 229 | §5 lands, the form's action is built server-side from the same first-tab |
| 230 | resolution the page already used, so this slice adds no new assumption |
| 231 | — it inherits one, and §5 removes it everywhere at once. |
| 232 | |
| 233 | ### 4.3 Cost and risk |
| 234 | |
| 235 | `templates/table.html`, `handlers_table.go`, and the decoder split in |
| 236 | `handlers_format.go`. No migration. No ruling. |
| 237 | |
| 238 | The risk is the two surfaces drifting. Decision D is load-bearing rather |
| 239 | than tidy-minded: if the form path reimplements the allowlist, the drift |
| 240 | is invisible until somebody formats in one view and reads in the other. |
| 241 | Second risk: a submitted rectangle large enough to evade |
| 242 | `maxFormatCells` — the bound applies after both decoders converge, never |
| 243 | in either decoder. |
| 244 | |
| 245 | ### 4.4 First slice |
| 246 | |
| 247 | One cell, one patch, one form, round-tripping identically in both views, |
| 248 | with the table painting what it sets. Range formatting and sizing |
| 249 | follow. |
| 250 | |
| 251 | --- |
| 252 | |
| 253 | ## 5. Tabs — specced, priced, **not built in this slice** |
| 254 | |
| 255 | ### 5.1 Why it moved out |
| 256 | |
| 257 | Both models put tabs in their top three and it was revision 1's first |
| 258 | pick. The review re-priced it and the re-pricing holds. |
| 259 | |
| 260 | The **storage** is tab-keyed: the `tabs` table exists, cells, history, |
| 261 | comments, saved views and the live hub are all keyed on tab id, and the |
| 262 | cell/format/size API routes are tab-addressed with `openTab` checking |
| 263 | the tab against the sheet before checking the grant. |
| 264 | |
| 265 | The **product** is not. Twenty-one HTML routes exist and **none carries |
| 266 | a tab id**. Every screen resolves the first tab — `SheetSummary.TabID` |
| 267 | is documented as "the first tab", `summarize` selects by position, and |
| 268 | `openTabFor` opens that one id. The first-tab assumption is baked into |
| 269 | the grid page, the table view's read and write, comments, history and |
| 270 | restore, export, import, saved views and the link view. |
| 271 | |
| 272 | So tabs is a page, navigation, redirect, export, history, comment and |
| 273 | authorisation change across the whole app. That is §13's "subsystem |
| 274 | wearing a feature's name", and shipping it inside a slice with two other |
| 275 | things is how that warning gets ignored again. |
| 276 | |
| 277 | ### 5.2 What it needs, when it is built |
| 278 | |
| 279 | - **A migration.** Revision 1 promised none. The `tabs` table has only a |
| 280 | plain index on `sheet_id` — no uniqueness on `(sheet_id, position)` — |
| 281 | while `rows` in the very same migration carries |
| 282 | `UNIQUE INDEX (tab_id, position)`. The asymmetry is the tell. Once add |
| 283 | and reorder exist, duplicate positions make document order fall out of |
| 284 | an id tie-break. Additive unique index, plus an allocation rule. |
| 285 | - **Tab-addressed HTML routes**, and a decision per screen about whether |
| 286 | it becomes tab-addressed or stays sheet-addressed with a documented |
| 287 | first-tab default. Twenty-one of them. |
| 288 | - **Sealed names throughout** (§2.1). Explicitly: add and rename errors |
| 289 | are fixed text or codes and never wrap an error carrying the submitted |
| 290 | name; URLs carry ids only; raw-SQLite and bucket coverage extends to |
| 291 | renamed and additional tab names. |
| 292 | - **Delete is a separate question** and stays in §6. |
| 293 | |
| 294 | ### 5.3 The authorisation claim, narrowed |
| 295 | |
| 296 | Revision 1 said `openTab` checks the pairing "on every request". True |
| 297 | for the cell, format and size routes. The SSE stream does **not** go |
| 298 | through `openTab` — it repeats the pair check and the read authorisation |
| 299 | itself, so it is not a bypass today, but "every request" is not |
| 300 | mechanically true and a route-coverage test is what would make it so. |
| 301 | |
| 302 | --- |
| 303 | |
| 304 | ## 6. Open — Paul's to rule |
| 305 | |
| 306 | Revision 1 listed four. Two of them were not decisions at all and the |
| 307 | review was right to say so. |
| 308 | |
| 309 | 1. **Deleting a tab.** What happens to a non-empty one, and to the last |
| 310 | one. Genuinely open. |
| 311 | |
| 312 | 2. **CSV import and export.** Two semantic rulings before a line is |
| 313 | written: does an imported leading `=` become a formula or literal |
| 314 | text (the spreadsheet-injection question), and does export write raw |
| 315 | inputs or displayed values. |
| 316 | |
| 317 | 3. **A workbook inventory, or not.** §3.2 declines to claim measured |
| 318 | coverage because nothing measures it. Building a content-safe |
| 319 | inventory — function *names* counted across imported workbooks, never |
| 320 | a formula or a cell reaching a log — is a small piece of work that |
| 321 | would make the next function slice evidence-led. It is a question |
| 322 | because it touches sealed content handling, which is not mine to |
| 323 | extend unasked. |
| 324 | |
| 325 | ### Removed from this list, with reasons |
| 326 | |
| 327 | - **Insert and delete a row or a column.** §4.2 rules it: no rewrite, |
| 328 | ids are stable, "this is the case stable ids buy". There is no product |
| 329 | choice left — there is unbuilt subsystem work, because `eval.go`'s |
| 330 | `refKey` still maps positions to synthetic ids and its own comment |
| 331 | leaves stable-id binding to later. That is an implementation gap |
| 332 | against a settled design, and §14 already names its trigger. |
| 333 | - **The grid ignoring the saved sort and filter.** Not a ruling. The |
| 334 | shared contract is already shipped and already stated on screen — |
| 335 | the table view tells you the view changes what everybody sees. Making |
| 336 | it personal would need new per-person storage and would contradict |
| 337 | what users have been told. So the grid not applying it is a **bug**, |
| 338 | and it should be fixed as one. (Revision 1 also said `loadView` is |
| 339 | called "from nowhere else"; it has one non-test caller and three test |
| 340 | call sites. The narrower statement is the true one.) |
| 341 | |
| 342 | --- |
| 343 | |
| 344 | ## 7. Deliberately not built yet |
| 345 | |
| 346 | | Thing | Trigger | |
| 347 | |---|---| |
| 348 | | Tabs (§5) | Its own slice, with the migration and the twenty-one route decisions priced in. Nothing else should ride along with it. | |
| 349 | | Multi-worksheet import | §14's existing trigger — "a second tab is reachable in the UI" — fires when §5 ships, and not before. Two subsystems in one slice is the thing §13 warns about. | |
| 350 | | Cross-tab references (`Sheet2!A1`) | **Corrected:** revision 1 said an id-keyed graph is already tab-agnostic. It is not — `eval.go` builds `CellKey{rowPos+1, colPos+1}` from POSITIONS, which is §14's existing warning. Trigger: the key space rebuilt on stable ids. | |
| 351 | | Per-tab permissions | **Corrected:** "somebody asks" is observable but not sufficient — one request would force a second access model beside the grants join. Trigger: a confirmed case needing different access to two tabs of one sheet, written down with who needed it. | |
| 352 | | `TEXT`, `DATEDIF` | A corpus run covering the format patterns and unit arguments actually present in imported workbooks, at a stated coverage threshold — which needs §6's inventory first. Circular without it, which is what revision 1's version was. | |
| 353 | | The grid applying the saved view | A bug, not a deferral. Listed here only so it is not lost: it belongs in the next fix slice. | |
| 354 | | **A batch cell route** | Every paste is one POST per cell, written strictly in series. Serial is what makes the responses arrive in version order, which is what stops grid.js discarding a paint — the alternative, concurrent writes plus a repairing refetch, opened a worse hole (a snapshot in flight overwrites a newer live edit, because `refetch` zeroes the version). MAX_CELLS is 500 because that is what serial can do without a progress bar this app has nowhere to put. Trigger: **somebody pastes more than 500 cells**, or a paste is measurably slow on the live instance. | |
| 355 | | **`#REF!` as a value in the formula AST** | `translateCopied` replaces a whole formula with the literal text `#REF!` when any reference lands off the grid. Excel keeps the rest — `=SUM(#REF!:$A2)+C1` — and stores an error VALUE, not text. Both need `#REF!` to exist as a node the parser accepts, the renderer emits in place, and the evaluator answers `ErrRef` for. That is a grammar change, which is why it is here rather than in the branch that found it. Trigger: **the next slice that touches calc's grammar**, or a report of a copy losing a formula. | |
| 356 | | **Excel's 1900 leap-year fiction** | Excel's serial 60 is 1900-02-29, a day that did not exist. This engine counts real days from 1899-12-30, so it agrees with Excel from 1900-03-01 onward and is one out below it. Fixing it moves every stored date in every sheet — a migration and a ruling, not a patch. Named in `calc/fn_date_parts.go`'s own comment. Trigger: **a sheet whose dates predate 1900-03-01**. | |
| 357 | | **The criteria matcher's last two edges** | Measured against LibreOffice 26.8 by the branch review and NOT fixed: `COUNTIF(range,"")` should count a cell holding a formula result of `""` as blank (ours counts only a truly empty cell), and a TEXT criterion like `"10"` should match a text cell holding `10` before it is coerced to a number (ours coerces first and then refuses the text). Both need support cells the shared corpus table does not have — an `=""` cell and a numeric-looking text cell — and adding them changes the fixture every other case is written against. Trigger: **the next slice that touches gencorpus's support table**, which the remaining text functions will. | |
| 358 | | **Copy provenance across tabs** | `lastCopy` lives in one page's memory, so a formula copied in one tab and pasted in another is pasted verbatim rather than translated. Same root as the MIME-type row below and fixed by the same thing. | |
| 359 | | **Clipboard provenance as a MIME type** | A pasted formula is translated only when the clipboard text exactly matches what this tab last copied, and `lastCopy` is now forgotten on window blur. That narrows the window without closing it: the same text copied from another application while the tab still has focus is still claimed. The real fix is a custom clipboard type carrying the origin, which is what Google Sheets does. Trigger: **a report of a paste rewriting a formula it should not have**, or the next slice that touches the clipboard. | |
| 360 | | **Rows past the grid's ceiling in any editing surface** | Beyond `maxWindowRows` the data is in the export and in NEITHER editing screen — the table view paginates to the same ceiling and its write route refuses past it. The truncation notice now says so honestly rather than sending people to a screen that cannot show them the row. Trigger: **a sheet that actually reaches it**, which import can produce today. | |
| 361 | |