# Implementation plan — §8.8's fact box

Against `docs/superpowers/specs/2026-09-03-memoria-design.md` §8.8 draft
3, decision 15, gates 34–37 (38 struck). Branch `feat/lookup-box`.

**Draft 3 is what this plan is written against, and §0 of the previous
version of this file is why it exists.** Writing the retrieval step out
concretely is what found that a corpus-wide facts table cannot coexist
with a cap of three. That finding is now in the spec; what remains here is
operational.

---

## 1. `internal/memoria/fact` — new package

Recognisers, validators, the kind lexicon and the label rule. Imports
`regexp`, `strings`, `unicode` and nothing else: pure, table-driven, and
unit-testable without a database, a browser or an app — the same reason
`query` and `chunk` are their own packages.

**The lexicon lives here, beside the validators, not in `query`.** `query`
is about one decision and its doc comment says so. More to the point, the
words that ask for a kind and the validator that admits one are two
columns of one table and must not drift apart.

```go
type Kind string   // "phone", "eircode", "iban", "vat", "email"

// Wanted returns the kinds a query names. Empty means no box.
func Wanted(q string) []Kind

// Found is one validated fact, located.
type Found struct {
    Kind      Kind
    Span      string // the document's bytes. The only thing rendered.
    Canonical string // match-only; never rendered, never an operand.
    Label     string // the document's, per §8.8's five rules.
    NoBox     bool   // an over-cap source-1 label: this kind renders nothing.
}

// Scan returns every validated fact of the wanted kinds in text.
// heading and title are the label fallbacks, in that order.
func Scan(text, heading, title string, want []Kind) []Found
```

Build order, each step's test before the next:

1. **`Wanted`** — a fixed map, longest phrase first so `email address`
   beats `address` (which maps to nothing). Tests: the five kinds;
   `address`, `number` and `mobile app` priming nothing, nothing and
   `phone` respectively.
2. **Recognise and validate, per kind.** Each kind is
   `{re, canon, valid}`. **The regex is deliberately loose and the
   validator does the work** — that is §8.8's "validation is not
   pattern-spotting" made structural, and it is what stops the next
   person tightening a regex into a validator.

   Verified against known-good values before this plan was written,
   because a validator that admits everything is the guard-written-to-pass
   failure in its purest form and a checksum is the one thing here
   checkable against the world rather than against a fixture somebody
   wrote:

   | Input | Expect | Got |
   |---|---|---|
   | `IE6388047V` (Google Ireland) | valid | valid |
   | `IE3628739L` | valid | valid |
   | `IE1234567X` (invented) | **invalid** | invalid |
   | `IE29AIBK93115212345678` | valid | valid |
   | `GB82WEST12345698765432` | valid | valid |
   | `IE29AIBK9311521234567`**`9`** | **invalid** | invalid |
   | `A65 F4E2` (published example) | valid | valid |
   | `D6W 1234` (Dublin 6W's irregular key) | valid | valid |
   | `B12 3456` (`B` is not a routing-key letter) | **invalid** | invalid |

   The reject rows are load-bearing: the invented VAT and the one-digit
   IBAN edit are what tell a checksum from a regex, and `D6W` is the case
   a pattern written `[A-Z][0-9][0-9]` gets wrong.

   **Old-style Irish VAT** — a letter or `+` in second position — is
   refused deliberately. A refused VAT is a box that does not appear; an
   admitted wrong one is a fact on the page.

   **`phone` gets §8.8's structural rule:** a bare run of digits is never
   a phone number; the span must carry `+` or grouping separators.
   `rank_test.go`'s `DUNS/D-U-N-S number is 985632364` is the fixture
   that proves it — nine digits, a plausible Irish mobile without the
   rule. **This is a validator test in `fact`, and a rejection fixture in
   gate 35** — not gate 37, which is the label gate.

   **Separators include U+00A0**, since Go's `\s` does not match it and
   Word and Notion paste it. `chunk.go` does not normalise interior ones,
   so the bytes reach the sealed row.

3. **`label`**, the part drafts 1 and 2 both got wrong. Given the span's
   byte offset in the chunk text:
   - walk back to the start of the enclosing sentence or line — `. `,
     `! `, `? `, `\n`, or the start — **honouring a fixed abbreviation
     list** (`Dr. Mr. Mrs. Ms. St. No. ext. Ltd. Co.`), which is stated
     as incomplete and whose failures compose;
   - **if the run contains an earlier recognised span, start after it**;
   - if what remains ends in `:`, `—`, `–`, `-`, `=` or tab:
     - **empty after removing it and trimming** → fall through to the
       trail;
     - **longer than 60** → `NoBox` for this kind;
     - else → the label;
   - else the heading trail; else the document title, never `""`.

**Every span is taken by byte offset**, so `Span` is a slice of the input
and cannot be a reconstruction. Gate 36's byte-identical property is then
a property of the type rather than a discipline.

---

## 2. `internal/memoria/store.go` — candidates, after the ruling

One unexported method called by `Search` after `rule`:

1. `want := fact.Wanted(q)`; empty → return. **No database work for a
   query naming no kind**, which is the common case.
2. Read the sealed chunk rows for `p.Results` — the **ruled** slice,
   never `all` — in one `IN` query; open under `seal.PurposeChunk`. At
   most 25 rows.
3. **A chunk row that is gone is skipped silently**, as `livePassage`
   skips a stale hit. It does not fail the search.
4. `fact.Scan` per result, carrying that result's `Title` and `Heading`
   as the label fallbacks and its `DocumentID`/`Ordinal` as the citation.
5. **Group by `Kind`. Every rule below is per kind.**
6. Dedupe on `Canonical` into an **ordered slice plus a seen-set** — not
   a bare map, whose range order is randomised and whose first-seen order
   gate 35 asserts. First occurrence wins the citation, and because
   `p.Results` is in rank order that is the best-ranked passage.
7. Any `NoBox` in the kind → drop the kind.
8. Two candidates sharing the whole line `(Title, Heading, Label)` → drop
   the kind.
9. More than three → drop the kind.

`Page` gains `Facts []Fact`; `Fact` carries `Kind, Title, Trail, Label,
Span, DocumentID, Ordinal`. **Not `ChunkID` and not the chunk text** — a
field a template can reach is a field a template eventually prints, which
is why `Result.item` is unexported.

---

## 3. `internal/memoria/handlers.go`

`runSearch` copies `found.Facts` onto the page beside `found.Notices`.
That is the whole handler change, for **both** routes, because `index`
and `searchResults` share `runSearch` — which is why the page and the
fragment cannot disagree about the box any more than they can about
priming.

---

## 4. `templates/results.html` — inside the define

Between the notices block and `<ol rst-results>`: below the withholding
callout, above the list, **inside** `{{define "results"}}` so the fragment
swap replaces box and list together.

Each row carries title and trail as well as label and value (§8.8 — two
pages called "Contact" are the same row to a reader who cannot see which
is which), and **the citation link's text is the document title**, not a
bare "source".

**The box must never reuse `rst-result-title`.** `search.js` targets that
attribute to decide where Enter goes; a link inside the box wearing it
would make Enter land on the box's citation, which §8.8 rules against.
The template comment says so.

**No `safeSnippet`** — that helper exists to let FTS5's `<mark>` through
and nothing here has one. `Span` gets Go's ordinary contextual escaping.
**No anchor built from a document's bytes.**

`memoria.css` gets `[rst-fact-box]`, `[rst-fact]`, `[rst-fact-label]`,
`[rst-fact-value]`, `[rst-fact-cite]` — **tokens only, no colour
literals**. `TestNoColourLiteralsInAppCSS` and
`TestEveryTemplateAttributeIsInTheDesignSystem` both bite, which is why
the stylesheet is written before the template rather than after.

---

## 5. `tools/drive` — the section nobody seeds is the section nobody sees

AGENTS.md, and it has found five bugs this way. Seed three shapes, not
one:

- a labelled office number and an eircode in an address block — the happy
  case;
- **the motivating fixture's paragraph**, two numbers with distinct
  sentence labels — the enumerated case, where the reader should see
  "unused" and understand the page;
- **a bulleted contact pair** — the collision case, where the box should
  be absent. The previous version of this plan called the two-sentence
  paragraph the collision case; it is the opposite, and a seed that
  demonstrates the wrong thing is worse than no seed.

---

## 6. Tests, in the order they get written

**Unit, in `fact`, before any wiring.** `Wanted`; each validator's accept
and reject rows from §1.2; the label's eight cases from gate 37; spans
with an extension and with U+00A0.

**Gates**, each carrying its mutation in the comment:

- **35, 36, 37** in `internal/memoriatest/gates_test.go` — slice 0.
- **34 in `internal/memoria`**, at store level, against **a custody fake
  that rules per item id**. `fixedCustody` returns
  `sibling.Decide(f.give)` for every candidate from one field and cannot
  express fixtures 2–6. Extending it is part of this branch.
- **38 is struck**, not written. The measurement it was going to assert
  is prose in §8.8; a test that injects a term into every row and asserts
  `bm25()` collapses tests SQLite and no mutation of this repo turns it
  red.

**And the mutations are not mine to pick.** §1.9: the gate review picks
its own, and this branch is not done until somebody who did not write it
has tried them.

---

## 7. What this does not touch

`index/` (no column, no table, no weight, no `MatchQuery` change),
`chunk.go` (no change to `Indexable`), `models.go`, `migrations/` (nothing
new is stored), `seal/` (no new purpose), the sibling contract, the
connectors, `Delete`, `Rebuild`.

That list is the strongest evidence for draft 3. Draft 2's design touched
five of them.

---

## 8. What building it changed (2026-09-15)

Kept as the record of what the plan did not foresee.

**The reachability limit is the ordinary case, not an edge.** Writing the
gates showed that bare `phone number` does not match a document reading
`Office: +353 (0)1 547 7854` — the words are not there, nothing ranks,
and there is nothing to extract from. Every fixture had to be given a
word the query could find. Pinned in both directions by
`TestTheBoxOnlySeesWhatRetrievalReturned`, and §12 carries the fix.

**Three recognisers were wrong on ordinary text**, all found by the
implementation review and none by the fixtures:
`Take the N11 then exit at Bray` boxed `N11 then`;
`The deadline is 01-02-2024 10:30` boxed `01-02-2024 10`;
`Growth of +10 000 000 users` boxed `+10 000 000`. And the grouped IBAN
pattern made that kind inert on its commonest written shape —
`IE29 AIBK … 78 and our BIC is …` found nothing at all. §8.8 now carries
the three structural rules that fix them.

**Label derivation was quadratic in the facts a chunk holds.** `chunk.Split`
bounds a chunk by WORDS, so a 2 MiB upload with no whitespace is one
chunk and one line: 48.5s to scan 1 MB, which at the upload cap is
minutes of search latency bought by one person's file. Deduping before
labelling, binary-searching the span list and bounding the backward walk
took 1 MB to 152 ms.

**Four gates had holes the author's own mutations could not reach**, which
is §1.9's whole point. `silenced` counted before the ruling, a citation
cross-wired field by field, a collision check comparing only neighbours,
and rows sorted rather than first-seen — each passed every shipped
fixture and each now has one written for it.

**And `addFacts` turned out to be structurally unable to see unruled
data**, because it takes only `*Page`. The leak mutations all have to be
made in `Search`, one level up. That is a stronger statement of the
design than the comment claiming it, and it is why the gate-34 comment
names the mutation at the level where it is reachable.
