# 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.md` forbids. 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.TabID` or `openTabFor`. 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 `tabs` table has only a
  plain index on `sheet_id` — no uniqueness on `(sheet_id, position)` —
  while `rows` in the very same migration carries
  `UNIQUE 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.

1. **Deleting a tab.** What happens to a non-empty one, and to the last
   one. Genuinely open.

2. **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.

3. **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`'s
  `refKey` still 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 `loadView` is
  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). 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. |
| **`#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/fn_date_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. |
