| 1 | # Plan 6 — finding a document by its title |
| 2 | |
| 3 | Brief, from Paul, 2026-09-01: *"shall we do search now, so i can find |
| 4 | docs by title?"* — and, asked how much: a search box on the list **plus** |
| 5 | a keyboard jump-to. |
| 6 | |
| 7 | Spec §15.2 already rules on the scope: *"Title and metadata search |
| 8 | ships; full text waits for a design, not for a decision."* This is that, |
| 9 | and nothing more. Full-text search is still unbuilt and still needs a |
| 10 | design compatible with §12's plaintext-grep gate. |
| 11 | |
| 12 | ## D1 — filtering happens in Go, over the owner's own rows, not in SQL |
| 13 | |
| 14 | The obvious implementation is `WHERE title LIKE '%' || ? || '%'`. This |
| 15 | plan does not use it. |
| 16 | |
| 17 | Spec §2 wants document titles sealed — per-row encryption, the same as |
| 18 | `update_bin`. A SQL `LIKE` stops working the instant they are, and |
| 19 | "search silently returns nothing" is a bad way to find that out. |
| 20 | Filtering in Go over the rows `Store.List` already loads costs one pass |
| 21 | over one person's *titles* — a few hundred short strings — and survives |
| 22 | sealing unchanged, because at that point the rows are decrypted on the |
| 23 | way past anyway. |
| 24 | |
| 25 | This is not in tension with §15.2's objection to full-text search. |
| 26 | That objection is that decrypting the *corpus* per query is O(corpus). |
| 27 | Titles are not the corpus; they are one short string per document. |
| 28 | |
| 29 | It also keeps §4.2's scoping automatic rather than restated: the filter |
| 30 | runs over the result of the same owner-scoped query every other document |
| 31 | route uses, so a document belonging to someone else cannot appear in |
| 32 | results by construction, not by a second `WHERE` somebody has to |
| 33 | remember. |
| 34 | |
| 35 | ## D2 — the search box works with no JavaScript; the palette is additive |
| 36 | |
| 37 | `GET /?q=…` is a plain form in the list header. It filters |
| 38 | server-side and renders the same list, so search works exactly as far as |
| 39 | reading does (§6.2). The jump-to palette is layered on top and adds |
| 40 | speed, not capability — a person with no JavaScript loses the keyboard |
| 41 | shortcut and keeps search. |
| 42 | |
| 43 | Paul chose the palette knowing §6.4 cut filter chrome from this screen |
| 44 | and §6.2 pushes against new JS. The rulings it sits against were about |
| 45 | *chrome that does not earn itself*: a template carousel, an "Owned by |
| 46 | anyone" filter, grid/list/AZ toggles. A search field on a list of |
| 47 | documents is the thing those toggles were a poor substitute for. |
| 48 | |
| 49 | ## D3 — one ranker, on the server |
| 50 | |
| 51 | The palette could filter client-side over a downloaded title list. It |
| 52 | does not: it queries the same endpoint the list uses, debounced. |
| 53 | |
| 54 | Two rankers that disagree is exactly the kind of defect nobody notices — |
| 55 | the palette and the search box would put the same document in different |
| 56 | places, and each would look correct on its own. One implementation |
| 57 | cannot drift from itself. It also means no bulk export of every title to |
| 58 | the browser, and no cap to tune. |
| 59 | |
| 60 | The cost is that the palette needs the network per keystroke. On a |
| 61 | document editor already streaming updates that is not a new dependency, |
| 62 | and the palette shows its own state when a request fails. |
| 63 | |
| 64 | Ranking is deliberately explainable rather than clever: an exact |
| 65 | title match, then a title that starts with the query, then a title with a |
| 66 | *word* starting with it, then a match anywhere; ties broken by most |
| 67 | recently updated. Somebody should be able to look at a result order and |
| 68 | say why it is that order. |
| 69 | |
| 70 | ## D4 — the palette implements its ARIA contract, or it does not claim one |
| 71 | |
| 72 | A combobox that says `role="combobox"`/`aria-expanded` is promising |
| 73 | Down/Up to move through results, Enter to open, Escape to close, and |
| 74 | `aria-activedescendant` to say which option is current. This branch |
| 75 | implements all of it — including that Escape gets out and focus returns |
| 76 | to where it came from, because a modal that traps focus with no way back |
| 77 | is the failure mode a palette has. |
| 78 | |
| 79 | **That behaviour is not held by a committed check, and this plan said it |
| 80 | was.** It was exercised by seventeen checks in a real browser, and they |
| 81 | all pass, but they live in a scratch driver that is deliberately not in |
| 82 | the repo: the harness they belong in (`fixtures/browser-drive.mjs`) is |
| 83 | on the `formatting-and-export` branch and not yet on `main`, and |
| 84 | shipping a second harness to avoid waiting is worse than waiting. So |
| 85 | what protects this today is one person having run it once, which is |
| 86 | exactly the thing this repo does not count as a gate. Folding them in is |
| 87 | a filed task, and until that happens the honest statement is the one in |
| 88 | this paragraph rather than the one that was here before. |
| 89 | |
| 90 | That rule is on this repo's own record (AGENTS.md, accessibility |
| 91 | conventions) because the formatting bar broke it a day ago. |
| 92 | |
| 93 | ## The work |
| 94 | |
| 95 | 1. `internal/docs/search.go` — `Store.Search`, the ranking, and |
| 96 | `GET /documents/search` returning JSON for the palette. |
| 97 | 2. `GET /?q=` — the list handler filters, and renders an ungrouped, |
| 98 | ranked result list with its own empty state. Recency grouping is what |
| 99 | an *unfiltered* list is for; a ranked result list grouped by "Today" |
| 100 | would be ordering the results twice, differently. |
| 101 | 3. `fixtures/jump-to.mjs` — the palette, its own committed bundle so the |
| 102 | list page does not pay for ProseMirror to get a keyboard shortcut. |
| 103 | `assets.go` grows from "the editor bundle" to a small registry, one |
| 104 | committed artifact per name. |
| 105 | 4. Tests: the ranking, the scoping (someone else's document is not |
| 106 | findable), the no-JS path, and browser-drive checks for the palette. |
| 107 | |
| 108 | ## Risks |
| 109 | |
| 110 | - **A second committed bundle** touches `assets.go`'s "exactly one" init |
| 111 | invariant. It becomes "exactly one per name", which is the same |
| 112 | guarantee, but it is the kind of check that is easy to weaken by |
| 113 | accident while generalising it. |
| 114 | - **`/documents/search` is a new authenticated endpoint returning |
| 115 | titles.** It goes inside the `RequireSession` group and through the |
| 116 | same owner scope; that it does is worth a test rather than a reading. |
| 117 | |