oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

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
5family from the author — verdict **REQUEST CHANGES**, eleven findings.
6Every one is answered in §7, including the four where revision 1 was
7simply 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
10fixed in the branch; the four that are grammar changes, migrations or
11new subsystems became rows in §7 with triggers.
12
13Written after an independent brainstorm by two models
14(`docs/superpowers/brainstorms/2026-09-03-{gpt,fable}.md`), briefed
15identically and separately and told not to hedge toward consensus. They
16agreed on exactly one item and disagreed on everything else.
17
18This is a slice spec, not a redesign. Everything here sits under the
192026-08-30 design; where the two disagree, that one wins and this one is
20wrong.
21
22---
23
24## 0. What changed in revision 2
25
26The review was right about the two things that mattered most, and both
27were the same mistake in different places — **taking a claim from the
28brainstorm 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
40The three that ship are now §2, §3 and §4. Tabs is §5, specced and
41priced 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
63The grid page opened a constant 100×26 window. The cell route accepts a
64write below row 5,000 (`inCellRange`, ten times the request maximum),
65the table view lists whatever rows exist, and the export carries all of
66them. So a cell at row 151 returned 200, appeared in the table view and
67in the .xlsx, and had no row on the grid to scroll to — with no copy
68anywhere admitting a window existed.
69
70Measured in Chromium, 2026-09-03. It is **invisibility, not loss**: the
71first reading of this bug was that the cell was unreachable, and that
72was wrong — the table view lists populated rows and the API returns it
73for a wider window. Recorded because the difference decides its
74severity, and because the corrected version is the one that goes in a
75commit 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
81the default so a one-cell sheet still opens a workable grid, and
82ceilings at the route's own maximum — a window wider than the server
83will serve paints rows that scroll and never fill.
84
85Past the ceiling, `data-window-truncated` on the surface and a sentence
86in the status line. `note.js` is the status line's own module: the
87300-line cap caught it being written inside `clipboard.js`, which is how
88a shared thing acquires two copies and two timers that clear each other.
89
90Four 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
93clamp returned one row.
94
95### 2.3 Why it is first
96
97The review argued this belongs ahead of new features and it is right:
98the app's principal screen could not show data the app itself had
99accepted, and every other item here is an addition rather than a
100correction.
101
102---
103
104## 3. Function coverage
105
106### 3.1 What is there
107
108The engine registers **32** callable names across three dispatch tables
109— 22 ordinary `Register` calls, 2 clock registrations (`NOW`, `TODAY`),
110and 8 node registrations (the aggregates and lookups):
111
112```
113ABS AND AVERAGE CONCAT COUNT COUNTA IF IFERROR IFS INDEX LEFT LEN LOWER
114MATCH MAX MID MIN MOD NOT NOW OR POWER RIGHT ROUND ROUNDDOWN ROUNDUP SQRT
115SUM TODAY TRIM UPPER VLOOKUP
116```
117
118§5 of the design says "~120 functions to start". `xlsx.Import`
119preserves a formula's input and writes it through our engine, so a
120workbook using a name we do not register imports faithfully and then
121shows `#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,
126and an imported workbook using one of them evaluates to `#NAME?`. All
127three verified against the registration sites and `xlsx/import.go`.
128
129**Not claimed:** that these are the most common functions in real
130workbooks. Revision 1 said conditional aggregates were "the largest
131cause of `#NAME?` in an imported workbook" and there is no evidence for
132it — `tools/gencorpus` holds a hand-maintained case list, not an
133inventory of anything anybody imported. Decision B: this is a
134judgment-based starter set, named here rather than left to the
135implementation, 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
147aspirational:** `VALUE`, `SUBSTITUTE`, `FIND` and `SEARCH` were in
148revision 2's list and are not in the branch. They are the next four and
149need no ruling; they were cut for time, not for a reason. A spec that
150claims four functions the code does not have is the same failure as a
151menu item that does nothing.
152
153`TEXT` is **out**, and this is a change from revision 1. Its argument is
154a number-format grammar; shipping a `TEXT` that handles four patterns
155and silently mangles the fifth is the quiet wrong PRODUCT.md forbids,
156and §6 gives it a trigger with a threshold rather than a wish. If it
157ever ships it must call `internal/sheets/format`, never grow a second
158formatter — two implementations of one truth is the bug class ruling 2
159exists to prevent.
160
161`DATEDIF` is out for the same reason: its unit argument has documented
162Excel 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
167client code, no ruling. `Register` panics on a duplicate at init.
168
169The risk is agreeing with ourselves: a function tested against
170expectations we wrote matches our reading of Excel, not Excel. Decision
171C is the whole mitigation, and a function whose corpus vectors cannot be
172generated does not ship.
173
174### 3.5 First slice
175
176The six conditional aggregates. A sheet that computes `SUMIF` and does
177not know `EOMONTH` is coherent.
178
179---
180
181## 4. Formatting in the table view
182
183### 4.1 The gap, stated exactly
184
185Revision 1 said the table view has "nothing", and the review was right
186that 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
197product, not the fallback, "and an affordance in the grid and not in the
198table view means the table view is unfinished" — with the trigger "the
199next slice that touches the table view". This slice touches it.
200
201**Scope: controls and visual parity, both.** A control that sets bold on
202a 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
206Revision 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
209payload. 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.
213JSON callers keep the JSON response; form callers get **303** to a
214server-built return URL. The target is derived by the server from the
215sheet and page, never taken from the request — a caller-supplied return
216is an open redirect, and this repo has already shipped one fix in that
217family (`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
221an unchecked checkbox is simply absent from a form submission — so a
222naive form can never turn bold *off*. Each toggle posts a tri-state
223value (`set` / `clear` / omitted) rather than a checkbox. This is the
224one place the two decoders' inputs genuinely differ, and it is why they
225must converge on the patch and not on the wire format.
226
227**Tab addressing.** The format route is tab-addressed; the table page
228and 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
230resolution 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
238The risk is the two surfaces drifting. Decision D is load-bearing rather
239than tidy-minded: if the form path reimplements the allowlist, the drift
240is invisible until somebody formats in one view and reads in the other.
241Second risk: a submitted rectangle large enough to evade
242`maxFormatCells` — the bound applies after both decoders converge, never
243in either decoder.
244
245### 4.4 First slice
246
247One cell, one patch, one form, round-tripping identically in both views,
248with the table painting what it sets. Range formatting and sizing
249follow.
250
251---
252
253## 5. Tabs — specced, priced, **not built in this slice**
254
255### 5.1 Why it moved out
256
257Both models put tabs in their top three and it was revision 1's first
258pick. The review re-priced it and the re-pricing holds.
259
260The **storage** is tab-keyed: the `tabs` table exists, cells, history,
261comments, saved views and the live hub are all keyed on tab id, and the
262cell/format/size API routes are tab-addressed with `openTab` checking
263the tab against the sheet before checking the grant.
264
265The **product** is not. Twenty-one HTML routes exist and **none carries
266a tab id**. Every screen resolves the first tab — `SheetSummary.TabID`
267is documented as "the first tab", `summarize` selects by position, and
268`openTabFor` opens that one id. The first-tab assumption is baked into
269the grid page, the table view's read and write, comments, history and
270restore, export, import, saved views and the link view.
271
272So tabs is a page, navigation, redirect, export, history, comment and
273authorisation change across the whole app. That is §13's "subsystem
274wearing a feature's name", and shipping it inside a slice with two other
275things 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
296Revision 1 said `openTab` checks the pairing "on every request". True
297for the cell, format and size routes. The SSE stream does **not** go
298through `openTab` — it repeats the pair check and the read authorisation
299itself, so it is not a bypass today, but "every request" is not
300mechanically true and a route-coverage test is what would make it so.
301
302---
303
304## 6. Open — Paul's to rule
305
306Revision 1 listed four. Two of them were not decisions at all and the
307review was right to say so.
308
3091. **Deleting a tab.** What happens to a non-empty one, and to the last
310 one. Genuinely open.
311
3122. **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
3173. **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