oficina / sheets Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Sheets Grid and Live Sync Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use
4> superpowers:subagent-driven-development (recommended) or
5> superpowers:executing-plans to implement this plan task-by-task. Steps use
6> checkbox (`- [ ]`) syntax for tracking.
7
8**Goal:** A spreadsheet you can open in a browser, type into, and watch
9update in another browser tab within a second — on a deployed canary, not
10localhost.
11
12**Architecture:** The engine from plan 1 stays exactly where it is. This
13plan adds the three layers above it: a **view layer** (embedded Go
14templates over rastrillo's tokens and partials, theme resolved server-side
15before first paint), a **grid surface** (vendored Jspreadsheet CE painting
16strings the server computed, never formulas the client evaluated), and a
17**live layer** (an in-process SSE hub fanning each committed edit out to
18every other open tab, carrying the cell version so a client can discard an
19out-of-order paint). Every write goes through one server path —
20`store.Handle.SetReport` — so the scriptless form POST and the grid's
21fetch produce identical events and identical fan-out.
22
23**Tech Stack:** Go 1.26, `github.com/carlosframework/rastrillo` v0.22.0
24(`ui` for tokens/layouts/partials, `auth` for sessions, `csrf`), Jspreadsheet
25CE v5.0.2 + jsuites v5 vendored as static assets (MIT, verified in the
26package's own LICENSE), `html/template`. No bundler, no CDN, no build step.
27
28**Spec:** `docs/superpowers/specs/2026-08-30-sheets-design.md` — §6 (the
29grid, and where zero-JS stops), §7 (collaboration), §10 (design system),
30§11.1 (a retained fill can be unreadable), §14 (the deferred table this plan
31triggers one row of).
32
33---
34
35## Global Constraints
36
37Copied from `AGENTS.md` and the spec. Every task's requirements include
38these.
39
40- **The one rule:** *"A sheet's contents are sealed at rest. The bucket never
41 holds a plaintext cell."* Nothing this plan adds writes a cell value
42 anywhere but through `seal.Sealer`. Titles and tab names are sealed too.
43- **No cell content, comment text, sheet title or tab name in any log line,
44 error string, job status message or metric label.** This plan adds an SSE
45 layer whose natural debug logging would violate this. Log tab ids and
46 counts, never payloads.
47- **`CGO_ENABLED=0` throughout.**
48- **Migrations are additive-only.** This plan adds one table (`members`);
49 it alters nothing.
50- **Never `scope.Owned` or `scope.OwnedBy`.** Sheets are team-shared. Until
51 §8's grants land, access is instance-wide: every address in
52 `SHEETS_OWNERS` sees every sheet. That is a *stated* scope, written in
53 the code and tested (Task 3), not an oversight — see "Assumptions" below.
54- **Cell values are IEEE doubles end to end.**
55- **200,000 cells per tab, hard.** The grid paints a window, never the tab.
56- **300-line cap per file, .go and .js alike.** The vendored Jspreadsheet
57 and jsuites blobs are the only exemptions and are named as such in
58 `lineCapExempt` / `jsLineCapExempt`.
59- **Write the attribute grammar** (`rst-btn="primary compact"`), never the
60 class spelling, and **adopt per screen**. Utilities stay classes.
61- **Light/dark is per person and must resolve before first paint.** Read it
62 synchronously from a cookie, server-side. Never a fetch.
63- **Every drag-only affordance has a menu equivalent.**
64- **No form in the app is script-required.**
65- **Review on a deployed canary, never localhost.** `carlos deploy`, never
66 `carlos ship`. `make release`, never a bare `go build`. Verify with
67 `GET /api/version` — the process, not the edge — and poll, because a
68 promote is not a deploy.
69
70## Assumptions, stated because they are mine and not Paul's
71
72Written down so they are cheap to overturn rather than discovered later.
73
741. **"Live updates across tabs" means browser tabs.** Two windows on the
75 same sheet; type in one, see it in the other. It also happens to work
76 across spreadsheet tabs because the fan-out key is the tab id.
772. **Sharing is not in this plan.** The instance allowlist is the team.
78 Spec §13 sequences sharing (6) before live sync (7), but sharing is a
79 subsystem — grants, roles, link sharing, the home contract — and none of
80 it is needed to make a grid live. Doing it here would have swapped a
81 working spreadsheet for a half-finished sharing model.
823. **Presence is not in this plan.** §7 pairs presence with LWW; who's-here
83 is a second, independent SSE message type and adds nothing to
84 correctness. Deferred with a trigger: *the first time two people report
85 colliding on a cell.*
864. **Undo is not in this plan.** §7's undo is own-edits-only over the event
87 log, and the event log already records what it needs. Deferred with a
88 trigger: *the grid is in daily use by anyone.*
895. **The grid paints a fixed window** — 100 rows × 26 columns, extendable by
90 an explicit button — rather than virtualising. §6's cap reasoning is
91 about not rendering 200,000 cells; a window is the smallest thing that
92 honours it.
93
94---
95
96## File Structure
97
98```
99internal/sheets/
100 members.go Address -> actor id. The only place a Member row is made.
101 render.go Template parsing, PageData, theme-before-first-paint.
102 theme.go The theme cookie: read, write, and the POST handler.
103 sheetsvc.go Create/list/open a Sheet and its Tab. Sealed titles.
104 handlers_sheets.go GET /sheets, POST /sheets, GET /sheets/{id}.
105 handlers_grid.go The grid JSON API: GET cells, POST cell.
106 handlers_table.go The scriptless table view and its per-cell edit form.
107 handlers_stream.go GET .../stream — the SSE endpoint. Transport only.
108 keys.go SHEETS_DEK -> *seal.Sealer, refusing at boot.
109 templates/
110 layout.html The one layout. rastrillo `column`, attribute grammar.
111 index.html Root: signed-in state and a way to the sheet list.
112 signin.html Moved off page.go's string constants.
113 sheets.html The sheet list, plus "new sheet". Scriptless.
114 sheet.html The grid page. <noscript> points at the table view.
115 table.html The scriptless, paginated table with edit forms.
116 static/
117 tokens.css Copied by `rastrillo doctor`. Frozen deliberately.
118 sheets.css Our own grid chrome. Tokens only, no raw colours.
119 grid.js Our grid module. <= 300 lines, enforced.
120 live.js SSE client: subscribe, apply-by-version, heartbeat.
121 vendor/
122 jspreadsheet.js Vendored v5.0.2. Exempt from the cap, named as such.
123 jspreadsheet.css
124 jsuites.js
125 jsuites.css
126 live/
127 hub.go The fan-out hub. No HTTP, no templates, no store.
128 hub_test.go
129 store/
130 handle_report.go SetReport: Set, but it tells you what changed.
131```
132
133Rationale for the splits: the HTTP layer is split by surface rather than by
134verb, because the scriptless table view and the JSON grid API are the two
135things most likely to drift apart, and keeping them in separate files with a
136shared service (`sheetsvc.go`) makes the drift visible. `live/` has no
137dependency on `store` or `net/http` at all — it moves opaque messages — so
138its tests are pure and fast, and the fan-out logic can be reasoned about
139without a database.
140
141---
142
143### Task 1: The DEK, and refusing to boot without it
144
145Nothing in the app builds a `*seal.Sealer` yet — plan 1's tests each made
146their own. A grid cannot store a cell without one. Spec §2.1: *"The DEK
147arrives as platform config (`carlos secrets`), never stored in the
148database."*
149
150**Files:**
151- Create: `internal/sheets/keys.go`, `internal/sheets/keys_test.go`
152- Modify: `cmd/sheets/main.go`, `internal/sheets/app.go`
153
154**Interfaces:**
155- Consumes: `seal.New(dek []byte) (*seal.Sealer, error)`.
156- Produces: `sheets.LoadSealer() (*seal.Sealer, error)`, and `App` gains a
157 `sealer *seal.Sealer` field set by `New`.
158
159- [ ] **Step 1: Write the failing test**
160
161Create `internal/sheets/keys_test.go`:
162
163```go
164package sheets
165
166import (
167 "encoding/base64"
168 "strings"
169 "testing"
170)
171
172func TestLoadSealerRefusesAMissingDEK(t *testing.T) {
173 t.Setenv("SHEETS_DEK", "")
174 if _, err := LoadSealer(); err == nil {
175 t.Fatal("LoadSealer with no SHEETS_DEK = nil error, want a refusal")
176 } else if !strings.Contains(err.Error(), "SHEETS_DEK") {
177 t.Fatalf("error %q does not name the variable an operator must set", err)
178 }
179}
180
181func TestLoadSealerRefusesAShortDEK(t *testing.T) {
182 t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 16)))
183 if _, err := LoadSealer(); err == nil {
184 t.Fatal("LoadSealer with a 16-byte DEK = nil error, want a refusal")
185 }
186}
187
188func TestLoadSealerAcceptsA32ByteDEK(t *testing.T) {
189 t.Setenv("SHEETS_DEK", base64.StdEncoding.EncodeToString(make([]byte, 32)))
190 s, err := LoadSealer()
191 if err != nil {
192 t.Fatalf("LoadSealer: %v", err)
193 }
194 if s == nil {
195 t.Fatal("LoadSealer returned a nil sealer and a nil error")
196 }
197}
198
199// The DEK is a live key. A refusal that echoes it puts it in platform-held
200// log storage we cannot grep (AGENTS.md / Mechanical rules).
201func TestLoadSealerNeverEchoesTheKey(t *testing.T) {
202 const secret = "c2VjcmV0LXNlY3JldC1zZWNyZXQtc2VjcmV0LTEyMw=="
203 t.Setenv("SHEETS_DEK", secret)
204 _, err := LoadSealer()
205 if err != nil && strings.Contains(err.Error(), secret) {
206 t.Fatalf("error text contains the DEK itself: %q", err)
207 }
208}
209```
210
211- [ ] **Step 2: Run it and watch it fail**
212
213Run: `go test ./internal/sheets/ -run TestLoadSealer -v`
214Expected: FAIL — `undefined: LoadSealer`.
215
216- [ ] **Step 3: Write the implementation**
217
218Create `internal/sheets/keys.go`:
219
220```go
221package sheets
222
223import (
224 "encoding/base64"
225 "errors"
226 "fmt"
227 "os"
228 "strings"
229
230 "amadan.net/oficina/sheets/internal/sheets/seal"
231)
232
233// dekBytes is the DEK's required length. seal.New enforces it too; this
234// check exists so the refusal names SHEETS_DEK and the length an
235// operator has to produce, rather than surfacing a framework message
236// about a byte slice.
237const dekBytes = 32
238
239// LoadSealer reads the data encryption key from platform config and
240// builds the app's one Sealer.
241//
242// The DEK arrives as platform config (`carlos secrets`), never stored in
243// the database (spec 2.1). A missing one is a hard refusal rather than a
244// generated key: an instance that invents a DEK at boot seals this run's
245// cells with a key the next run will not have, which reads as data loss
246// and cannot be undone.
247//
248// No error here ever includes the value read from the environment. It is
249// a live key and `carlos logs` is platform-held storage we cannot grep.
250func LoadSealer() (*seal.Sealer, error) {
251 raw := strings.TrimSpace(os.Getenv("SHEETS_DEK"))
252 if raw == "" {
253 return nil, errors.New(
254 "sealing is not configured: SHEETS_DEK not set. " +
255 "Generate one with `head -c 32 /dev/urandom | base64` and set it " +
256 "with `carlos secrets set --app sheets SHEETS_DEK=...`")
257 }
258 dek, err := base64.StdEncoding.DecodeString(raw)
259 if err != nil {
260 return nil, errors.New("sealing is not configured: SHEETS_DEK is not valid base64")
261 }
262 if len(dek) != dekBytes {
263 return nil, fmt.Errorf(
264 "sealing is not configured: SHEETS_DEK decodes to %d bytes, want %d",
265 len(dek), dekBytes)
266 }
267 s, err := seal.New(dek)
268 if err != nil {
269 return nil, fmt.Errorf("sealing is not configured: %w", err)
270 }
271 return s, nil
272}
273```
274
275- [ ] **Step 4: Thread it into App and main**
276
277In `internal/sheets/app.go`, add the field and parameter:
278
279```go
280type App struct {
281 db *gorm.DB
282 auth *auth.Auth
283 sealer *seal.Sealer
284 admit func(string) bool
285}
286
287// New builds an App. db and sealer may be nil ONLY in tests that do not
288// touch storage; every route that reads or writes a cell checks and 500s
289// rather than panicking, which TestGridWithoutASealerIs500 pins.
290func New(db *gorm.DB, a *auth.Auth, sealer *seal.Sealer, admit func(string) bool) *App {
291 return &App{db: db, auth: a, sealer: sealer, admit: admit}
292}
293```
294
295Update every existing `New(...)` call site — `app_test.go`, `page_test.go`,
296`auth_test.go`, `begin_test.go`, `signin_test.go` — to pass `nil` for the
297new parameter.
298
299In `cmd/sheets/main.go`, after `LoadAuthEnv` and before `sheets.New`:
300
301```go
302 // Same shape as the sign-in refusals above and for the same reason:
303 // an instance with no DEK answers /healthz perfectly well and then
304 // fails on the first cell anybody types.
305 sealer, err := sheets.LoadSealer()
306 if err != nil {
307 logger.Error("sealing", "err", err)
308 os.Exit(1)
309 }
310
311 app := sheets.New(d.G, signin, sealer, sheets.Admits(authEnv.Owners))
312```
313
314- [ ] **Step 5: Run the tests**
315
316Run: `make test-vet test-build test-go`
317Expected: PASS.
318
319- [ ] **Step 6: Commit**
320
321```bash
322git add internal/sheets/keys.go internal/sheets/keys_test.go \
323 internal/sheets/app.go cmd/sheets/main.go internal/sheets/*_test.go
324git commit -m "feat: the DEK arrives as platform config, or the instance refuses to boot"
325```
326
327---
328
329### Task 2: Members — an address becomes an actor id
330
331`Cell.UpdatedBy`, `Sheet.CreatedBy` and `Event.ActorID` are `uint64`. The
332session carries an email address. Something has to map one to the other, and
333`sessions.UserID` cannot (AGENTS.md: it returns `(0, false)` for an email
334Subject). Spec §2.2 puts membership and readable addresses in the plaintext
335column, so this table is not sealed.
336
337**Files:**
338- Modify: `internal/sheets/models.go`, `internal/sheets/migrations.go`
339- Create: `internal/sheets/migrations/0002_create_members.sql`
340- Create: `internal/sheets/members.go`, `internal/sheets/members_test.go`
341
342**Interfaces:**
343- Consumes: `App.db`, `App.identity(r)` from `page.go`.
344- Produces: `type Member struct{ ID uint64; Address string; CreatedAt time.Time }`,
345 and `func (a *App) actor(r *http.Request) (Member, bool)`.
346
347- [ ] **Step 1: Write the failing test**
348
349Create `internal/sheets/members_test.go`:
350
351```go
352package sheets
353
354import "testing"
355
356func TestMemberForIsIdempotent(t *testing.T) {
357 app := newTestApp(t) // helper from app_test.go; see Step 3
358 a, err := app.memberFor("Ada@example.com")
359 if err != nil {
360 t.Fatalf("memberFor: %v", err)
361 }
362 if a.ID == 0 {
363 t.Fatal("memberFor returned id 0, which is the uid-0 trap AGENTS.md names")
364 }
365 b, err := app.memberFor("ada@example.com")
366 if err != nil {
367 t.Fatalf("memberFor again: %v", err)
368 }
369 if b.ID != a.ID {
370 t.Fatalf("memberFor is case-sensitive: %d then %d", a.ID, b.ID)
371 }
372}
373
374func TestMemberForRejectsAnEmptyAddress(t *testing.T) {
375 app := newTestApp(t)
376 if _, err := app.memberFor(""); err == nil {
377 t.Fatal("memberFor(\"\") = nil error; an empty address must never mint a member")
378 }
379}
380```
381
382- [ ] **Step 2: Run it and watch it fail**
383
384Run: `go test ./internal/sheets/ -run TestMemberFor -v`
385Expected: FAIL — `undefined: newTestApp`, `undefined: memberFor`.
386
387- [ ] **Step 3: Add the model, the migration and the helper**
388
389In `internal/sheets/models.go`:
390
391```go
392// Member is one person who may hold a session on this instance.
393//
394// Address is stored in plaintext, deliberately: spec 2.2 lists
395// "membership, grants, and roles — including readable email addresses"
396// among the things this design does NOT seal, and publishes that fact in
397// the README rather than leaving it to be discovered. Sealing it would
398// also make the uniqueIndex below impossible, since a random nonce per
399// seal means the same address seals to a different ciphertext every
400// time.
401//
402// The row exists so a cell edit can carry a stable uint64 actor id.
403// Addresses are lowercased before they reach here (memberFor), matching
404// the admission allowlist's own comparison.
405type Member struct {
406 ID uint64 `gorm:"primaryKey"`
407 Address string `gorm:"uniqueIndex;size:320"`
408 CreatedAt time.Time
409}
410```
411
412Add `&Member{}` to `Models` in `internal/sheets/migrations.go`.
413
414Create `internal/sheets/migrations/0002_create_members.sql`:
415
416```sql
417CREATE TABLE `members` (
418 `id` integer PRIMARY KEY AUTOINCREMENT,
419 `address` text,
420 `created_at` datetime
421);
422CREATE UNIQUE INDEX `idx_members_address` ON `members`(`address`);
423```
424
425Then run `rastrillo migration check` and reconcile the SQL with whatever it
426reports until it is silent — the committed SQL must be exactly what the
427generator would produce, or `make test-migrations` fails in CI.
428
429Create `internal/sheets/members.go`:
430
431```go
432package sheets
433
434import (
435 "errors"
436 "net/http"
437 "strings"
438
439 "gorm.io/gorm"
440 "gorm.io/gorm/clause"
441)
442
443// memberFor returns the Member row for an address, creating it on first
444// sight.
445//
446// First sight is the right moment because admission has already
447// happened: auth.Config.Authorize refused every address not on the
448// allowlist before a session existed, so anything reaching here is
449// someone who is allowed to be here. Creating rows at boot from
450// SHEETS_OWNERS instead would mint members for people who never signed
451// in and would go stale the moment the allowlist changed.
452//
453// The upsert is DO NOTHING plus a read, not a read-then-create: two tabs
454// signing in at once would otherwise race and one would get a unique
455// violation.
456func (a *App) memberFor(address string) (Member, error) {
457 addr := strings.ToLower(strings.TrimSpace(address))
458 if addr == "" {
459 return Member{}, errors.New("sheets: refusing to mint a member for an empty address")
460 }
461 if a.db == nil {
462 return Member{}, errors.New("sheets: no database")
463 }
464 m := Member{Address: addr}
465 if err := a.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&m).Error; err != nil {
466 return Member{}, err
467 }
468 if m.ID != 0 {
469 return m, nil
470 }
471 // The insert conflicted: the row is already there, so read it.
472 var existing Member
473 if err := a.db.Where("address = ?", addr).First(&existing).Error; err != nil {
474 if errors.Is(err, gorm.ErrRecordNotFound) {
475 return Member{}, errors.New("sheets: member neither created nor found")
476 }
477 return Member{}, err
478 }
479 return existing, nil
480}
481
482// actor resolves the signed-in viewer to a Member. No session is not an
483// error — it is "no actor" — so callers gate on ok rather than on err,
484// and a route that requires one has already been through
485// auth.RequireSession.
486func (a *App) actor(r *http.Request) (Member, bool) {
487 address, ok := a.identity(r)
488 if !ok {
489 return Member{}, false
490 }
491 m, err := a.memberFor(address)
492 if err != nil {
493 return Member{}, false
494 }
495 return m, true
496}
497```
498
499- [ ] **Step 4: Add the test helper**
500
501In `internal/sheets/app_test.go`, add a helper the later tasks reuse. It
502opens a real SQLite file under `t.TempDir()` through rastrillo's `db`
503package (never `gorm.io/driver/sqlite` — the forbidden-imports guard bans
504it) and applies `BootSchema`:
505
506```go
507// newTestApp builds an App over a real, migrated, throwaway database and
508// a deterministic all-zero DEK. Nil auth: these tests exercise storage
509// and rendering, not sign-in.
510func newTestApp(t *testing.T) *App {
511 t.Helper()
512 d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), slog.Default())
513 if err != nil {
514 t.Fatalf("db.Open: %v", err)
515 }
516 t.Cleanup(func() { d.Close() })
517 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
518 t.Fatalf("migrate: %v", err)
519 }
520 s, err := seal.New(make([]byte, 32))
521 if err != nil {
522 t.Fatalf("seal.New: %v", err)
523 }
524 return New(d.G, nil, s, func(string) bool { return true })
525}
526```
527
528- [ ] **Step 5: Run the tests**
529
530Run: `go test ./internal/sheets/ -run TestMember -v && make test-migrations`
531Expected: PASS, and `rastrillo migration check` silent.
532
533- [ ] **Step 6: Commit**
534
535```bash
536git add internal/sheets/members.go internal/sheets/members_test.go \
537 internal/sheets/models.go internal/sheets/migrations.go \
538 internal/sheets/migrations/0002_create_members.sql \
539 internal/sheets/app_test.go
540git commit -m "feat: an address becomes a stable actor id"
541```
542
543---
544
545### Task 3: Sheets and tabs — create, list, open
546
547**Files:**
548- Create: `internal/sheets/sheetsvc.go`, `internal/sheets/sheetsvc_test.go`
549
550**Interfaces:**
551- Consumes: `App.db`, `App.sealer`, `Member` from Task 2.
552- Produces:
553 - `type SheetSummary struct{ ID uint64; Title string; TabID uint64; UpdatedAt time.Time }`
554 - `func (a *App) createSheet(title string, by Member) (SheetSummary, error)`
555 - `func (a *App) listSheets() ([]SheetSummary, error)`
556 - `func (a *App) sheetSummary(id uint64) (SheetSummary, error)`
557
558- [ ] **Step 1: Write the failing test**
559
560Create `internal/sheets/sheetsvc_test.go`:
561
562```go
563package sheets
564
565import "testing"
566
567func TestCreateSheetMakesAFirstTab(t *testing.T) {
568 app := newTestApp(t)
569 m, err := app.memberFor("ada@example.com")
570 if err != nil {
571 t.Fatalf("memberFor: %v", err)
572 }
573 s, err := app.createSheet("Budget", m)
574 if err != nil {
575 t.Fatalf("createSheet: %v", err)
576 }
577 if s.TabID == 0 {
578 t.Fatal("createSheet made a sheet with no tab; there is nothing to open")
579 }
580 if s.Title != "Budget" {
581 t.Fatalf("title = %q, want %q", s.Title, "Budget")
582 }
583}
584
585// The one rule. A title is sheet content.
586func TestSheetTitleIsSealedAtRest(t *testing.T) {
587 app := newTestApp(t)
588 m, _ := app.memberFor("ada@example.com")
589 if _, err := app.createSheet("Salaries 2027", m); err != nil {
590 t.Fatalf("createSheet: %v", err)
591 }
592 var raw []byte
593 if err := app.db.Raw("SELECT title_sealed FROM sheets LIMIT 1").Scan(&raw).Error; err != nil {
594 t.Fatalf("read back: %v", err)
595 }
596 if bytes.Contains(raw, []byte("Salaries")) {
597 t.Fatal("the sheet title reached the database in plaintext")
598 }
599}
600
601func TestListSheetsIsInstanceWideNotPerCreator(t *testing.T) {
602 app := newTestApp(t)
603 ada, _ := app.memberFor("ada@example.com")
604 bob, _ := app.memberFor("bob@example.com")
605 if _, err := app.createSheet("Ada's", ada); err != nil {
606 t.Fatalf("createSheet: %v", err)
607 }
608 if _, err := app.createSheet("Bob's", bob); err != nil {
609 t.Fatalf("createSheet: %v", err)
610 }
611 got, err := app.listSheets()
612 if err != nil {
613 t.Fatalf("listSheets: %v", err)
614 }
615 // This is the scope.Owned trap AGENTS.md names: the wrong
616 // implementation returns one row and looks like an empty-ish list
617 // rather than an error.
618 if len(got) != 2 {
619 t.Fatalf("listSheets returned %d sheets, want 2 — sheets are team-shared", len(got))
620 }
621}
622
623func TestCreateSheetRefusesAnEmptyTitle(t *testing.T) {
624 app := newTestApp(t)
625 m, _ := app.memberFor("ada@example.com")
626 if _, err := app.createSheet(" ", m); err == nil {
627 t.Fatal("createSheet with a blank title = nil error, want a refusal")
628 }
629}
630```
631
632- [ ] **Step 2: Run it and watch it fail**
633
634Run: `go test ./internal/sheets/ -run 'TestCreateSheet|TestListSheets|TestSheetTitle' -v`
635Expected: FAIL — `undefined: createSheet`.
636
637- [ ] **Step 3: Write the implementation**
638
639Create `internal/sheets/sheetsvc.go`:
640
641```go
642package sheets
643
644import (
645 "errors"
646 "strings"
647 "time"
648
649 "gorm.io/gorm"
650)
651
652// maxTitleRunes caps a sheet title. It is a UI-shaped limit, not a
653// storage one — the sealed column takes anything — and exists so a
654// pasted paragraph cannot become a sheet name that breaks every list
655// row.
656const maxTitleRunes = 200
657
658// SheetSummary is one row of the sheet list: everything a list or a page
659// header needs, with the title already unsealed.
660//
661// TabID is the sheet's FIRST tab, which is the one a bare /sheets/{id}
662// opens. Multi-tab navigation is a later slice; carrying the id here
663// means the grid page never has to query for it.
664type SheetSummary struct {
665 ID uint64
666 Title string
667 TabID uint64
668 UpdatedAt time.Time
669}
670
671// errNoSealer is returned rather than panicking when a nil-sealer App
672// (tests that render pages only) reaches a path that needs one.
673var errNoSealer = errors.New("sheets: no sealer configured")
674
675// createSheet makes a sheet and its first tab in one transaction.
676//
677// One transaction because a sheet with no tab is not openable: it would
678// list, and then 404 when clicked. Nothing outside this function ever
679// creates a Sheet row, which is what makes "every sheet has at least one
680// tab" a real invariant rather than a convention.
681func (a *App) createSheet(title string, by Member) (SheetSummary, error) {
682 if a.db == nil || a.sealer == nil {
683 return SheetSummary{}, errNoSealer
684 }
685 clean := strings.TrimSpace(title)
686 if clean == "" {
687 return SheetSummary{}, errors.New("sheets: a sheet needs a title")
688 }
689 if len([]rune(clean)) > maxTitleRunes {
690 clean = string([]rune(clean)[:maxTitleRunes])
691 }
692 sealedTitle, err := a.sealer.Seal("sheet.title", []byte(clean))
693 if err != nil {
694 return SheetSummary{}, err
695 }
696 sealedName, err := a.sealer.Seal("tab.name", []byte("Sheet 1"))
697 if err != nil {
698 return SheetSummary{}, err
699 }
700
701 sheet := Sheet{TitleSealed: sealedTitle, CreatedBy: by.ID}
702 tab := Tab{NameSealed: sealedName, Position: 0}
703 if err := a.db.Transaction(func(tx *gorm.DB) error {
704 if err := tx.Create(&sheet).Error; err != nil {
705 return err
706 }
707 tab.SheetID = sheet.ID
708 return tx.Create(&tab).Error
709 }); err != nil {
710 return SheetSummary{}, err
711 }
712 return SheetSummary{ID: sheet.ID, Title: clean, TabID: tab.ID, UpdatedAt: sheet.UpdatedAt}, nil
713}
714
715// listSheets returns every live sheet on the instance, newest edit
716// first.
717//
718// Deliberately unscoped by creator. Sheets are team-shared, and
719// scope.Owned here would return only the caller's own rows — failing as
720// a plausible short list rather than as an error (AGENTS.md /
721// Mechanical rules; TestListSheetsIsInstanceWideNotPerCreator). When
722// spec 8's grants land, this grows a hand-written grants join; it does
723// NOT grow a scope.Owned.
724func (a *App) listSheets() ([]SheetSummary, error) {
725 if a.db == nil || a.sealer == nil {
726 return nil, errNoSealer
727 }
728 var rows []Sheet
729 if err := a.db.Where("deleted_at IS NULL").Order("updated_at DESC").Find(&rows).Error; err != nil {
730 return nil, err
731 }
732 out := make([]SheetSummary, 0, len(rows))
733 for _, s := range rows {
734 sum, err := a.summarize(s)
735 if err != nil {
736 return nil, err
737 }
738 out = append(out, sum)
739 }
740 return out, nil
741}
742
743// sheetSummary reads one sheet. A missing sheet is gorm.ErrRecordNotFound,
744// which handlers turn into a 404 — never a 403, per AGENTS.md.
745func (a *App) sheetSummary(id uint64) (SheetSummary, error) {
746 if a.db == nil || a.sealer == nil {
747 return SheetSummary{}, errNoSealer
748 }
749 var s Sheet
750 if err := a.db.Where("id = ? AND deleted_at IS NULL", id).First(&s).Error; err != nil {
751 return SheetSummary{}, err
752 }
753 return a.summarize(s)
754}
755
756// summarize unseals a Sheet row's title and finds its first tab.
757func (a *App) summarize(s Sheet) (SheetSummary, error) {
758 title, err := a.sealer.Open("sheet.title", s.TitleSealed)
759 if err != nil {
760 return SheetSummary{}, err
761 }
762 var tab Tab
763 if err := a.db.Where("sheet_id = ?", s.ID).Order("position ASC").First(&tab).Error; err != nil {
764 return SheetSummary{}, err
765 }
766 return SheetSummary{ID: s.ID, Title: string(title), TabID: tab.ID, UpdatedAt: s.UpdatedAt}, nil
767}
768```
769
770- [ ] **Step 4: Run the tests**
771
772Run: `go test ./internal/sheets/ -run 'TestCreateSheet|TestListSheets|TestSheetTitle' -v`
773Expected: PASS.
774
775- [ ] **Step 5: Commit**
776
777```bash
778git add internal/sheets/sheetsvc.go internal/sheets/sheetsvc_test.go
779git commit -m "feat: create, list and read a sheet, with the title sealed"
780```
781
782---
783
784### Task 4: `SetReport` — a write that says what it changed
785
786Live sync needs the touched set. `Handle.Set` returns only an error, and
787seventeen tests plus `xlsx/import.go` call it. So `Set` keeps its signature
788and becomes a wrapper; the reporting form is new.
789
790**Files:**
791- Create: `internal/sheets/store/handle_report.go`,
792 `internal/sheets/store/handle_report_test.go`
793- Modify: `internal/sheets/store/handle_set.go` (Set becomes a wrapper)
794
795**Interfaces:**
796- Consumes: `Handle.Set`'s internals — `setBlank`, `setContent`, `recalcTouched`.
797- Produces:
798
799```go
800type ChangedCell struct {
801 RowID, ColID uint64
802 Raw string
803 Value calc.Value
804 Derived bool
805}
806type Report struct {
807 Version uint64
808 Cells []ChangedCell
809 Capped bool
810}
811func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error)
812```
813
814- [ ] **Step 1: Write the failing test**
815
816Create `internal/sheets/store/handle_report_test.go`:
817
818```go
819package store
820
821import "testing"
822
823func TestSetReportNamesTheOriginAndItsDependents(t *testing.T) {
824 h := openTestHandle(t) // existing helper in handle_test.go
825 a1r, a1c := gridAt(t, h, 0, 0)
826 b1r, b1c := gridAt(t, h, 0, 1)
827 if err := h.Set(b1r, b1c, "=A1*2", 1); err != nil {
828 t.Fatalf("Set B1: %v", err)
829 }
830 rep, err := h.SetReport(a1r, a1c, "21", 1)
831 if err != nil {
832 t.Fatalf("SetReport: %v", err)
833 }
834 if len(rep.Cells) != 2 {
835 t.Fatalf("report covers %d cells, want 2 (A1 and its dependent B1)", len(rep.Cells))
836 }
837 var sawOrigin, sawDerived bool
838 for _, c := range rep.Cells {
839 switch {
840 case c.RowID == a1r && c.ColID == a1c:
841 sawOrigin = true
842 if c.Derived {
843 t.Error("the edited cell is reported as derived")
844 }
845 if c.Raw != "21" {
846 t.Errorf("origin raw = %q, want %q", c.Raw, "21")
847 }
848 case c.RowID == b1r && c.ColID == b1c:
849 sawDerived = true
850 if !c.Derived {
851 t.Error("a recalculated dependent is not flagged derived")
852 }
853 if c.Value.Num != 42 {
854 t.Errorf("B1 = %v, want 42", c.Value.Num)
855 }
856 }
857 }
858 if !sawOrigin || !sawDerived {
859 t.Fatalf("origin seen=%v, dependent seen=%v", sawOrigin, sawDerived)
860 }
861}
862
863func TestSetReportVersionAdvances(t *testing.T) {
864 h := openTestHandle(t)
865 r, c := gridAt(t, h, 0, 0)
866 first, err := h.SetReport(r, c, "1", 1)
867 if err != nil {
868 t.Fatalf("SetReport: %v", err)
869 }
870 second, err := h.SetReport(r, c, "2", 1)
871 if err != nil {
872 t.Fatalf("SetReport: %v", err)
873 }
874 if second.Version <= first.Version {
875 t.Fatalf("version did not advance: %d then %d", first.Version, second.Version)
876 }
877}
878
879func TestSetReportOnBlankReportsTheClearedCell(t *testing.T) {
880 h := openTestHandle(t)
881 r, c := gridAt(t, h, 0, 0)
882 if err := h.Set(r, c, "7", 1); err != nil {
883 t.Fatalf("Set: %v", err)
884 }
885 rep, err := h.SetReport(r, c, "", 1)
886 if err != nil {
887 t.Fatalf("SetReport blank: %v", err)
888 }
889 if len(rep.Cells) == 0 {
890 t.Fatal("blanking a cell reported no change; the other tab would never clear it")
891 }
892 if rep.Cells[0].Raw != "" || rep.Cells[0].Value.Kind != calc.KindEmpty {
893 t.Fatalf("cleared cell reported as %+v, want empty", rep.Cells[0])
894 }
895}
896```
897
898`openTestHandle` and `gridAt` may need adding to `handle_test.go` as thin
899wrappers over whatever the existing helpers there are named — read that file
900first and reuse rather than duplicate.
901
902- [ ] **Step 2: Run it and watch it fail**
903
904Run: `go test ./internal/sheets/store/ -run TestSetReport -v`
905Expected: FAIL — `h.SetReport undefined`.
906
907- [ ] **Step 3: Refactor Set into a reporting core**
908
909In `internal/sheets/store/handle_set.go`, rename the existing `Set` body's
910entry point to `setReportLocked` and have both public forms call it. The two
911inner methods gain a returned touched set:
912
913```go
914// Set writes a cell's new raw input and recalculates forward from it.
915// It is SetReport with the report discarded — kept because every caller
916// that does not fan out over SSE (XLSX import, most tests) wants exactly
917// this and nothing more.
918func (h *Handle) Set(rowID, colID uint64, raw string, actor uint64) error {
919 _, err := h.SetReport(rowID, colID, raw, actor)
920 return err
921}
922```
923
924Create `internal/sheets/store/handle_report.go` with `SetReport`, holding
925`h.mu` for the whole call exactly as `Set` did, delegating to `setBlank` /
926`setContent` (both changed to return `map[calc.CellKey]struct{}` alongside
927their error), and then building the `Report` from the graph's current cached
928values plus `h.rawText`:
929
930```go
931// SetReport is Set, plus an account of what it changed: the cell that
932// was edited and every cell the recalculation touched, each with the
933// value it now holds and the version that value carries.
934//
935// The version is the single number a client uses to discard an
936// out-of-order paint (spec 7). Every cell in one report shares it,
937// because they were all written by one Set and one recalculation pass —
938// a per-cell version would be the same number repeated.
939//
940// Capped is true when the edit's dependent set was wider than
941// calc.MaxDirtyPerPass, so no recalculation ran and the report covers
942// the origin alone. A client seeing Capped reloads the window rather
943// than painting a partial truth. This is the honest error the cap
944// exists to produce, surfaced rather than swallowed.
945func (h *Handle) SetReport(rowID, colID uint64, raw string, actor uint64) (Report, error) {
946 // ... implementation as described above
947}
948```
949
950Derived is `key != origin`. `Raw` is `h.rawText[key]` (empty for a cleared
951or derived-formula-output cell is fine — the client repaints from `Value`,
952and only uses `Raw` to refill the editor for the cell you are on).
953
954- [ ] **Step 4: Run the whole store suite, including the race gate**
955
956Run: `go test -count=1 ./internal/sheets/store/... && make test-race`
957Expected: PASS. The refactor touches the locking path, so `-race` matters
958here more than anywhere else in this plan.
959
960- [ ] **Step 5: Commit**
961
962```bash
963git add internal/sheets/store/handle_report.go \
964 internal/sheets/store/handle_report_test.go \
965 internal/sheets/store/handle_set.go
966git commit -m "store: a write reports what it changed, with the version to order it by"
967```
968
969---
970
971### Task 5: The fan-out hub
972
973Pure fan-out. No HTTP, no store, no templates — so it can be tested with
974goroutines and channels and nothing else, and `-race` over it means
975something.
976
977**Files:**
978- Create: `internal/sheets/live/hub.go`, `internal/sheets/live/hub_test.go`
979
980**Interfaces:**
981- Consumes: nothing.
982- Produces:
983
984```go
985type Message struct {
986 Version uint64
987 Data []byte // an already-marshalled JSON payload; the hub never looks inside
988}
989type Sub struct{ /* ... */ }
990func NewHub() *Hub
991func (h *Hub) Subscribe(tabID uint64) *Sub
992func (h *Hub) Publish(tabID uint64, m Message)
993func (h *Hub) Subscribers(tabID uint64) int
994func (s *Sub) C() <-chan Message
995func (s *Sub) Lost() bool
996func (s *Sub) Close()
997```
998
999- [ ] **Step 1: Write the failing test**
1000
1001Create `internal/sheets/live/hub_test.go`:
1002
1003```go
1004package live
1005
1006import (
1007 "testing"
1008 "time"
1009)
1010
1011func TestPublishReachesEverySubscriberOnTheTab(t *testing.T) {
1012 h := NewHub()
1013 a := h.Subscribe(7)
1014 b := h.Subscribe(7)
1015 other := h.Subscribe(8)
1016 defer a.Close()
1017 defer b.Close()
1018 defer other.Close()
1019
1020 h.Publish(7, Message{Version: 3, Data: []byte(`{"v":3}`)})
1021
1022 for i, s := range []*Sub{a, b} {
1023 select {
1024 case m := <-s.C():
1025 if m.Version != 3 {
1026 t.Errorf("subscriber %d got version %d, want 3", i, m.Version)
1027 }
1028 case <-time.After(time.Second):
1029 t.Fatalf("subscriber %d received nothing", i)
1030 }
1031 }
1032 select {
1033 case m := <-other.C():
1034 t.Fatalf("a subscriber on tab 8 received tab 7's message: %+v", m)
1035 case <-time.After(50 * time.Millisecond):
1036 }
1037}
1038
1039func TestCloseUnsubscribes(t *testing.T) {
1040 h := NewHub()
1041 s := h.Subscribe(7)
1042 s.Close()
1043 if got := h.Subscribers(7); got != 0 {
1044 t.Fatalf("after Close, Subscribers(7) = %d, want 0", got)
1045 }
1046 // Close is idempotent: an SSE handler defers it and may also hit it
1047 // on a write error, and a double close of the channel would panic
1048 // in a goroutine no request owns.
1049 s.Close()
1050}
1051
1052// A tab that has stopped reading must never block the writer. The
1053// alternative — an unbounded buffer — turns one wedged browser into an
1054// instance-wide memory leak.
1055func TestASlowSubscriberIsDroppedNotBlocking(t *testing.T) {
1056 h := NewHub()
1057 s := h.Subscribe(7)
1058 defer s.Close()
1059 for i := 0; i < subBuffer+10; i++ {
1060 done := make(chan struct{})
1061 go func() { h.Publish(7, Message{Version: uint64(i)}); close(done) }()
1062 select {
1063 case <-done:
1064 case <-time.After(time.Second):
1065 t.Fatalf("Publish blocked on message %d", i)
1066 }
1067 }
1068 if !s.Lost() {
1069 t.Fatal("an overrun subscriber is not marked lost; it would paint a sheet with holes in it")
1070 }
1071}
1072```
1073
1074- [ ] **Step 2: Run it and watch it fail**
1075
1076Run: `go test ./internal/sheets/live/ -v`
1077Expected: FAIL — the package does not exist.
1078
1079- [ ] **Step 3: Write the implementation**
1080
1081Create `internal/sheets/live/hub.go`. The shape:
1082
1083- `subBuffer = 64` — a named constant with the reasoning beside it: deep
1084 enough that a normal tab under a burst of edits never overruns, shallow
1085 enough that a thousand wedged tabs cost kilobytes, not megabytes.
1086- `Hub` holds `mu sync.Mutex` and `tabs map[uint64]map[*Sub]struct{}`.
1087- `Publish` does a non-blocking send per subscriber; a full channel sets
1088 `lost` (via `atomic.Bool`) and does NOT close the channel — the reader
1089 discovers it through `Lost()` and asks the client to resync.
1090- `Close` removes the sub under the hub's lock, guarded by `sync.Once` so
1091 it is idempotent, and deletes the tab's map entry when it empties, so an
1092 instance that has served a thousand tabs does not keep a thousand empty
1093 maps.
1094
1095The hub never inspects `Data`. That is what keeps cell content out of this
1096package entirely, and with it out of any log line this package could ever
1097grow.
1098
1099- [ ] **Step 4: Run the tests, with the race detector**
1100
1101Run: `go test -race -count=1 ./internal/sheets/live/ -v`
1102Expected: PASS.
1103
1104- [ ] **Step 5: Add live to the race target**
1105
1106In `Makefile`, extend `test-race`:
1107
1108```make
1109test-race:
1110 go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
1111```
1112
1113and update the target's comment to say live is here for the same reason
1114store is: it is where the goroutines are.
1115
1116- [ ] **Step 6: Commit**
1117
1118```bash
1119git add internal/sheets/live/ Makefile
1120git commit -m "live: an in-process fan-out hub that never blocks on a wedged tab"
1121```
1122
1123---
1124
1125### Task 6: The view layer — templates, tokens, theme before first paint
1126
1127**Files:**
1128- Create: `internal/sheets/render.go`, `internal/sheets/theme.go`,
1129 `internal/sheets/theme_test.go`, `internal/sheets/render_test.go`
1130- Create: `internal/sheets/templates/layout.html`, `index.html`, `signin.html`
1131- Create: `internal/sheets/static/tokens.css`, `internal/sheets/static/sheets.css`
1132- Modify: `internal/sheets/page.go` (delete the string constants), `app.go`
1133
1134**Interfaces:**
1135- Consumes: `rastrillo/ui` (`Templates()`, `TokensCSS()`, `Layout()`).
1136- Produces:
1137 - `type PageData struct{ Title, Theme, Address, Path string; SignedIn bool; CSRF string; Data any }`
1138 - `func (a *App) render(w http.ResponseWriter, r *http.Request, page string, status int, data any)`
1139 - `func themeFrom(r *http.Request) string`
1140 - `GET /static/*` serving the embedded assets with a long cache lifetime.
1141
1142- [ ] **Step 1: Scaffold the tokens with rastrillo doctor**
1143
1144```bash
1145rastrillo doctor
1146rastrillo doctor --fix
1147```
1148
1149Expected: it copies `ui/tokens.css` (and the partials it styles) into
1150`internal/sheets/static/`. AGENTS.md requires this on every rastrillo
1151upgrade; this is the first adoption, so it is the first run. Record its
1152output in the branch discussion.
1153
1154- [ ] **Step 2: Write the failing theme test**
1155
1156Create `internal/sheets/theme_test.go`:
1157
1158```go
1159package sheets
1160
1161import (
1162 "net/http"
1163 "net/http/httptest"
1164 "testing"
1165)
1166
1167func TestThemeDefaultsToDayWithNoCookie(t *testing.T) {
1168 r := httptest.NewRequest(http.MethodGet, "/", nil)
1169 if got := themeFrom(r); got != "day" {
1170 t.Fatalf("themeFrom with no cookie = %q, want %q", got, "day")
1171 }
1172}
1173
1174func TestThemeReadsTheCookie(t *testing.T) {
1175 r := httptest.NewRequest(http.MethodGet, "/", nil)
1176 r.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
1177 if got := themeFrom(r); got != "signal" {
1178 t.Fatalf("themeFrom = %q, want %q", got, "signal")
1179 }
1180}
1181
1182// An unknown value must not reach the page. It is interpolated into a
1183// stylesheet href and a data attribute; "day" is the safe floor.
1184func TestThemeRejectsAnUnknownValue(t *testing.T) {
1185 r := httptest.NewRequest(http.MethodGet, "/", nil)
1186 r.AddCookie(&http.Cookie{Name: themeCookie, Value: "../../etc/passwd"})
1187 if got := themeFrom(r); got != "day" {
1188 t.Fatalf("themeFrom with a junk cookie = %q, want the %q floor", got, "day")
1189 }
1190}
1191```
1192
1193And a render test that pins the reason the cookie is read server-side:
1194
1195```go
1196// AGENTS.md: light/dark must resolve before first paint, because cell
1197// fills resolve against the theme and a late preference repaints every
1198// coloured cell. A page that ships the theme in its first byte cannot
1199// flash; one that fetches it can. This test fails the moment somebody
1200// moves the decision into JS.
1201func TestThemeIsInTheFirstResponseNotFetched(t *testing.T) {
1202 app := newTestApp(t)
1203 req := httptest.NewRequest(http.MethodGet, "/", nil)
1204 req.AddCookie(&http.Cookie{Name: themeCookie, Value: "signal"})
1205 rec := httptest.NewRecorder()
1206 app.Router().ServeHTTP(rec, req)
1207 if !strings.Contains(rec.Body.String(), `data-theme="signal"`) {
1208 t.Fatal("the rendered page does not carry the theme; it would flash on load")
1209 }
1210}
1211```
1212
1213- [ ] **Step 3: Run them and watch them fail**
1214
1215Run: `go test ./internal/sheets/ -run TestTheme -v`
1216Expected: FAIL — `undefined: themeFrom`.
1217
1218- [ ] **Step 4: Write theme.go**
1219
1220```go
1221package sheets
1222
1223import "net/http"
1224
1225// themeCookie is the per-person theme. Per person, not per document
1226// (spec 9.3, AGENTS.md) — so it is a cookie on this origin and not a
1227// column on Sheet.
1228const themeCookie = "sheets_theme"
1229
1230// defaultTheme is the floor. rastrillo ships day, plain and signal;
1231// anything else — a stale cookie, a hand-edited one, an attempt at path
1232// traversal through the stylesheet href — resolves to this rather than
1233// reaching a template.
1234const defaultTheme = "day"
1235
1236func themeFrom(r *http.Request) string {
1237 c, err := r.Cookie(themeCookie)
1238 if err != nil {
1239 return defaultTheme
1240 }
1241 for _, name := range ui.ThemeNames() {
1242 if c.Value == name {
1243 return c.Value
1244 }
1245 }
1246 return defaultTheme
1247}
1248```
1249
1250Plus `handleTheme` (POST `/theme`), which validates against
1251`ui.ThemeNames()`, sets the cookie `HttpOnly=false, SameSite=Lax,
1252Secure` (on an https origin), `Max-Age` one year, and redirects back to the
1253`return` form field — validated as a same-origin *path*, never an absolute
1254URL, or the theme switcher becomes an open redirect.
1255
1256- [ ] **Step 5: Write render.go and the templates**
1257
1258`render.go` embeds `templates/*.html` and `static/*`, parses one
1259`*template.Template` per page over rastrillo's layout plus partials (so two
1260pages can both define `"content"`), and builds `PageData` with theme,
1261identity and the CSRF token. It serves `/static/*` from the embedded FS.
1262
1263Templates use the **attribute grammar** throughout —
1264`rst-btn="primary"`, `rst-callout="warn"`, `rst-field` — never the class
1265spelling, and adopt it per screen (AGENTS.md).
1266
1267`layout.html` puts `data-theme="{{.Theme}}"` on `<html>` and links
1268`/static/tokens.css` before anything else.
1269
1270- [ ] **Step 6: Move page.go onto templates**
1271
1272Delete `pageHead`/`pageWhat`/`pageSignedOut`/`pageFoot` from `page.go` and
1273render `index.html` instead. `page_test.go` and `signin_test.go` assert on
1274strings from those constants — update them to assert on the rendered page's
1275affordances (a sign-in link when signed out, the address and a sign-out
1276button when signed in), which is what they were really pinning.
1277
1278- [ ] **Step 7: Run the tests**
1279
1280Run: `make test-vet test-fmt test-build test-go`
1281Expected: PASS.
1282
1283- [ ] **Step 8: Commit**
1284
1285```bash
1286git add internal/sheets/render.go internal/sheets/theme.go \
1287 internal/sheets/templates internal/sheets/static \
1288 internal/sheets/page.go internal/sheets/*_test.go
1289git commit -m "feat: a view layer — rastrillo tokens, one layout, theme before first paint"
1290```
1291
1292---
1293
1294### Task 7: The sheet list, scriptless
1295
1296**Files:**
1297- Create: `internal/sheets/handlers_sheets.go`,
1298 `internal/sheets/handlers_sheets_test.go`,
1299 `internal/sheets/templates/sheets.html`
1300- Modify: `internal/sheets/app.go` (routes, `auth.RequireSession`, CSRF)
1301
1302**Interfaces:**
1303- Consumes: `createSheet`, `listSheets`, `sheetSummary`, `actor`, `render`.
1304- Produces: `GET /sheets`, `POST /sheets`, and `App.requireSession` — a
1305 middleware that is `a.auth.RequireSession` in production and a pass-through
1306 when `a.auth` is nil, so page tests keep working without a mailer.
1307
1308- [ ] **Step 1: Write the failing test**
1309
1310```go
1311func TestPostSheetsCreatesAndRedirects(t *testing.T) {
1312 app := newTestApp(t)
1313 form := url.Values{"title": {"Q3 forecast"}}
1314 req := httptest.NewRequest(http.MethodPost, "/sheets", strings.NewReader(form.Encode()))
1315 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1316 rec := httptest.NewRecorder()
1317 app.Router().ServeHTTP(rec, req)
1318
1319 if rec.Code != http.StatusSeeOther {
1320 t.Fatalf("POST /sheets = %d, want 303 — a POST that renders is a POST that resubmits on reload", rec.Code)
1321 }
1322 loc := rec.Header().Get("Location")
1323 if !strings.HasPrefix(loc, "/sheets/") {
1324 t.Fatalf("Location = %q, want the new sheet", loc)
1325 }
1326}
1327
1328func TestSheetListShowsEveryTitle(t *testing.T) { /* create two, GET /sheets, assert both titles appear */ }
1329
1330func TestUnknownSheetIs404NotForbidden(t *testing.T) {
1331 app := newTestApp(t)
1332 req := httptest.NewRequest(http.MethodGet, "/sheets/999999", nil)
1333 rec := httptest.NewRecorder()
1334 app.Router().ServeHTTP(rec, req)
1335 if rec.Code != http.StatusNotFound {
1336 t.Fatalf("GET a sheet that is not there = %d, want 404 (AGENTS.md: 404, never 403)", rec.Code)
1337 }
1338}
1339```
1340
1341- [ ] **Step 2: Run and watch it fail.** Expected: 404 on `/sheets`.
1342
1343- [ ] **Step 3: Write the handlers and the template.** `POST` reads the
1344 title with `r.FormValue` into an explicit struct — never a bind onto a
1345 GORM model (AGENTS.md) — resolves the actor, creates, and 303s to
1346 `/sheets/{id}`.
1347
1348- [ ] **Step 4: Mount the routes behind a session**
1349
1350In `app.go`, inside `r.Group(func(r chi.Router) { r.Use(a.requireSession) })`.
1351Add `r.Use(csrf.Protect(origin))` at the router root; `App` gains the origin
1352string (`New` grows a parameter, or it reads it from the `auth.Auth` —
1353prefer passing it explicitly so a nil-auth test App still has one).
1354Existing `POST /signin` tests will need the CSRF headers — update them.
1355
1356- [ ] **Step 5: Run `make test`.** Expected: PASS.
1357
1358- [ ] **Step 6: Commit**
1359
1360```bash
1361git commit -am "feat: create and list sheets, no script required"
1362```
1363
1364---
1365
1366### Task 8: The grid JSON API
1367
1368**Files:**
1369- Create: `internal/sheets/handlers_grid.go`,
1370 `internal/sheets/handlers_grid_test.go`
1371- Modify: `internal/sheets/app.go`
1372
1373**Interfaces:**
1374- Consumes: `store.Open`, `store.Handle.SetReport`, `format.Display`,
1375 `App.actor`, `App.hub` (Task 5's hub, held on `App`).
1376- Produces:
1377 - `GET /api/sheets/{sheetID}/tabs/{tabID}/cells?rows=100&cols=26` →
1378 `{"version":N,"rows":100,"cols":26,"cells":[{"r":0,"c":0,"raw":"=A1*2","display":"42","v":7}]}`
1379 - `POST /api/sheets/{sheetID}/tabs/{tabID}/cells` with
1380 `{"r":0,"c":0,"raw":"=A1*2"}` → the same `cells` shape for everything
1381 the write changed, and a fan-out of that payload on the hub.
1382
1383- [ ] **Step 1: Write the failing test**
1384
1385```go
1386func TestPostACellThenGetItBack(t *testing.T) {
1387 app := newTestApp(t)
1388 m, _ := app.memberFor("ada@example.com")
1389 sheet, _ := app.createSheet("Budget", m)
1390
1391 body := `{"r":0,"c":0,"raw":"=1+1"}`
1392 post := httptest.NewRequest(http.MethodPost,
1393 fmt.Sprintf("/api/sheets/%d/tabs/%d/cells", sheet.ID, sheet.TabID),
1394 strings.NewReader(body))
1395 post.Header.Set("Content-Type", "application/json")
1396 rec := httptest.NewRecorder()
1397 app.Router().ServeHTTP(rec, post)
1398 if rec.Code != http.StatusOK {
1399 t.Fatalf("POST cell = %d: %s", rec.Code, rec.Body.String())
1400 }
1401 var wrote gridResponse
1402 json.Unmarshal(rec.Body.Bytes(), &wrote)
1403 if len(wrote.Cells) != 1 || wrote.Cells[0].Display != "2" {
1404 t.Fatalf("POST returned %+v, want one cell displaying 2", wrote.Cells)
1405 }
1406
1407 get := httptest.NewRequest(http.MethodGet, /* same path */, nil)
1408 rec2 := httptest.NewRecorder()
1409 app.Router().ServeHTTP(rec2, get)
1410 var read gridResponse
1411 json.Unmarshal(rec2.Body.Bytes(), &read)
1412 // The client edits raw and paints display. Both must survive a reload.
1413 if read.Cells[0].Raw != "=1+1" || read.Cells[0].Display != "2" {
1414 t.Fatalf("after reload: %+v", read.Cells[0])
1415 }
1416}
1417
1418// Spec 6's cap reasoning, enforced at the boundary rather than trusted.
1419func TestGridWindowIsCapped(t *testing.T) { /* rows=999999 must clamp, not allocate */ }
1420
1421// The one rule, at the last layer that could break it.
1422func TestNoCellContentInAnErrorResponse(t *testing.T) {
1423 // POST an unparseable formula containing a distinctive string;
1424 // assert the 400 body does not contain it.
1425}
1426```
1427
1428- [ ] **Step 2: Run and watch it fail.**
1429
1430- [ ] **Step 3: Write the handlers.** Key points, each with the reasoning in
1431 a comment:
1432 - Open the handle once per request via `store.Open`, `defer h.Release()`.
1433 - Window: `rows` and `cols` clamp to `maxWindowRows = 200` /
1434 `maxWindowCols = 50`. A request for more is clamped, not refused —
1435 refusing turns a wide window into a broken page.
1436 - `display` comes from `format.Display(value, numFmt)` — the server owns
1437 every string the grid paints, which is the §12 gate that crosses from
1438 computed value to painted output.
1439 - The POST resolves row/col ids via `h.EnsureRow` / `h.EnsureCol`, so
1440 typing into an untouched cell works.
1441 - On success, marshal the same payload once and `hub.Publish` it, tagged
1442 with an `origin` client id the poster sends so its own echo is skipped.
1443 - Errors carry coordinates and codes, never the cell's text.
1444
1445- [ ] **Step 4: Run `make test`.** Expected: PASS.
1446
1447- [ ] **Step 5: Commit**
1448
1449```bash
1450git commit -am "feat: read a grid window and write a cell, with the server owning every painted string"
1451```
1452
1453---
1454
1455### Task 9: SSE — the endpoint and the transport gate
1456
1457**Files:**
1458- Create: `internal/sheets/handlers_stream.go`,
1459 `internal/sheets/handlers_stream_test.go`
1460- Modify: `internal/sheets/app.go`
1461
1462**Interfaces:**
1463- Consumes: `App.hub`.
1464- Produces: `GET /api/sheets/{sheetID}/tabs/{tabID}/stream`.
1465
1466- [ ] **Step 1: Write the failing test**
1467
1468Spec §7: *"The SSE transport check is a streamed 200, not a 101."*
1469
1470```go
1471func TestStreamIsAStreamed200NotAnUpgrade(t *testing.T) {
1472 app := newTestApp(t)
1473 srv := httptest.NewServer(app.Router())
1474 defer srv.Close()
1475 m, _ := app.memberFor("ada@example.com")
1476 sheet, _ := app.createSheet("Budget", m)
1477
1478 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1479 defer cancel()
1480 req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
1481 fmt.Sprintf("%s/api/sheets/%d/tabs/%d/stream", srv.URL, sheet.ID, sheet.TabID), nil)
1482 resp, err := http.DefaultClient.Do(req)
1483 if err != nil {
1484 t.Fatalf("stream: %v", err)
1485 }
1486 defer resp.Body.Close()
1487 if resp.StatusCode != http.StatusOK {
1488 t.Fatalf("stream = %d, want a streamed 200 (SSE is text/event-stream, not a 101 upgrade)", resp.StatusCode)
1489 }
1490 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
1491 t.Fatalf("Content-Type = %q, want text/event-stream", ct)
1492 }
1493
1494 // Unbuffered: the first event must arrive without the response
1495 // having to end. A handler that builds its body and returns passes
1496 // every status check above and is useless.
1497 go func() {
1498 time.Sleep(100 * time.Millisecond)
1499 post(t, srv.URL, sheet, `{"r":0,"c":0,"raw":"5"}`)
1500 }()
1501 line := readEventWithin(t, resp.Body, 3*time.Second)
1502 if !strings.Contains(line, `"display":"5"`) {
1503 t.Fatalf("first streamed event = %q", line)
1504 }
1505}
1506```
1507
1508- [ ] **Step 2: Run and watch it fail.**
1509
1510- [ ] **Step 3: Write the handler.** The shape:
1511 - Headers: `Content-Type: text/event-stream`, `Cache-Control: no-store`,
1512 `X-Accel-Buffering: no` (the edge may buffer otherwise, which is the
1513 failure the streamed-200 test is written to catch), then
1514 `http.NewResponseController(w).Flush()` immediately so the client's
1515 `onopen` fires before the first edit.
1516 - Subscribe, `defer sub.Close()`, and select over `sub.C()`,
1517 `r.Context().Done()`, a `time.Ticker` writing a `: ping` comment every
1518 `pingEvery = 25 * time.Second`, and an idle timer.
1519 - **Idle policy, written down** (spec §7): `idleTimeout = 15 * time.Minute`
1520 with no edits and no heartbeat closes the stream; `maxStreamLife = 4 * time.Hour`
1521 closes it regardless. A tab left open all weekend cannot hold an instance
1522 awake and billable. The client reconnects on its own — `EventSource`
1523 does that natively — so a close is a pause, not a failure.
1524 - `sub.Lost()` sends a single `event: resync` and returns, so an overrun
1525 client reloads its window instead of painting a sheet with holes.
1526
1527- [ ] **Step 4: Run `make test test-race`.** Expected: PASS.
1528
1529- [ ] **Step 5: Commit**
1530
1531```bash
1532git commit -am "feat: SSE fan-out, with an idle policy written down rather than emergent"
1533```
1534
1535---
1536
1537### Task 10: The grid in the browser
1538
1539**Files:**
1540- Create: `internal/sheets/static/vendor/jspreadsheet.{js,css}`,
1541 `internal/sheets/static/vendor/jsuites.{js,css}`
1542- Create: `internal/sheets/static/grid.js`, `internal/sheets/static/live.js`
1543- Create: `internal/sheets/templates/sheet.html`
1544- Create: `internal/guardstest/jscap_test.go`
1545
1546- [ ] **Step 1: Vendor the assets**
1547
1548```bash
1549D=$(mktemp -d)
1550curl -sSL -o $D/js.tgz https://registry.npmjs.org/jspreadsheet-ce/-/jspreadsheet-ce-5.0.2.tgz
1551curl -sSL -o $D/su.tgz https://registry.npmjs.org/jsuites/-/jsuites-5.10.2.tgz
1552tar xzf $D/js.tgz -C $D && tar xzf $D/su.tgz -C $D --one-top-level=su
1553install -D $D/package/dist/index.js internal/sheets/static/vendor/jspreadsheet.js
1554install -D $D/package/dist/jspreadsheet.css internal/sheets/static/vendor/jspreadsheet.css
1555install -D $D/su/package/dist/jsuites.js internal/sheets/static/vendor/jsuites.js
1556install -D $D/su/package/dist/jsuites.css internal/sheets/static/vendor/jsuites.css
1557install -D $D/package/LICENSE internal/sheets/static/vendor/LICENSE.jspreadsheet
1558install -D $D/su/package/LICENSE internal/sheets/static/vendor/LICENSE.jsuites
1559```
1560
1561Both are MIT. Copy the licences in beside the code, and record the exact
1562versions in `internal/sheets/static/vendor/README.md` along with the
1563commands above, so the next upgrade is a re-run rather than an
1564investigation.
1565
1566- [ ] **Step 2: Write the JS line-cap guard first**
1567
1568Create `internal/guardstest/jscap_test.go`, mirroring `linecap_test.go`:
1569walk `internal/sheets/static/**/*.js`, cap at 300 lines, with an
1570exemption map holding exactly the two vendored blobs — by exact path,
1571never by directory, so a third file dropped into `vendor/` is not silently
1572exempt.
1573
1574- [ ] **Step 3: Run it and watch it fail** (the vendored files exceed the cap
1575 until they are listed). Then add the two exemptions and watch it pass.
1576
1577- [ ] **Step 4: Write `grid.js`**
1578
1579Under 300 lines. Responsibilities, and nothing else:
1580- Build the Jspreadsheet instance over the window the page was rendered with.
1581- **`parseFormulas` off.** Our engine computes; the client paints. A
1582 client-side evaluator would disagree with the server on the first
1583 `SUM(1,2,"3")` — the LibreOffice/Excel divergence the corpus already
1584 documents — and the disagreement would be invisible.
1585- Paint `display`; keep `raw` in a side map so selecting a cell puts the
1586 formula, not its result, in the editor.
1587- On `onchange`, POST the raw text and apply the response.
1588- Menu equivalents for every drag-only affordance (AGENTS.md): fill-down
1589 and fill-right live in the toolbar as buttons, not only on the drag
1590 handle.
1591
1592- [ ] **Step 5: Write `live.js`**
1593
1594Under 300 lines. `EventSource` on the stream; on message, **discard by
1595version** — `if (msg.v <= lastAppliedVersionFor(cell)) return;` — which is
1596the whole reason the version travels (spec §7). Skip our own echo by client
1597id. Heartbeat POST every 30s, **stopped while `document.hidden`**, with a
1598**ceiling** of 4 hours, matching the server's `maxStreamLife`. On
1599`event: resync`, refetch the window.
1600
1601- [ ] **Step 6: Write `sheet.html`**
1602
1603The grid page. Its `<noscript>` block links to `/sheets/{id}/table`, so a
1604scriptless visitor lands somewhere useful rather than on an empty div.
1605
1606- [ ] **Step 7: Run `make test`, then drive it in a real browser**
1607
1608Two tabs, same sheet, type in one, watch the other. This is the acceptance
1609criterion for the whole plan and it cannot be asserted in Go.
1610
1611- [ ] **Step 8: Commit**
1612
1613```bash
1614git add internal/sheets/static internal/sheets/templates/sheet.html \
1615 internal/guardstest/jscap_test.go
1616git commit -m "feat: the grid, painting strings the server computed"
1617```
1618
1619---
1620
1621### Task 11: The scriptless table, and the affordance gate
1622
1623Spec §6: a **read-only table view of any sheet with per-cell edit forms**,
1624paginated. And: *"No form in the app is script-required. A test fetches every
1625page script-stripped and diffs for every affordance."*
1626
1627**Files:**
1628- Create: `internal/sheets/handlers_table.go`,
1629 `internal/sheets/handlers_table_test.go`,
1630 `internal/sheets/templates/table.html`
1631
1632- [ ] **Step 1: Write the failing test**
1633
1634```go
1635// The gate spec 6 asks for, made concrete: every write the grid can do,
1636// the scriptless view can do too — proven by actually doing it with no
1637// JavaScript anywhere in the request.
1638func TestACellCanBeEditedWithNoScriptAtAll(t *testing.T) {
1639 app := newTestApp(t)
1640 m, _ := app.memberFor("ada@example.com")
1641 sheet, _ := app.createSheet("Budget", m)
1642
1643 form := url.Values{"r": {"0"}, "c": {"0"}, "raw": {"=2*21"}}
1644 req := httptest.NewRequest(http.MethodPost,
1645 fmt.Sprintf("/sheets/%d/table", sheet.ID), strings.NewReader(form.Encode()))
1646 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
1647 rec := httptest.NewRecorder()
1648 app.Router().ServeHTTP(rec, req)
1649 if rec.Code != http.StatusSeeOther {
1650 t.Fatalf("scriptless cell edit = %d, want 303", rec.Code)
1651 }
1652
1653 page := get(t, app, fmt.Sprintf("/sheets/%d/table", sheet.ID))
1654 if !strings.Contains(page, ">42<") {
1655 t.Fatal("the scriptless table does not show the value it just computed")
1656 }
1657}
1658
1659func TestTableViewIsPaginated(t *testing.T) {
1660 // 200,000 cells rendered as one HTML table is a several-hundred-
1661 // megabyte page (spec 6). Assert a page renders at most
1662 // tableRowsPerPage rows and offers a next link.
1663}
1664
1665func TestEveryPageWorksWithoutScript(t *testing.T) {
1666 // For each page path: fetch, strip every <script>...</script> and
1667 // every on* attribute, and assert at least one <form> or <a href>
1668 // remains for each affordance the page claims to offer.
1669}
1670```
1671
1672- [ ] **Step 2–4:** run, implement, run. `tableRowsPerPage = 50`.
1673
1674- [ ] **Step 5: Commit**
1675
1676```bash
1677git commit -am "feat: a scriptless table view that can edit any cell"
1678```
1679
1680---
1681
1682### Task 12: An imported fill that fails WCAG AA gets surfaced
1683
1684Spec §14's deferred table has one row whose trigger is *"plan 2's grid
1685work"*, cross-referencing §11.1. This is that trigger firing. §11.1 already
1686ruled that a retained fill can be unreadable and that this is **correct** —
1687so the work is to *surface* it, not to fix it.
1688
1689**Files:**
1690- Modify: `internal/sheets/handlers_grid.go` (add `contrastWarn` to the
1691 cell payload), `internal/sheets/static/grid.js`, `templates/table.html`
1692- Create: `internal/sheets/contrast_test.go`
1693- Modify: `docs/superpowers/specs/2026-08-30-sheets-design.md` (§14: strike
1694 the row, since its trigger has fired and it is now built)
1695
1696- [ ] **Step 1: Read §11.1 and §10.4 in full before writing anything.** The
1697 ruling is subtle and the test must encode the ruling, not a guess at it.
1698
1699- [ ] **Step 2: Write the failing test.** A cell whose imported fill and ink
1700 fall below AA reports `contrastWarn: true`; one that passes does not. Use
1701 rastrillo's own contrast helper if `ui` exposes one — check
1702 `ui/contrast_test.go` for the function it tests — rather than writing a
1703 second WCAG implementation that can disagree with the framework's.
1704
1705- [ ] **Step 3–4:** implement, run.
1706
1707- [ ] **Step 5: Commit**
1708
1709```bash
1710git commit -am "feat: surface an imported fill that fails AA, per spec 11.1's trigger"
1711```
1712
1713---
1714
1715### Task 13: Deploy to the canary and verify the process
1716
1717**Files:** none. This is the step AGENTS.md's Deploys section exists for.
1718
1719- [ ] **Step 1: Confirm the tree is clean and CI is green**
1720
1721```bash
1722git status --porcelain # must be empty
1723make test # all six targets
1724```
1725
1726- [ ] **Step 2: Set the new secret**
1727
1728`SHEETS_DEK` did not exist before Task 1, so the instance will refuse to
1729boot without it:
1730
1731```bash
1732head -c 32 /dev/urandom | base64
1733carlos secrets set --app sheets SHEETS_DEK=<that value>
1734```
1735
1736- [ ] **Step 3: Deploy**
1737
1738```bash
1739make release
1740carlos deploy --app sheets --channel edge
1741```
1742
1743`carlos deploy`, never `carlos ship` — `ship` publishes a release and stops,
1744leaving it unpromoted and unserved.
1745
1746- [ ] **Step 4: Verify the process, not the edge, by polling**
1747
1748```bash
1749want=$(git rev-parse --short HEAD)
1750for i in $(seq 1 40); do
1751 got=$(curl -sS https://sheets.bds.oncarlos.com/api/version | tr -d '"')
1752 echo "$i: $got (want $want)"
1753 [ "$got" = "$want" ] && break
1754 sleep 10
1755done
1756```
1757
1758A promote is not a deploy: the old binary served for over 90 seconds on
1759three consecutive deploys here. `X-Carlos-Version` is the router's adopted
1760release and says nothing about which binary is running — do not check it.
1761
1762- [ ] **Step 5: Drive it in two real browser tabs on the canary.** Type in
1763 one, watch the other. Review happens on a deployed canary, never
1764 localhost.
1765
1766- [ ] **Step 6: Open the branch discussion and record the review model**
1767
1768Every branch gets a discussion, and that discussion IS the review record,
1769including which model reviewed it (AGENTS.md §Review). Record the
1770`rastrillo doctor` output from Task 6 there too.
1771
1772---
1773
1774## Self-Review
1775
1776**Spec coverage.** §6: grid vendored, no build step (Task 10); scriptless
1777list, table view with per-cell edit forms, pagination, the affordance gate
1778(Tasks 7, 11). §7: cell-level LWW over SSE (Tasks 5, 8, 9); derived cells
1779carry the actor and a `derived` flag (Task 4); every message carries the
1780version and clients discard by it (Tasks 4, 9, 10); streamed-200 transport
1781check (Task 9); idle policy with hidden-tab heartbeats and a ceiling (Tasks
17829, 10). Presence and undo are §7 and are **deferred with triggers**, stated
1783in Assumptions. §10: attribute grammar, per-screen adoption, tokens frozen
1784via `doctor`, theme before first paint (Task 6). §11.1/§14: the contrast
1785trigger (Task 12).
1786
1787**Gaps I am naming rather than hiding.** §7's presence and undo are not
1788built. §8's sharing is not built, so access is instance-wide. §13 sequences
1789sharing before live sync; this plan inverts that, for the reason in
1790Assumptions 2.
1791
1792**Placeholders.** Tasks 7, 8, 11 and 12 give test names and the reasoning
1793each test encodes, but leave some bodies as a described assertion rather
1794than finished Go. That is a deliberate density trade at the tail of a long
1795plan, and it is a real weakness of this document: the executor writes those
1796bodies. Every task's *first* test — the one that drives the design — is
1797complete code.
1798
1799**Type consistency.** `Report`/`ChangedCell` (Task 4) are what Task 8
1800marshals and Task 9 fans out. `SheetSummary` (Task 3) is what Tasks 7, 8 and
180111 read. `Member` (Task 2) is the actor everywhere. `themeFrom`/`PageData`
1802(Task 6) are what every template consumes.
1803