oficina / docs Public
Plan 6 — finding a document by its title
Brief, from Paul, 2026-09-01: "shall we do search now, so i can find docs by title?" — and, asked how much: a search box on the list plus a keyboard jump-to.
Spec §15.2 already rules on the scope: "Title and metadata search ships; full text waits for a design, not for a decision." This is that, and nothing more. Full-text search is still unbuilt and still needs a design compatible with §12's plaintext-grep gate.
D1 — filtering happens in Go, over the owner's own rows, not in SQL
The obvious implementation is WHERE title LIKE '%' || ? || '%'. This plan does not use it.
Spec §2 wants document titles sealed — per-row encryption, the same as update_bin. A SQL LIKE stops working the instant they are, and "search silently returns nothing" is a bad way to find that out. Filtering in Go over the rows Store.List already loads costs one pass over one person's titles — a few hundred short strings — and survives sealing unchanged, because at that point the rows are decrypted on the way past anyway.
This is not in tension with §15.2's objection to full-text search. That objection is that decrypting the corpus per query is O(corpus). Titles are not the corpus; they are one short string per document.
It also keeps §4.2's scoping automatic rather than restated: the filter runs over the result of the same owner-scoped query every other document route uses, so a document belonging to someone else cannot appear in results by construction, not by a second WHERE somebody has to remember.
D2 — the search box works with no JavaScript; the palette is additive
GET /?q=… is a plain form in the list header. It filters server-side and renders the same list, so search works exactly as far as reading does (§6.2). The jump-to palette is layered on top and adds speed, not capability — a person with no JavaScript loses the keyboard shortcut and keeps search.
Paul chose the palette knowing §6.4 cut filter chrome from this screen and §6.2 pushes against new JS. The rulings it sits against were about chrome that does not earn itself: a template carousel, an "Owned by anyone" filter, grid/list/AZ toggles. A search field on a list of documents is the thing those toggles were a poor substitute for.
D3 — one ranker, on the server
The palette could filter client-side over a downloaded title list. It does not: it queries the same endpoint the list uses, debounced.
Two rankers that disagree is exactly the kind of defect nobody notices — the palette and the search box would put the same document in different places, and each would look correct on its own. One implementation cannot drift from itself. It also means no bulk export of every title to the browser, and no cap to tune.
The cost is that the palette needs the network per keystroke. On a document editor already streaming updates that is not a new dependency, and the palette shows its own state when a request fails.
Ranking is deliberately explainable rather than clever: an exact title match, then a title that starts with the query, then a title with a word starting with it, then a match anywhere; ties broken by most recently updated. Somebody should be able to look at a result order and say why it is that order.
D4 — the palette implements its ARIA contract, or it does not claim one
A combobox that says role="combobox"/aria-expanded is promising Down/Up to move through results, Enter to open, Escape to close, and aria-activedescendant to say which option is current. This branch implements all of it — including that Escape gets out and focus returns to where it came from, because a modal that traps focus with no way back is the failure mode a palette has.
That behaviour is not held by a committed check, and this plan said it was. It was exercised by seventeen checks in a real browser, and they all pass, but they live in a scratch driver that is deliberately not in the repo: the harness they belong in (fixtures/browser-drive.mjs) is on the formatting-and-export branch and not yet on main, and shipping a second harness to avoid waiting is worse than waiting. So what protects this today is one person having run it once, which is exactly the thing this repo does not count as a gate. Folding them in is a filed task, and until that happens the honest statement is the one in this paragraph rather than the one that was here before.
That rule is on this repo's own record (AGENTS.md, accessibility conventions) because the formatting bar broke it a day ago.
The work
internal/docs/search.go—Store.Search, the ranking, andGET /documents/searchreturning JSON for the palette.GET /?q=— the list handler filters, and renders an ungrouped, ranked result list with its own empty state. Recency grouping is what an unfiltered list is for; a ranked result list grouped by "Today" would be ordering the results twice, differently.fixtures/jump-to.mjs— the palette, its own committed bundle so the list page does not pay for ProseMirror to get a keyboard shortcut.assets.gogrows from "the editor bundle" to a small registry, one committed artifact per name.- Tests: the ranking, the scoping (someone else's document is not findable), the no-JS path, and browser-drive checks for the palette.
Risks
- A second committed bundle touches
assets.go's "exactly one" init invariant. It becomes "exactly one per name", which is the same guarantee, but it is the kind of check that is easy to weaken by accident while generalising it. /documents/searchis a new authenticated endpoint returning titles. It goes inside theRequireSessiongroup and through the same owner scope; that it does is worth a test rather than a reading.