design: map complete workbook tab scope and migration gates
2 files changed,
+107
−0
.gitignore+1 −0docs/superpowers/specs/2026-09-07-workbook-tabs.md+106 −0
CI passed — run details
diff --git a/.gitignore b/.gitignore| index d30a32e..e034a69 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -23,3 +23,4 @@ |
| .claude/worktrees |
| .claude/HANDOFF.md |
| /sheets-arm64 |
| +writing/ |
diff --git a/docs/superpowers/specs/2026-09-07-workbook-tabs.md b/docs/superpowers/specs/2026-09-07-workbook-tabs.md| new file mode 100644 |
| index 0000000..aa1cd73 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-07-workbook-tabs.md |
| @@ -0,0 +1,106 @@ |
| +# Workbook tabs implementation contract |
| + |
| +Status: draft for independent Fable review; no implementation is approved yet. User request: enact the overnight suite readiness plan, continuously deploy reviewed features, use Amadan for coordination, and use Estilo for shared UI. Tracking: oficina/sheets discussion 18, task 2, branch feat/workbook-tabs. Baseline: origin/main 4aea8f4. |
| + |
| +## Scope and existing decisions |
| + |
| +The 2026-08-30 design and 2026-09-03 next-three §5 govern this slice. Add, select, rename and reorder tabs; preserve tab selection across every editing/review surface; export the whole workbook. No deletion: behavior for non-empty/last-tab deletion remains an explicit unresolved user ruling in next-three §6. No cross-tab formulas or structural row/column edits: calc still keys references by position+1, so those require the separate stable-reference subsystem. Sheet grants apply to every tab; no per-tab permissions. |
| + |
| +This is one subsystem, not a tab strip laid over first-tab handlers. Existing cells, formats, sizes, comments, history, saved views and live subscriptions already carry tab IDs. The page routes and return links are the missing connection. |
| + |
| +## Source findings |
| + |
| +- app.go has 19 session-gated /sheets routes (the older spec said 21; the current route registrations were recounted), counting list/create/import. The anonymous capability view is an additional route. There are also three job status routes that remain unrelated. |
| +- sheetsvc.go SheetSummary embeds the first TabID/TabName. summarize orders by position then ID. tabName currently falls back to Sheet 1 on unreadable names. |
| +- handlers_sheets.go sheetFromURL checks access using sheetSummary and never reads a requested tab. |
| +- handlers_table.go openTabFor simply opens summary.TabID. Comments/history/view and table writes reuse this assumption. All their redirects are currently sheet-only. |
| +- format and size APIs are correctly tab-addressed, but tableReturn takes only sheet ID; a form editing a second tab would land on the first one. |
| +- commands.js undo constructs a sheet-only route. Grid cells and SSE already use data-tab-id. |
| +- export.go writes one hardcoded Excel worksheet. Import reads only the first worksheet and does not preserve its name. Import jobs hide a partially filled workbook using FillingSince. |
| +- ShareLink stores sheet ID. Its capability already means access to the document, not a specific tab. Anonymous link view renders a scriptless table with no API/SSE access. |
| +- Tab has a plain sheet_id index and no position uniqueness. Existing application creation allocates one tab at position zero. |
| + |
| +## Route contract |
| + |
| +P denotes /sheets/{sheetID}; T denotes P/tabs/{tabID}. Keep every existing URL working. Bare sheet-level tab operations resolve the first tab exactly as before; rendered links and post-success redirects use canonical T routes after selection. A malformed, zero, unrelated or nonexistent explicit tab ID must 404, never fall back to the first tab. Authorization is checked against the sheet before unsealing any tab names. |
| + |
| +| Existing route | Scope after this slice | Canonical operation | |
| +| --- | --- | --- | |
| +| GET /sheets | Whole collection | Unchanged; preview uses first tab | |
| +| POST /sheets | Whole workbook | Create first tab, redirect to its T | |
| +| POST /sheets/import | Whole workbook | Existing job flow; see import sequencing | |
| +| GET P | Default-tab alias | Resolve first tab; 303 to T | |
| +| GET P/export.xlsx | Whole workbook | Export all tabs in workbook order | |
| +| GET P/table | First-tab compatibility alias | GET T/table | |
| +| POST P/table | First-tab compatibility write | POST T/table; redirect preserves tab/page | |
| +| GET P/share | Whole workbook | Keep sheet-level; return target may carry validated selected-tab ID | |
| +| POST P/share | Whole workbook | Unchanged grant scope | |
| +| POST P/share/revoke | Whole workbook | Unchanged grant scope | |
| +| POST P/share/link | Whole workbook | Link continues to grant the workbook | |
| +| POST P/share/link/revoke | Whole workbook | Unchanged grant scope | |
| +| GET P/history | First-tab compatibility alias | GET T/history | |
| +| POST P/restore | First-tab compatibility write | POST T/restore | |
| +| POST P/undo | First-tab compatibility write | POST T/undo | |
| +| GET P/comments | First-tab compatibility alias | GET T/comments | |
| +| POST P/comments | First-tab compatibility write | POST T/comments | |
| +| POST P/comments/resolve | First-tab compatibility write | POST T/comments/resolve | |
| +| POST P/view | First-tab compatibility write | POST T/view | |
| +| GET /jobs/{id} | Job owner | Unchanged | |
| +| GET /jobs/{id}/fragment and /events | Job owner | Unchanged | |
| +| GET /l/{token} | Workbook capability default | First tab; navigation to /l/{token}/tabs/{tabID} | |
| + |
| +New tab-management routes: GET P/tabs (viewer list, editor controls), POST P/tabs (editor adds named tab), POST T/name (editor renames), POST T/order (editor moves one place earlier/later). GET T renders the grid. All API cells/format/size/stream routes remain unchanged; add the atomic batch route only by merging that separately reviewed feature, not by copying its implementation into this branch. |
| + |
| +One helper builds canonical tab URLs from numeric IDs. One request resolver derives selected tab from a non-empty chi tabID param after sheet authorization, validates pairing, and unseals its name. SheetSummary should retain its first-tab meaning for collection previews; introduce an explicit selected-tab page summary or selected field rather than silently making every list caller's TabID ambiguous. Methods that need only sheet-level grants keep taking sheet ID. |
| + |
| +All template links/actions, table pagination/edit links, comments/restore/undo returns, saved-view returns, size/format form returns, and commands.js undo must use the selected-tab URL. Do not accept arbitrary return URLs. Share's returnTab is a numeric ID validated against the same sheet; grant semantics remain sheet-level. Existing raw URLs that omit tab preserve their historic first-tab meaning even after reorder changes which tab is first. |
| + |
| +## Data and concurrency |
| + |
| +Add an additive unique index on (sheet_id, position), generated with rastrillo migration generate and matched in GORM tags. Keep the existing plain sheet index if generation requires it; do not rewrite old migrations. Preflight duplicate positions in the migration test fixture: normal one-tab databases upgrade; anomalous duplicates cause an explicit migration refusal rather than deleting or merging user data. No data repair is silently bundled into a schema migration. |
| + |
| +All tab writes run in a DB transaction. Read and write through tx, never the outer writer handle. Add allocates max(position)+1 after counting current tabs; cap at 100 tabs per workbook. Single-writer serialization plus the unique constraint prevent duplicates. Add and rename names are sealed with purpose tab.name before storage; error codes must never wrap input/name strings. IDs alone go in URLs, metrics and logs. Tab names are unsealed only after sheet or capability authorization. |
| + |
| +Names follow the XLSX worksheet-name rules implemented by the pinned excelize version: nonempty, at most 31 UTF-16 code units (verified in pinned excelize sheet.go checkSheetName, which uses countUTF16String), exclude : \\ / ? * [ ], no leading/trailing apostrophe. Reject duplicate names case-insensitively by decrypting this workbook's at-most-100 names inside the write transaction. No plaintext name or name digest column is introduced. Unicode EqualFold is the application comparison; round-trip tests must include non-ASCII names to detect differences from excelize. The constraints apply to newly added/renamed tabs. Existing names are not silently rewritten; an unexportable legacy name must produce an explicit export error until renamed. |
| + |
| +Reorder moves one tab one slot earlier or later in the ordered list. A missing/foreign tab or invalid direction refuses without mutation. Boundary movement is a no-op success. To avoid immediate unique-index collisions, calculate a disjoint positive temporary range above max(position), checked for integer overflow; assign all tab positions in that range, then write compact positions 0..N-1 in the same transaction. Concurrent requests serialize; no intermediate order is visible. Every mutation touches the parent sheet's UpdatedAt in the same transaction so the collection reflects activity. |
| + |
| +Adding/renaming/reordering never changes cell IDs, formulas, row/col positions, permissions or existing comments/history. These operations do not pretend to be cell input events: the existing event schema cannot attribute tab metadata without a new event contract. Metadata history is separately tracked readiness work, not a fake cell-history row. |
| + |
| +## UI and Estilo |
| + |
| +Read Estilo AGENTS and actual navigation/form implementations before creating a primitive. Keep the day theme and attribute grammar. Reuse existing components when they meet the requirement; propose a shared primitive to Estilo only against real implemented uses. A worksheet selector is distinct from document tabs in Docs: do not infer a shared state/data contract from their names. |
| + |
| +The tab list uses real links and aria-current, horizontal scrolling within its own region at narrow widths, visible keyboard focus and no drag-only action. The active tab's content is the sole page region; do not apply an ARIA tablist keyboard contract to server navigation links unless all its keyboard behavior is implemented. Grid, table, history, comments and anonymous link views all expose tab selection. List and sharing remain whole-workbook screens. Add/rename/reorder use ordinary forms; no JavaScript is required. Viewer/commenter controls expose only actions their role can perform. |
| + |
| +A concrete ten-string copy set is being drafted under writing/workbook-tabs, then passed through plain-writing, humane-docs and copy-review. Do not put new strings in templates or handlers before approval. Existing strings are reused where semantics are identical. Deletion has no visible disabled control. New management labels stay consistent across grid and table views. |
| + |
| +Metadata changes on an open live grid use the existing SSE connection. Extend live.Message with an optional event kind, defaulting to cells for existing publishers. The SSE writer allowlists cells and tabs, preserving its existing per-message access check. After a tab metadata transaction commits, publish a tabs invalidation to every tab ID in that workbook; the opaque payload contains no names. The hub remains unaware of cells/sheets. A separate GET /api/sheets/{sheetID}/tabs endpoint rechecks viewer access and returns ordered IDs/names and canonical links. The client refreshes this bounded list on a tabs event, initial stream open, reconnect and page visibility return. Use one request at a time, coalesce invalidations, and use a monotonic local request sequence to discard stale responses. No periodic polling, second SSE stream, presence write or keepalive policy change. The tab strip updates only its own links using textContent; it must preserve focus and the active tab. Scriptless pages reflect current metadata on the next ordinary request, like their existing cell content. A capability link page does not subscribe to this endpoint or stream. This is local data behavior, not an Estilo component API: Estilo owns shared markup/style only when actual adoption justifies it. |
| + |
| +## Workbook export and import sequencing |
| + |
| +The existing Download as .xlsx means the document, so GET P/export.xlsx must contain every tab after this slice. Add xlsx.ExportWorkbook with a list of tab IDs/names or a callback opener, preserving the existing single-tab Export wrapper for its callers/tests. Factor the current writer into one helper writing a tab into an existing excelize file; reuse exportCell, style and size code. Preserve names and order, formulas, numeric types and formats. Open/release one store handle at a time so the app does not retain 100 decrypted graphs. The excelize output workbook still accumulates in memory; cap total exported cells at 200,000 for this first whole-workbook exporter, with a visible refusal rather than returning a partial workbook. Check before writing the HTTP status/body. Review this whole-workbook cap against pilot workloads before broader readiness claims. |
| + |
| +Cross-tab formulas are not supported by calc yet. Do not claim that introducing tabs makes them work, and do not flatten or alter stored formulas to fake support. They remain a stable-reference/grammar task with its own corpus coverage. |
| + |
| +Multi-worksheet import's existing trigger fires when a second tab is reachable. Track it as the immediately following slice, not an unowned deferral. Extend existing hidden-workbook import so every worksheet is enumerated before publication, names validated/sealed, per-tab and total workbook resource limits checked, and every tab imported before FillingSince is cleared. Existing unsupported workbook features (external links/macros/etc.) retain their refusal policy. Do not claim multi-tab migration in the tabs release until this adjacent import slice is reviewed and deployed. Ideally keep the tabs feature on canary until both slices satisfy round-trip fixtures, avoiding a production interval in which users export tabs and reimport only one. |
| + |
| +## Verification and rollout |
| + |
| +- Add/rename/reorder under real transaction concurrency; rollback on sealing/storage failure; positions remain unique/ordered and parent touch is atomic. |
| +- Raw SQLite/WAL and bucket-object gate covers original/additional/renamed tab-name canaries. Refusals must not expose names through errors or logs. |
| +- Every canonical and legacy route is exercised with two workbooks, at least two tabs per workbook, and owner/editor/commenter/viewer/stranger identities. Mixed sheet/tab IDs and malformed explicit IDs 404 before content is unsealed. |
| +- Put visibly different values, comments, saved views and history in each tab. Table edit, format, sizing, comment resolve, restore and keyboard undo must affect only the selected tab and return there. First-tab compatibility must remain covered. |
| +- Capability links reach all tabs in their sheet, never another sheet; revoked links and deleted/hidden workbooks expose none. No presence or SSE cell access is granted to anonymous readers. |
| +- Workbook export fixture checks tab names/order, content/formulas/formats/sizes and >1-tab round-trip after the adjacent importer lands. A failed tab export sends no partial XLSX response. |
| +- Real-browser canary checks: two simultaneous selected tabs, navigation through every screen, no-JS management/table, keyboard operation, narrow-width overflow and light/dark. No page error, cross-tab edit, or stale identity context. |
| +- Fable spec approval before implementation; full make test and current-head remote CI; make release; isolated canary deploy with process /api/version verification; Fable exact-branch plus canary review; squash merge only after all gates. Production deploy uses root orchestration and process-version verification. |
| + |
| +## Prerequisites before implementation approval |
| + |
| +1. Review the explicit typed metadata invalidation mechanism and bounded authenticated list endpoint alongside existing SSE access revalidation. |
| +2. Fable review of names, transactional ordering, route classification and export limits. |
| +3. User copy approval before new UI language ships. |
| +4. Parent's isolated canary environment/authentication setup. |
| + |
| +No user decision is required to preserve the existing no-tab-deletion scope. If deletion is requested later, surface the already-recorded last/non-empty-tab ruling explicitly. |