oficina / sheets Public
Sheets — the next three
Status: revision 2, 2026-09-03. §Review (spec): revision 1 reviewed by Codex / GPT-5.6-sol, a different family from the author — verdict REQUEST CHANGES, eleven findings. Every one is answered in §7, including the four where revision 1 was simply wrong. Revision 2 needs a fresh pass before it is approved. §Review (branch): the implementation was reviewed by the same model — verdict REQUEST CHANGES, eight findings, all answered. Six were fixed in the branch; the four that are grammar changes, migrations or new subsystems became rows in §7 with triggers.
Written after an independent brainstorm by two models (docs/superpowers/brainstorms/2026-09-03-{gpt,fable}.md), briefed identically and separately and told not to hedge toward consensus. They agreed on exactly one item and disagreed on everything else.
This is a slice spec, not a redesign. Everything here sits under the 2026-08-30 design; where the two disagree, that one wins and this one is wrong.
---
0. What changed in revision 2
The review was right about the two things that mattered most, and both were the same mistake in different places — taking a claim from the brainstorm and writing it down without checking it against the code.
- Revision 1 asked Paul to rule on insert/delete semantics. §4.2 already rules on them ("Insert / delete row or col — No rewrite. Ids are stable. This is the case stable ids buy."). Asking again is exactly the drive-by relitigation
AGENTS.mdforbids. Removed. - Revision 1 called tabs "moderate" because the storage is tab-keyed. The storage is; the product is not. Twenty-one HTML routes exist and not one carries a tab id: every screen resolves the first tab through
SheetSummary.TabIDoropenTabFor. Re-priced in §5 as the subsystem it is, and moved out of this slice.
The three that ship are now §2, §3 and §4. Tabs is §5, specced and priced and not built, because a subsystem shipped in a slice is how §13's warning gets ignored for the fourth time.
---
1. Settled by this document
| # | Decision | Rejected alternative | Who / when | |---|---|---|---| | 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 | | 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 | | 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 | | 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 | | E | A form expresses "off" explicitly, never by omission. | Reading an absent checkbox as false | This session, 2026-09-03 — see §4.2 | | F | Tabs are specced and not built in this slice. | Shipping add/switch against twenty-one first-tab routes | This session, 2026-09-03 |
---
2. The grid's window reaches the sheet — BUILT
2.1 The bug
The grid page opened a constant 100×26 window. The cell route accepts a write below row 5,000 (inCellRange, ten times the request maximum), the table view lists whatever rows exist, and the export carries all of them. So a cell at row 151 returned 200, appeared in the table view and in the .xlsx, and had no row on the grid to scroll to — with no copy anywhere admitting a window existed.
Measured in Chromium, 2026-09-03. It is invisibility, not loss: the first reading of this bug was that the cell was unreachable, and that was wrong — the table view lists populated rows and the API returns it for a wider window. Recorded because the difference decides its severity, and because the corrected version is the one that goes in a commit message.
2.2 What shipped
extent() reports one past the furthest cell that exists. windowFor() adds a screenful of room to keep typing into, floors at the default so a one-cell sheet still opens a workable grid, and ceilings at the route's own maximum — a window wider than the server will serve paints rows that scroll and never fill.
Past the ceiling, data-window-truncated on the surface and a sentence in the status line. note.js is the status line's own module: the 300-line cap caught it being written inside clipboard.js, which is how a shared thing acquires two copies and two timers that clear each other.
Four tests, each seen failing first, including the one that catches clamp being misused for this — clamp's def is what a request of zero gets, so a sheet with one cell asked for a one-row grid and clamp returned one row.
2.3 Why it is first
The review argued this belongs ahead of new features and it is right: the app's principal screen could not show data the app itself had accepted, and every other item here is an addition rather than a correction.
---
3. Function coverage
3.1 What is there
The engine registers 32 callable names across three dispatch tables — 22 ordinary Register calls, 2 clock registrations (NOW, TODAY), and 8 node registrations (the aggregates and lookups):
ABS AND AVERAGE CONCAT COUNT COUNTA IF IFERROR IFS INDEX LEFT LEN LOWER
MATCH MAX MID MIN MOD NOT NOW OR POWER RIGHT ROUND ROUNDDOWN ROUNDUP SQRT
SUM TODAY TRIM UPPER VLOOKUP§5 of the design says "~120 functions to start". xlsx.Import preserves a formula's input and writes it through our engine, so a workbook using a name we do not register imports faithfully and then shows #NAME? in every cell that used one.
3.2 What is claimed, and what is not
Claimed: the gap is real, the functions below are genuinely absent, and an imported workbook using one of them evaluates to #NAME?. All three verified against the registration sites and xlsx/import.go.
Not claimed: that these are the most common functions in real workbooks. Revision 1 said conditional aggregates were "the largest cause of #NAME? in an imported workbook" and there is no evidence for it — tools/gencorpus holds a hand-maintained case list, not an inventory of anything anybody imported. Decision B: this is a judgment-based starter set, named here rather than left to the implementation, and the honest ranking waits for §6's inventory.
3.3 The list, named here and not deferred
Shipped — fifteen, taking the engine from 32 to 47:
- Conditional aggregates —
SUMIF,COUNTIF,AVERAGEIF,SUMIFS,COUNTIFS,AVERAGEIFS. - Date parts —
YEAR,MONTH,DAY,DATE,EOMONTH. - Predicates —
ISBLANK,ISNUMBER,ISTEXT,ISERROR.
Not shipped, and named so the list is honest rather than aspirational: VALUE, SUBSTITUTE, FIND and SEARCH were in revision 2's list and are not in the branch. They are the next four and need no ruling; they were cut for time, not for a reason. A spec that claims four functions the code does not have is the same failure as a menu item that does nothing.
TEXT is out, and this is a change from revision 1. Its argument is a number-format grammar; shipping a TEXT that handles four patterns and silently mangles the fifth is the quiet wrong PRODUCT.md forbids, and §6 gives it a trigger with a threshold rather than a wish. If it ever ships it must call internal/sheets/format, never grow a second formatter — two implementations of one truth is the bug class ruling 2 exists to prevent.
DATEDIF is out for the same reason: its unit argument has documented Excel quirks the corpus must settle before we copy them.
3.4 Cost and risk
internal/sheets/calc only. No migration, no route, no template, no client code, no ruling. Register panics on a duplicate at init.
The risk is agreeing with ourselves: a function tested against expectations we wrote matches our reading of Excel, not Excel. Decision C is the whole mitigation, and a function whose corpus vectors cannot be generated does not ship.
3.5 First slice
The six conditional aggregates. A sheet that computes SUMIF and does not know EOMONTH is coherent.
---
4. Formatting in the table view
4.1 The gap, stated exactly
Revision 1 said the table view has "nothing", and the review was right that this is false. What is true:
- The table already computes each display string through the cell's stored number format, and right-aligns numeric computed values.
- It already surfaces imported fill/ink pairs that fail the contrast floor (§11.1).
- It does not paint bold, italic, fill, ink or explicit alignment.
- It offers no control for any format or size. The only per-cell form edits
raw.
§14 records this as debt in its own words — the scriptless path is the product, not the fallback, "and an affordance in the grid and not in the table view means the table view is unfinished" — with the trigger "the next slice that touches the table view". This slice touches it.
Scope: controls and visual parity, both. A control that sets bold on a screen that cannot show bold is half a feature and the worse half.
4.2 The form protocol, designed rather than assumed
Revision 1 said POST .../format is "scriptless-ready" and the work is "form decoding". The review is right that this is false as written: handleFormatPost decodes JSON only and always answers with a JSON grid payload. Three things have to be designed, not assumed.
Content-type dispatch. One route, two decoders, converging on one formatPatch before validate, normalize or maxFormatCells runs. JSON callers keep the JSON response; form callers get 303 to a server-built return URL. The target is derived by the server from the sheet and page, never taken from the request — a caller-supplied return is an open redirect, and this repo has already shipped one fix in that family (fix/safe-return-backslash).
Decision E: "off" is explicit. formatPatch distinguishes absent ("leave unchanged") from false ("clear it") by pointer presence, and an unchecked checkbox is simply absent from a form submission — so a naive form can never turn bold off. Each toggle posts a tri-state value (set / clear / omitted) rather than a checkbox. This is the one place the two decoders' inputs genuinely differ, and it is why they must converge on the patch and not on the wire format.
Tab addressing. The format route is tab-addressed; the table page and its redirects are sheet-addressed and resolve the first tab. Until §5 lands, the form's action is built server-side from the same first-tab resolution the page already used, so this slice adds no new assumption — it inherits one, and §5 removes it everywhere at once.
4.3 Cost and risk
templates/table.html, handlers_table.go, and the decoder split in handlers_format.go. No migration. No ruling.
The risk is the two surfaces drifting. Decision D is load-bearing rather than tidy-minded: if the form path reimplements the allowlist, the drift is invisible until somebody formats in one view and reads in the other. Second risk: a submitted rectangle large enough to evade maxFormatCells — the bound applies after both decoders converge, never in either decoder.
4.4 First slice
One cell, one patch, one form, round-tripping identically in both views, with the table painting what it sets. Range formatting and sizing follow.
---
5. Tabs — specced, priced, not built in this slice
5.1 Why it moved out
Both models put tabs in their top three and it was revision 1's first pick. The review re-priced it and the re-pricing holds.
The storage is tab-keyed: the tabs table exists, cells, history, comments, saved views and the live hub are all keyed on tab id, and the cell/format/size API routes are tab-addressed with openTab checking the tab against the sheet before checking the grant.
The product is not. Twenty-one HTML routes exist and none carries a tab id. Every screen resolves the first tab — SheetSummary.TabID is documented as "the first tab", summarize selects by position, and openTabFor opens that one id. The first-tab assumption is baked into the grid page, the table view's read and write, comments, history and restore, export, import, saved views and the link view.
So tabs is a page, navigation, redirect, export, history, comment and authorisation change across the whole app. That is §13's "subsystem wearing a feature's name", and shipping it inside a slice with two other things is how that warning gets ignored again.
5.2 What it needs, when it is built
- A migration. Revision 1 promised none. The
tabstable has only a plain index onsheet_id— no uniqueness on(sheet_id, position)— whilerowsin the very same migration carriesUNIQUE INDEX (tab_id, position). The asymmetry is the tell. Once add and reorder exist, duplicate positions make document order fall out of an id tie-break. Additive unique index, plus an allocation rule. - Tab-addressed HTML routes, and a decision per screen about whether it becomes tab-addressed or stays sheet-addressed with a documented first-tab default. Twenty-one of them.
- Sealed names throughout (§2.1). Explicitly: add and rename errors are fixed text or codes and never wrap an error carrying the submitted name; URLs carry ids only; raw-SQLite and bucket coverage extends to renamed and additional tab names.
- Delete is a separate question and stays in §6.
5.3 The authorisation claim, narrowed
Revision 1 said openTab checks the pairing "on every request". True for the cell, format and size routes. The SSE stream does not go through openTab — it repeats the pair check and the read authorisation itself, so it is not a bypass today, but "every request" is not mechanically true and a route-coverage test is what would make it so.
---
6. Open — Paul's to rule
Revision 1 listed four. Two of them were not decisions at all and the review was right to say so.
- Deleting a tab. What happens to a non-empty one, and to the last one. Genuinely open.
- CSV import and export. Two semantic rulings before a line is written: does an imported leading
=become a formula or literal text (the spreadsheet-injection question), and does export write raw inputs or displayed values.
- A workbook inventory, or not. §3.2 declines to claim measured coverage because nothing measures it. Building a content-safe inventory — function names counted across imported workbooks, never a formula or a cell reaching a log — is a small piece of work that would make the next function slice evidence-led. It is a question because it touches sealed content handling, which is not mine to extend unasked.
Removed from this list, with reasons
- Insert and delete a row or a column. §4.2 rules it: no rewrite, ids are stable, "this is the case stable ids buy". There is no product choice left — there is unbuilt subsystem work, because
eval.go'srefKeystill maps positions to synthetic ids and its own comment leaves stable-id binding to later. That is an implementation gap against a settled design, and §14 already names its trigger. - The grid ignoring the saved sort and filter. Not a ruling. The shared contract is already shipped and already stated on screen — the table view tells you the view changes what everybody sees. Making it personal would need new per-person storage and would contradict what users have been told. So the grid not applying it is a bug, and it should be fixed as one. (Revision 1 also said
loadViewis called "from nowhere else"; it has one non-test caller and three test call sites. The narrower statement is the true one.)
---
7. Deliberately not built yet
| Thing | Trigger | |---|---| | Tabs (§5) | Its own slice, with the migration and the twenty-one route decisions priced in. Nothing else should ride along with it. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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). MAXCELLS 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. | | #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. | | 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/fndate_parts.go's own comment. Trigger: **a sheet whose dates predate 1900-03-01**. | | **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. | | **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. | | **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. | | **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. |