oficina / memoria Public

Spec §8.8, draft 2: the gate review's findings

Reviewed by Fable (dispatched model: fable; the agent did not self-report
an id). REQUEST CHANGES, ten must-fix and eight should-fix. The three
that changed the design rather than the prose:

**The ambiguity ruling failed on this repo's own fixture.** rank_test.go's
office chunk — built from the live instance where Paul hit this — holds
two phone numbers in one paragraph, one of which the document calls
unused. Draft 1 required a label to start its own line, so both fell
through to the heading trail, came back identically labelled, and the box
would have shown them as equals with the disambiguating word one clause
away. The label rule is now sentence-anchored and capped at 60, which
yields "The office phone number is" and "We also have the following
unused phone numbers"; colliding labels render no box at all.

**The ingest half would have degraded the ranking it depends on.** FTS5
computes IDF table-wide, so folding kind words into the chunk body dilutes
"phone" across every chunk carrying a number — undoing the NEAR work
MatchQuery's comment measures, for that comment's own example. Facts now
get their own in-memory FTS5 table with its own IDF space; FactWeight is
gone, and so is the top-k reachability limit §12 was carrying.

**Gate 34 asserted a notice §4.1 forbids.** It demanded that a
NOT_VISIBLE produce a §6.5 notice. OmitSilently is the one disposition
that may be silent and Outcome.Record implements that, so the gate was
either unreachable or an instruction to build the existence oracle. The
silent case is now the load-bearing assertion and UNKNOWN carries the
loud one.

Also: the box is a layer before §8.2's router, not a row in it, so a
fact query still gets routed; renamed off "lookup", which already means
something else in query; url dropped and email's weakness stated; span
vs canonical form separated, with §8.7's parser rule and decision 10's
arithmetic rule each ruled on explicitly; candidate identity defined as
the canonical value; cap three inclusive; the box lives inside the
results define, below the notices; the new sealed-chunk read, its cost
and its delete race stated; the facts-table rejection reworked after the
review showed ground 3 attacked a design nobody proposed.

Gates 34-38 written into §10, which draft 1 left untouched while §8.8
described them. §8.1's floor, §11's slice 0, §12 and §13 updated.
Decision 15 now records which half is Paul's and which is the spec
author's, with the difference filed as NEEDS PAUL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 08f4408093f1ca5c2d798c58dfd19583783fa8c0 parent 27100e6
2 files changed, +512 −222
  • NOTES.md +24 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +488 −222
diff --git a/NOTES.md b/NOTES.md
new file mode 100644
index 0000000..8c8ee57
--- /dev/null
+++ b/NOTES.md
@@ -0,0 +1,24 @@
+# Memoria v0.1.0
+
+First release, and the first public one of Memoria at all: the suite's
+shared memory, one instance per team. Ask a question, get an answer
+grounded in your team's own documents — with a citation you can open,
+every time.
+
+What this release is, precisely: a search engine, not an assistant. There
+is no model call anywhere in this binary. Passages and index are sealed
+at rest under the team's own key, and no document leaves the instance at
+query time or at indexing time, because nothing is sent anywhere to be
+indexed. Signing in does fetch `/.well-known/keymail` for the domain in
+the address typed, so a DNS name leaves; no document does.
+
+The artifact is a stripped linux/arm64 binary, built with `make release`
+and stamped so that `GET /api/version` answers `v0.1.0` rather than the
+`dev` default — which is what lets a deploy check actually fail.
+
+Verify what you downloaded:
+
+ shasum -a 256 memoria-linux-arm64
+
+Built from this tag with CGO disabled, so it has no runtime library
+dependencies beyond the kernel.
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 6cab445..e751aa2 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -108,7 +108,7 @@ embedding is a plaintext cell in a costume — §3.1.
| 12 | **Name: `memoria`.** House convention is plain Spanish office nouns. A *memoria* is both a memory and, in Spanish office usage, a written record. | `archivo`; `cerebro`; `base`; anything English | Paul, 2026-09-03 |
| 13 | **The provider is TensorX, and memoria supports exactly one.** A team owner's choice is on or off, not which — a configurable base URL would let an owner reinstate a proxy while the answer still printed "one named provider". Chosen for EU jurisdiction and open weights, criteria decision 4 did not state, at a known cost in id-pinning and frontier quality (§8.3). | OpenRouter (a proxy: two parties see content); OpenAI direct (better id-pinning, worse jurisdiction and cost) | Paul, 2026-09-04, both halves — *"we go with exactly one for now: TensorX. If we want to swap that later we can."* Swapping the *generation* provider is a code change and a review, not a setting; swapping the *embedding* provider is that plus a full corpus re-embed unless the new host serves the same open weights (§8.3, §13) |
| 14 | **memoria is a search page, not a chat.** One GET search form, `?q=`, one query in and one page out, with `/documents/{id}` as the only other passage surface. This follows from decision 5, not from decision 3 — §8.4's invariant is what decision 3 requires, and a conversational surface could satisfy it. | A chat UI with citations; a threaded follow-up. Deferred to §12, not forbidden — the invariant forbids the leak, this row chooses the shape | Spec author, 2026-09-04, answering a question Paul asked; **confirmed by Paul 2026-09-05**, parking the conversational surface in §12 rather than forbidding it |
-| 15 | **The lookup box calls no model, ever.** A validated fact — eircode, phone, IBAN, VAT, email, URL — is extracted deterministically and shown above the results with its citation. A kind with no validator is not a kind. §8.8 | A cheap extractive model picking the span (§8.7's retrieval-augmented calculator with a different noun); a model composing the label; postal address as a kind, which has nothing to validate | Paul, 2026-09-14 — *"do as much deterministic ordering of the data before we go doing inference on it"* |
+| 15 | **The fact box calls no model, ever.** A validated fact — eircode, phone, IBAN, VAT, email — is extracted deterministically and shown above the results with its citation. A kind with no validator is not a kind. §8.8 | A cheap extractive model picking the span (§8.7's retrieval-augmented calculator with a different noun); a model composing the label; postal address and `url` as kinds, neither having anything to validate | **The constraint is Paul's, 2026-09-14** — *"do as much deterministic ordering of the data before we go doing inference on it"*, asking for the box in the first place. **The "ever", the validator rule, the kind list and the postal exclusion are the spec author's reading of it and are unconfirmed — §13.** Decision 14 is the form this follows: record which half was ruled and by whom, rather than letting the interpretation live in a commit message (§1.9) |
**Three of these are now suite rulings, and this spec cites them rather
than restating them.** Decision 1 is **suite ruling 1**, decision 3 is
@@ -2217,6 +2217,11 @@ app, title, locator, 👨/🤖 marker, date, and a deep link. Scriptless,
paginated, rastrillo `server` shape. Lexical only, since embeddings need
a provider — and per §3.2, nothing leaves the instance in this mode.
+**§8.8's fact box is part of this floor and not an addition above it.** It
+calls no model (decision 15), so it is present in exactly the mode this
+paragraph describes; gate 15 covers it with everything else here, and it
+renders inside the results region rather than beside it.
+
**Loud withholding, naming the shape of the loss:**
- `UNKNOWN` from a sibling → *"Sheets did not answer; 7 results
@@ -2366,16 +2371,14 @@ a provider — and per §3.2, nothing leaves the instance in this mode.
### 8.2 Routing, decided after retrieval
-Retrieval is free, so the router runs on results. Six classes, four
-calling no model — and §8.8's arrived last precisely because it is the
-cheapest: the deterministic classes are the ones worth growing.
+Retrieval is free, so the router runs on results. Five classes, three
+calling no model:
| Class | Signal | Route |
|---|---|---|
| Navigational | "where is the Q3 budget" | **No model.** Result list. |
| Numeric / aggregate | asks for a computed value | **No model does the arithmetic.** Resolve slots, then S6 — §8.7. |
| Who knows about X | asks about people | **No model.** Authorship markers on passages that already passed §4.1, aggregated. |
-| Lookup | names a validated fact kind — "phone number", "eircode", "VAT number" | **No model.** Recognisers over the ruled results; the box, or nothing — §8.8. |
| Single-source factual | large top-1 BM25 margin, one document | Cheap model, extractive. |
| Synthesis | top-K spread across ≥3 documents | Frontier model. |
@@ -2383,6 +2386,15 @@ Draft 1 wrote "filtered to what the asker can see" on the third row,
which either restated §4.1 or smuggled a second per-person filter
alongside it. The passages have already passed the gate.
+**§8.8's fact box is not a sixth row, and it was one in that section's
+first draft.** A row here is a route, and a route is terminal: a query
+naming a fact kind would have stopped at the box, so *"what's the office
+phone number?"* with nothing validated in reach would get no answer where
+the single-source class would have given one. The box runs **before** this
+table and does not consume the query — Paul's constraint is deterministic
+*first*, not deterministic *instead*. It is a fourth way memoria answers
+without a model; it is not a fourth destination.
+
Two configuration names — `model.cheap`, `model.frontier` — with the
route printed on the answer.
@@ -2683,17 +2695,14 @@ below its $0.02–0.10 range rather than sitting in it.
Most of that came from §8.2 pinning dated ids from one family, not from the
provider switch, and none of it is a reason to route more queries to the
-frontier tier — §8.2's model-free classes remain the design, and there are
-four of them since §8.8, not three. This
+frontier tier — §8.2's three model-free classes remain the design, and this
section's closing sentence still holds.
Derived-artefact regeneration (§5.2) adds a synthesis-class call per
invalidated artefact and is unbudgeted until the feature exists.
The money is recurring query tokens by an order of magnitude — the
-economic argument for §8.2's router and its four model-free classes — and
-§8.8, which added the fourth, is the only one of them that reduces the
-query count rather than routing it cheaper.
+economic argument for §8.2's router and its three model-free classes.
### 8.7 Numeric and aggregate questions
@@ -2892,7 +2901,7 @@ calculator; and caching an S6 result, which outlives its cells.
Gate 14 extends: **every digit in an answer sentence traces to a cited
passage or an S6 result.**
-### 8.8 The lookup box: deterministic extraction above the list
+### 8.8 The fact box: deterministic extraction above the list
Paul's motivating example, 2026-09-14: a search for `phone number` should
put the answer at the top of the page — *"Tito office number: +353 (0)1
@@ -2901,258 +2910,406 @@ his constraint, given in the same breath, which is the whole of this
section's shape: **do as much deterministic ordering of the data as
possible before any inference runs on it.**
-**This is §8.2's fourth model-free class.** That table already has three —
+**Called the fact box, and not the lookup box, which is what draft 1
+called it.** `query.IsQuestion`'s complement is already a lookup in this
+codebase — `TestALookupPrimesTheFirstResult` is about Enter, not about
+this — and a second meaning for the word in the same package is the name
+collision this repo's reviews keep finding.
+
+**It is §8.2's fourth model-free class.** That table already has three —
navigational, numeric/aggregate, who-knows-about-X — and the third is the
closest precedent: it answers a real question from structured markers on
-passages that already passed §4.1, with no model anywhere. A lookup box is
+passages that already passed §4.1, with no model anywhere. The fact box is
that shape applied to facts a regular expression can *validate* rather
than merely spot.
-**Decision 15: the lookup box calls no model, ever.** Not a cheap one, not
+**Decision 15: the fact box calls no model, ever.** Not a cheap one, not
an extractive one. The kinds it serves are the kinds that have a
-validator; a kind with no validator is not in the box and is not the
-box's problem (§12). The moment a model picks which span to show, every
-argument below collapses into §8.7's forbidden retrieval-augmented
-calculator with a different noun.
+validator; a kind with no validator is not in the box and is not the box's
+problem (§12). The moment a model picks which span to show, every argument
+below collapses into §8.7's forbidden retrieval-augmented calculator with
+a different noun.
+
+**It is a layer before the router, not a row in it.** Draft 1 put it in
+§8.2's table as a sixth class, which read as terminal: a query naming a
+kind would stop there, and *"what's the office phone number?"* with no
+validated number in reach would get no answer where §8.2's single-source
+class would have given one. Paul's constraint says deterministic
+**first**, not deterministic **instead**. So: the box is attempted, and
+then §8.2 routes the query exactly as it would have. Box and answer can
+both appear; neither suppresses the other.
It does not touch §3.2. No egress, no provider, no embedding — the box
works on an instance with AI switched off, which is decision 5's floor and
-therefore the whole product today.
-
-#### Two extractions, in two places, for two reasons
-
-Conflating these is where this goes wrong, so they are named separately
-and neither is optional.
-
-**At ingest, to make a chunk reachable.** A document that says `D02 XY45`
-and never says the word "eircode" cannot be retrieved by someone searching
-for one. So the recognisers run over chunk text at ingest and their output
-is folded into what the in-memory index holds for that chunk: **the kind
-words** (`eircode`), and **the canonicalised value** (`015477854` beside
-`+353 (0)1 547 7854`). That second half is §8.7's spreadsheet rule
-verbatim — *"every date-like header emitted twice, as displayed and
-canonicalised"* — and it is what makes a phone number findable by someone
-who types it without the spaces.
-
-**At query time, over the ruled results, to fill the box.** The box is
-memoria speaking a passage, so §8.4's invariant binds it: every passage in
-a response passed §4.1 **in the request that produced it**. The box is
-therefore derived from `Page.Results` — after `Store.rule` — and from
-nothing else. There is no second retrieval path, so there is no second
-authorization path to get wrong.
+therefore the whole product today. It is part of §8.1's floor and gate 15
+covers it: scriptless, the box arrives with the page.
+
+#### Retrieval: a second index, not a wider one
+
+Draft 1 folded the recognisers' output into the chunk's existing indexed
+text, so that a document saying `D02 XY45` and never saying "eircode"
+would still be reachable by someone searching for one. **The gate review
+killed that, and the reason is in `index.go`'s own comments.**
+
+FTS5 computes a term's IDF across the table, not per column. Injecting the
+word `phone` into every chunk that happens to carry a phone number lowers
+`phone`'s IDF corpus-wide — degrading the ordinary prose ranking that
+`MatchQuery`'s `NEAR` operand was added to fix, and degrading it for
+exactly the query the NEAR comment works through. A new column with a low
+weight does not avoid this; weights scale the per-column contribution and
+leave IDF alone. Draft 1's `FactWeight` was reasoned from `PathWeight`,
+and the analogy is unsound: path is weighted low because ancestry
+**repeats into every chunk of a document**, which is not why facts would
+need holding down.
+
+**So facts get their own in-memory FTS5 table, with its own IDF space, and
+the main index is untouched.** Columns: kind, canonical value, display
+span, and the chunk coordinates. Same lifetime as the main index, same
+`RemoveDocument`, rebuilt from the sealed chunk rows on wake. Nothing new
+exists at rest — it is the postings argument applied to the same bytes for
+the same reason (decision 6), and `TestIndexIsBackedByNoFile` covers the
+new table for free, which is why this section adds no gate for it. If §12's
+index snapshot ever lands, the facts table is part of the index and is
+sealed with it.
+
+That buys three things draft 1 paid for or went without:
+
+- **No ranking interaction at all**, so the `NEAR` measurement stands.
+- **The kind word retrieves the fact directly** — an unlabelled eircode is
+ found by `eircode`, and a phone number written `+353 (0)1 547 7854` is
+ found by someone typing `015477854`. This is §8.7's spreadsheet rule
+ verbatim: *"every date-like header emitted twice, as displayed and
+ canonicalised"*.
+- **No top-k reachability limit.** Draft 1 had to state one — a fact in a
+ document that did not rank could not fill the box — and defer the fix to
+ §12. The facts table is the fix, and it is cheaper than the deferral
+ was: one extra MATCH against a table holding a handful of rows per
+ document, run only when a kind is named.
+
+#### Authorization: one ruling, over the union
+
+The box is memoria speaking a passage, so §8.4's invariant binds it:
+every passage in a response passed §4.1 **in the request that produced
+it**. The facts table is a second source of candidate passages, and a
+second source of passages is exactly the thing that grows a second
+authorization path.
+
+**It does not get one.** The fact hits and the ranked hits are unioned
+into a single candidate set and handed to `store.rule` once. One S2 round,
+one batch, §6.5's deadline unchanged. Then:
+
+- the results list renders from the ruled ranked hits, as today;
+- the box is built from the ruled fact hits, and from nothing else.
**The ordering is load-bearing and it is the ruling of this subsection:
rule, then extract, then count candidates.** Extracting before ruling
-would let a withheld passage contribute a candidate, and the ambiguity
-count is the leak: two candidates where the reader may see one reports the
-existence of a passage §4.1 just refused them. Counting after ruling gives
-the right answer for the right reason — for that reader, at that moment,
-there is one.
-
-#### Why there is no facts table
-
-The obvious design is a sealed `facts` table written at ingest, keyed by
-chunk, read back at query time. It is rejected on three grounds, and the
-third is the one that decides it:
+would let a withheld passage contribute a candidate, and the *count* is
+the leak — two candidates where the reader may see one reports the
+existence of a passage §4.1 just refused them, and so does a box that
+vanishes for passing the cap because of documents the reader cannot see.
+Counting after ruling gives the right answer for the right reason: for
+that reader, at that moment, there is one.
+
+**A `NOT_VISIBLE` produces no notice here, and draft 1's gate demanded
+one.** §4.1's table makes `OmitSilently` the only disposition that may be
+silent, and `sibling.Outcome.Record` implements exactly that. Draft 1's
+gate 34 asserted *"the box shows the survivor and §6.5's notice is on the
+page"* — which is either unreachable against correct code or, worse, the
+thing somebody makes true to turn the gate green. That is §1.10's failure
+shape: a rule written while citing the section that forbids it. The two
+cases are now separate fixtures and the silent one is the one that
+matters.
+
+**If the box's passage is not among the rendered results, it is appended
+to them.** §8.4 says an answer is never served without its list, and a
+fact drawn from a chunk the ranked query did not return would otherwise be
+an answer whose passage is on no list. Appended last rather than merged by
+score: the two queries score against different IDF spaces and their
+numbers are not comparable, so a merged position would be a ranking
+memoria invented.
+
+**A chunk deleted between the ruling and the box's read is dropped
+silently**, exactly as `Store.Search`'s `livePassage` check drops a stale
+hit, and for the same reason: the passage is genuinely gone, so its
+absence is the true answer rather than a truncation of one. It does not
+fail the page.
+
+**And the box needs plaintext the search path does not currently open.**
+`Store.Search` opens titles, paths and item ids under their own purposes;
+chunk bodies are opened only by `indexDocument` and `Passages`. Snippets
+come from FTS5 and are 24 tokens with `<mark>` in them — too short and too
+marked-up to take a label from. So the box reads the sealed chunk rows for
+its own candidates, after the ruling, under `seal.PurposeChunk`: at most
+three rows, since the cap is three. Draft 1 claimed this was cheaper than
+the opens the search path already did, which was a statement about a
+codebase this is not.
+
+#### Why there is no facts table in SQLite
+
+The in-memory table above is the index. The design this rejects is a
+**sealed, durable** `facts` table, written at ingest, read at query time.
+Two grounds carry it, and draft 1 leaned on a third that attacked a design
+nobody proposed:
- It is **derived state**, so it inherits AGENTS.md's ordering rule in
both directions and §5.2's invalidation — a new way for the index and
- the database to disagree, added to buy nothing.
+ the database to disagree, added to buy nothing the in-memory table does
+ not already give.
- It is **a migration and a schema** for a computation that costs
- microseconds. Recognisers over twenty-five chunks of ~200 words is less
- work than the twenty-five AEAD opens that hand them the text.
-- **Its `kind` column is a plaintext inventory of which kinds of sensitive
- data this team holds.** A row reading `kind='iban'` tells anyone with
- the bucket that the corpus contains bank details, without opening one.
- That is the same object as the bare-SHA `external_id` §5 already
- retired: an identifier derived from plaintext that answers a question
- about the plaintext. Sealing the value and leaving the kind readable is
- precisely the half-measure that failed there.
-
-Computing at query time means **nothing new exists at rest at all**. Gate
-1's plaintext grep and `TestIndexIsNotOnDisk` already cover the only two
-places anything could land, and they cover the new index column for free,
-which is why this section adds no gate for it.
+ microseconds, and it **freezes the recognisers at the version that ran
+ at ingest**. That is the chunker problem `Passages`' comment records:
+ improve a recogniser and every existing document keeps the old answer
+ until somebody re-ingests it.
+
+Draft 1's third ground was that a plaintext `kind` column would be an
+inventory of which kinds of sensitive data the team holds — true, and
+irrelevant, because the proposal was for a sealed table. **The honest
+residual for a fully sealed one is the per-chunk fact-row count**, which
+is real, much smaller, and not on its own a reason. The commit message
+called the weak ground decisive; it was not.
#### The recognisers, and what is not one
A kind is in the box when a query word names it **and** a candidate
validates. Validation is not pattern-spotting: an eircode has a fixed
routing-key alphabet, an IBAN has a mod-97 check, an Irish VAT number has
-a checksum. What validates is what may sit above the results.
+a checksum.
| Kind | Primed by | Validated by |
|---|---|---|
-| `eircode` | `eircode`, `postcode`, `post code` | routing key from the published letter set, plus four characters |
-| `phone` | `phone`, `telephone`, `tel`, `mobile`, `phone number` | E.164, or an Irish national format; length and prefix bounds |
-| `email` | `email`, `email address`, `e-mail` | `local@domain`, domain carrying a dot, no whitespace |
-| `url` | `url`, `link`, `website` | scheme in {`http`, `https`} plus a host |
-| `iban` | `iban`, `bank details`, `account number` | country prefix, length by country, mod-97 |
+| `eircode` | `eircode`, `postcode`, `post code` | routing key from the published letter set, plus four characters from it |
+| `phone` | `phone`, `telephone`, `tel`, `mobile`, `phone number` | canonicalises to E.164 or to an Irish national number; length and prefix bounds |
+| `iban` | `iban`, `bank details` | country prefix, length by country, mod-97 |
| `vat` | `vat`, `vat number` | Irish VAT format and its checksum |
+| `email` | `email`, `email address`, `e-mail` | `local@domain`, domain carrying a dot, no whitespace — **and see below** |
+
+**`url` was in draft 1 and is dropped.** `scheme://host` is
+pattern-spotting with no validator behind it, so every link in every
+document qualifies; on any real corpus a search for "link" passes the cap
+immediately and the box is inert. It also collides with §8.5's *"refuse
+links to non-suite hosts"* for no gain, since a document full of links is
+what the results list is for.
+
+**`email` is kept and its weakness is stated rather than hidden.**
+`local@domain` is closer to pattern-spotting than to validation. On a
+corpus of uploads and Notion pages it is useful — one support address,
+cited. On a corpus carrying forwarded mail (§4.2's source list) every
+signature qualifies, the cap is passed, and the box is inert for that
+kind. That is the cap working, and the failure direction is silence rather
+than a wrong address.
+
+**Nothing in the box is ever an anchor.** The value is text; the only link
+the box carries is its citation, to `/documents/{id}#p{n}`. No `mailto:`,
+no `href` built from a document's bytes — §8.5's *"no remote loads in
+rendered answers"* is about exactly this and it does not need a second
+statement, only an obeying.
**`address` on its own primes nothing**, and the reason is the interesting
one: it is ambiguous between a postal address and an email address, and
-only one of those has a validator. `email address` primes `email`. A
-postal address has no checksum, no fixed grammar and no length — there is
-nothing to validate, so under decision 15 it is not a kind. §12 carries
-it.
+only one of those has anything resembling a validator. `email address`
+primes `email`. A postal address has no checksum, no fixed grammar and no
+length — there is nothing to validate, so under decision 15 it is not a
+kind. §12 carries it.
**`number` on its own primes nothing either.** The lexicon maps
distinctive words and phrases; generic words count only in combination.
-This is `query.openers`' trade made again and in the same direction: a
-missed kind costs a box that would have helped, a wrong kind costs a box
-that asserts something nobody asked about. A generous lexicon is
-affordable *only* because the box still requires a validated candidate —
-"mobile app" primes `phone` and then finds no phone number and renders
-nothing.
-
-**It complements Enter-priming rather than competing with it.** `query.
-IsQuestion` declines to prime a result for anything opening with an
-interrogative, so "what's the office phone number?" navigates nowhere
-today. Those are disproportionately the queries a lookup box answers. The
-two heuristics divide the same space and neither needs to know about the
-other.
-
-#### What the box displays, and where every word of it comes from
-
-**The value is the document's own span, byte for byte.** `+353 (0)1 547
-7854` is displayed as `+353 (0)1 547 7854`. Canonicalisation happens for
-matching and for the index, never for display. This is §8.7's rule one
-noun over — *a second formatter is the same bug class as a second
-calculator* — and it has teeth here: normalising to E.164 drops the
-extension a document wrote after the number, and reformatting an IBAN
-regroups digits a bank grouped deliberately.
-
-**The label is taken from the document and is never composed.** Three
-sources, in order, and the first that applies wins:
-
-1. **The run to the left of the value on its own line**, ending in a
- separator (`:`, `—`, `-`, `=`, tab) — `Office: +353 …` yields `Office`.
- **Capped at 40 characters, and the value must not be mid-sentence**:
- without that, a paragraph mentioning a number yields the preceding
- clause as a label, which is a sentence memoria composed out of
- fragments and presented as a fact's name.
-2. **The chunk's heading trail** — `Tito › Contact`.
-3. **The document title.**
-
-Conservative in §8.7's sense: where the line offers no short label, fall
-back rather than manufacture one. Nothing here title-cases, expands or
-rewords; a label the document did not write is a label a model wrote.
-
-So Paul's example renders as `Tito › Contact — Office: +353 (0)1 547
-7854`, carrying the same `/documents/{id}#p{n}` link every result carries.
-Less shapely than the mock-up, and every character of it is checkable.
-**The box never appears without its citation, and never instead of the
-list.** §8.4: citations with an optional answer, never the reverse. It is
-one element above the results, exactly where the 🤖 paragraph will go, and
-subject to the same sentence — it is a garnish on the search page, which
-is the product (decision 5).
+**This is `query.openers`' trade made in the opposite direction, and draft
+1 claimed it was the same one.** `openers` is deliberately stingy — *"the
+safe direction is not priming"* — because a wrong prime navigates away
+from a page somebody was reading. The fact lexicon can afford to be
+generous, and the reason is the validator, not a shared instinct: "mobile
+app" primes `phone`, finds no phone number, and renders nothing. A
+generous lexicon costs a box that does not appear.
+
+**It complements Enter-priming rather than competing with it.**
+`IsQuestion` declines to prime a result for anything ending in `?` or
+opening with an interrogative, so "what's the office phone number?"
+navigates nowhere today. Those are disproportionately the queries a fact
+box answers. Where both fire — "office phone number", no question mark —
+**Enter still goes to the first result, not to the box's citation.** The
+box is a thing to read, not a destination; changing what Enter means
+depending on whether a regular expression matched is the unreliable-Enter
+failure `PrimesFirstResult` exists to avoid.
+
+#### The span, the canonical form, and §8.7's parser rule
+
+Two strings come out of a recogniser and they have different jobs.
+
+**The span is what the document wrote**, byte for byte, including the
+punctuation, the `(0)`, and any extension after it. It is what the box
+displays and the only thing the box displays.
+
+**The canonical form is match-only.** It is what the facts table indexes
+so that `015477854` finds `+353 (0)1 547 7854`, and what the validator
+runs on. **It is never rendered and never arithmetic is reported from
+it.**
+
+That distinction is load-bearing against two of this spec's own rules, and
+draft 1 tripped over both:
+
+- **§8.7 forbids *"memoria parsing a display string back into a number …
+ a second parser"*.** What it forbids is parsing a display string into a
+ value memoria then *shows or computes with* — the `"€1.2M"` case, where
+ the parse feeds an answer. A canonical form that exists only as an index
+ term, is never displayed, and is never an operand is not that. Said
+ here explicitly so the next review finds the ruling rather than the
+ apparent contradiction.
+- **Decision 10 says memoria never does arithmetic.** A mod-97 check
+ digit is arithmetic, and it is not a number memoria reports — it decides
+ whether a string is an IBAN at all. Decision 10 governs numbers in
+ answers. A checksum is a predicate. Also stated rather than left to be
+ found.
+
+**Draft 1's own example failed draft 1's own validator**, which is the
+review finding worth keeping visible: `+353 (0)1 547 7854` is not E.164
+— E.164 has no spaces and no `(0)` — so "validated by E.164" was a rule
+the motivating case did not pass. The table above now says *canonicalises
+to* E.164, which is what was always meant and is what the code will do.
+
+#### The label, and the sentence it comes from
-**Zero candidates renders nothing at all** — not "no phone number found".
-The corpus is not the world, and a confident absence is this codebase's
-own worst failure shape. The results below already say what was searched
-and how much of it there is.
+**The label is taken from the document and is never composed.** Three
+sources, in order, first match wins:
-#### Ambiguity enumerates here, where §8.7 refuses
+1. **The run back to the start of the enclosing sentence or line, if it
+ ends in a separator** (`:`, `—`, `-`, `=`, tab) and is **60 characters
+ or fewer**. `Office: +353 …` yields `Office`. So does *"The office
+ phone number is: +353 …"*, yielding `The office phone number is`.
+2. **The chunk's heading trail** — `Tito › Contact`.
+3. **The document title**, for an upload with no headings at all, which is
+ the common shape for a pasted file and must not fall through to an
+ empty label.
+
+**Draft 1 required the value to be at the start of its own line and capped
+the label at 40 characters, and the repo's own fixture is the case that
+breaks.** `index/rank_test.go:56` — built from the live instance where
+Paul hit this — holds one chunk reading:
+
+> *"Turn the knob. The office phone number is: +353 (0)1 547 7854. We
+> also have the following unused phone numbers: +353 (0)1 598 1234."*
+
+Under draft 1 both numbers are mid-paragraph, both fall through to the
+heading trail, and both come back labelled `Office Address`: two
+identical labels, one of which the document explicitly calls **unused**.
+The box would have shown both as equals while the disambiguating word sat
+one clause away, unread. That is §8.7's *"cites correctly while being
+wrong"* in a lookup coat, and draft 1's "never by picking" made it
+unfixable from inside.
+
+Sentence-anchored labelling fixes it by doing less, not more: the two
+candidates now carry *"The office phone number is"* and *"We also have
+the following unused phone numbers"*, and the reader can see which is
+which because the document said so. Nothing was summarised, ranked or
+chosen.
+
+**The sentence split is naive and conservative on purpose** — a full stop,
+question mark or exclamation mark followed by a space, or a newline. It is
+not a sentence tokeniser and must not grow into one; where it gets the
+boundary wrong the run fails the separator test and the label falls to the
+trail, which is the safe direction.
+
+**60 characters is a guess.** What is not a guess is that it must clear
+the fixture above, whose longer label is 47.
+
+Nothing here title-cases, expands, truncates or rewords. A label the
+document did not write is a label memoria wrote.
+
+So Paul's example renders as `The office phone number is — +353 (0)1 547
+7854`, under the heading trail, carrying the same
+`/documents/{id}#p{n}` link every result carries.
+
+#### Candidates, ambiguity, and the cap
+
+**A candidate is one canonical value.** The same number in five forwarded
+signatures is **one** candidate, cited to the best-ranked passage carrying
+it — not five, and not a reason to pass the cap. Two different numbers in
+one chunk are **two** candidates; the fixture above is exactly that shape,
+and treating a chunk as the unit would have hidden the second one.
§8.7's slot rule is *"two or more → ambiguity, never extra evidence, and
-never collapsed"*, and it resolves that by showing no number. **The
-lookup box shows all of them instead, and the difference is not
-inconsistency.**
-
+never collapsed"*, and it resolves that by showing no number. **The fact
+box shows all of them instead, and the difference is not inconsistency.**
An average built from two of three operands is a *wrong number*: the
question had one answer and memoria produced a different one. Two phone
-numbers in a corpus are two *right answers* to an underspecified question.
-§8.7's ambiguity is incompleteness, which can only be refused; this
-section's is underspecification, which is honestly served by showing the
-reader both, labelled and cited, and letting them see that the corpus
-holds two.
+numbers are two *right answers* to an underspecified question. §8.7's
+ambiguity is incompleteness, which can only be refused; this one is
+underspecification, which is honestly served by showing both, labelled and
+cited.
**Never by picking.** Not highest BM25, not first, not longest. The rank
-ordering exists to sort a result list and carries no claim about which
-phone number is the office one.
+ordering sorts a result list and carries no claim about which number is
+the office one.
+
+**Two candidates that would render the same label render no box.** This is
+the guard behind the sentence-anchored rule rather than a replacement for
+it: where both fall through to the same heading trail, the page would show
+one label twice against two different values, which is not an enumeration
+but a contradiction with no way for the reader to resolve it. The results
+list, with its snippets, is the honest surface for that.
+
+**Capped at three inclusive: three renders, four renders nothing.** A box
+enumerating twelve numbers is the result list with extra steps and a false
+air of authority.
+
+#### Where it sits on the page
+
+Inside the `results` template definition, not above it. `results.html` is
+rendered two ways from one definition — inside the page on a scriptless
+submit, alone on `GET /search` for `search.js` — and a box outside it
+would sit above a fragment that swapped underneath it, leaving query N−1's
+answer over query N's results on every keystroke. That is §8.4 inverted,
+several times a second.
+
+**Below §6.5's notices and above the list.** `results.html` already puts
+the withholding callout above the results because *"a reader who scrolls
+past the last result has already decided what the page means"*. A
+confident single answer rendered above the line saying the page is partial
+is the same mistake with the order reversed.
+
+**Search page only.** `/documents/{id}` renders a whole document and
+answers no query; decision 14 makes those the only two passage surfaces
+and this is a feature of one of them.
-**Capped at three, and above the cap there is no box.** A box enumerating
-twelve numbers is the result list with extra steps and a false air of
-authority; at that point the honest surface is the one already below it.
+**Zero candidates renders nothing at all** — not "no phone number found".
+The corpus is not the world, and a confident absence is this codebase's
+own worst failure shape.
+
+**Gate 14's first clause extends to the box** — every character of it
+traces to a cited passage. **Its second does not.** "An all-dropped answer
+says so rather than rendering empty" exists because an empty 🤖 paragraph
+is indistinguishable from AI being off; a box that does not appear is
+indistinguishable from nothing, which is what it means. Draft 1 extended
+gate 14 whole and contradicted itself two paragraphs earlier.
#### Injection, and the one place ambiguity pays for itself
§8.5's mitigation for a poisoned document is that every claim carries an
-openable citation, and that it is a partial one. The box makes that
-sharper, because it promotes a single document's assertion to the top of
-the page with a validated-looking format around it. `Office phone: +353 1
-000 0000` in an uploaded file is a lookup box, and no model was involved
-in believing it.
+openable citation, and that it is a partial one. The box sharpens the
+problem: it promotes a single document's assertion to the top of the page
+with a validated-looking format around it. `Office phone: +353 1 000 0000`
+in an uploaded file is a fact box, and no model was involved in believing
+it.
-Two things bound it, and neither is a fix:
+Two things bound it and neither is a fix:
- The value is a cited span of a passage that passed §4.1, so the reader
- can open it, and a poisoned document is a document somebody put in this
- corpus.
+ can open it, and a poisoned document is a document somebody in this team
+ put in this corpus.
- **The ambiguity rule fails safe against poisoning.** A planted second
number does not replace the real one and does not outrank it — it makes
- the box show both, or vanish past the cap. The attack that works against
- a single-answer box degrades into a visibly contradictory page here, and
- that is a property worth keeping rather than an accident to optimise
- away.
-
-#### One stated limit
-
-**The box only sees what retrieval returned.** A fact in a document that
-does not reach the ruled top-25 is a fact the box does not have, and the
-page will show results without it. The ingest-time facts are what make
-this rare — an unlabelled eircode is reachable by the word "eircode" that
-is nowhere near it — but the facts column is weighted to buy
-**reachability, not rank** (see `FactWeight`), so a large corpus can still
-push the fact-bearing chunk off the page.
-
-Named rather than hidden, with its trigger in §12: a second,
-kind-restricted retrieval pass when the lookup class fires and the ruled
-results carry no candidate. It is not built now because it is a second
-query on the hot path bought for a case nobody has yet observed.
-
-#### Gates
-
-**34. The box never speaks a withheld passage, and the count happens
-after the ruling.** Two documents from a sibling, each carrying a
-different validated eircode. Presence first: with both `VISIBLE`, the box
-renders and shows two candidates. Then rule one `NOT_VISIBLE` — the box
-shows exactly the surviving one, as a single answer, and §6.5's notice is
-on the page. Then rule both `NOT_VISIBLE` — **no box at all**, and the
-notice stands. Mutation: run extraction over `all` rather than over
-`page.Results` in `Store.rule`; the second case goes red on the count and
-the third on the box's existence. Assert the box's fields, not the
-rendered copy — §4.1's `Outcome.Record` lesson, where two dispositions
-rendered identical pages.
-
-**35. Two candidates are never collapsed to one.** Presence: one
-validated phone number, one box, one value. Add a second document with a
-different one: both are named, neither is dropped, and the higher-scoring
-one is not presented as the answer. Add two more: no box. Mutation:
-return `candidates[0]` when `len(candidates) > 1` and the second case goes
-red; raise the cap and the fourth does.
-
-**36. The displayed value is byte-identical to the document's.** Seed
-`+353 (0)1 547 7854` and assert that exact string reaches the box.
-Mutation: canonicalise on the display path — the direction the code
-already canonicalises for the index, so this is one line's difference
-and the gate exists because it is — and it goes red.
-
-**37. The label is the document's or the fallback's, never a fragment.**
-Three fixtures: a labelled line (`Office: …` → `Office`), a number
-mid-paragraph (→ the heading trail, not the preceding clause), and a
-label longer than the cap (→ the heading trail). Mutation: drop the
-mid-sentence condition and the second fixture yields a composed label —
-red.
-
-**Gate 14 extends to this box**: every character of it traces to a cited
-passage or to the fixed lexicon. Nothing in it is authored.
-
-**And the mutations above are the author's, which §1.9 says is exactly
-what does not count.** They are listed so a reviewer has somewhere to
-start, not as the pass. The gate review picks its own.
+ the box show both, or show nothing if the labels collide, or vanish past
+ the cap. The attack that works against a single-answer box degrades into
+ a visibly contradictory page, which is a property worth keeping rather
+ than an accident to optimise away.
+
+#### Sequencing
+
+**Slice 0**, with one exception. Recognisers, the facts table, the
+lexicon, the box and gates 35–38 need no sibling and no model, and the
+box is part of decision 5's floor. **Gate 34 needs slice 1**, because its
+fixtures are S2 dispositions; it is written with the rest and runs when
+`memoriatest`'s sibling stub can answer, which is now.
---
@@ -3757,12 +3914,110 @@ The gates:
are distinguishable after five submissions, and a refused address
consumes no per-IP budget either. The budget is inside signin and not
reachable from here; it closes with the upstream fix.
+34. **The fact box never speaks a withheld passage, and the count happens
+ after the ruling** (§8.8). Needs slice 1's S2, which is the only gate
+ here that does. Four fixtures, and the shape of each is chosen so the
+ author's own mutations are not the ones it rests on:
+
+ - **Presence.** Two sibling documents, two different validated
+ eircodes, both `VISIBLE`. The box renders and carries **two**
+ candidates. Without this the three absences below are assertions
+ about an empty world — §10's second rule.
+ - **`NOT_VISIBLE` is silent.** Rule one of them `NOT_VISIBLE`. The box
+ shows **exactly the survivor, as a single answer, and `Notices` is
+ empty.** Draft 1 of §8.8 demanded a notice here, which §4.1's table
+ forbids and `Outcome.Record` will not produce — a gate that could
+ only be made green by building the existence oracle §4.1 exists to
+ deny. The empty-notice assertion is now the load-bearing half.
+ - **`UNKNOWN` is loud.** Same fixture, `UNKNOWN` instead: the box
+ shows the survivor **and** §6.5's notice is on the page. This is
+ what draft 1 meant and attached to the wrong disposition.
+ - **The cap is counted after the ruling too.** Four documents, four
+ distinct values, one `NOT_VISIBLE` — the box shows **three**. Under
+ "extract over the ruled set, count over the unruled one" the reader
+ loses the box entirely because of a document they may not see, and
+ a two-document fixture cannot tell the two implementations apart.
+
+ **Assert the struct, not the page.** Each case asserts the candidate
+ tuple `(display span, DocumentID, Ordinal)` and the notice slice —
+ never the rendered copy. Two dispositions rendering an identical page
+ is how `Outcome.Record` filed silent rulings undetected (§1.9), and a
+ citation cross-wired to the withheld document survives any assertion
+ that reads the value alone.
+35. **Candidates are canonical values; ambiguity is enumerated, never
+ collapsed; the cap is three** (§8.8). One gate because the three
+ rules share a definition and a gate that splits them cannot see the
+ interactions:
+
+ - one validated number → one box, one value **(presence)**;
+ - a second, different number → **both named**, neither dropped, and
+ the higher-scoring one not presented as the answer;
+ - **three → still a box. Four → none.** Both, because a cap written
+ `>= 3` passes a suite that tests only one, two and four;
+ - **the same number in five documents → one candidate and a box**,
+ cited to the best-ranked passage. This is the commonest real shape
+ — a signature repeated through forwarded mail — and an
+ implementation counting passages rather than values kills the box
+ on it;
+ - **two numbers in one chunk → two candidates.** `index/rank_test.go`'s
+ office fixture is exactly this, and an implementation counting
+ chunks silently hides the second;
+ - **two candidates whose labels collide → no box.**
+
+ Mutation: return `candidates[0]` when there is more than one, and case
+ 2 goes red. Count passages instead of canonical values and cases 4 and
+ 5 go red in opposite directions, which is the pair worth having.
+36. **The displayed value is byte-identical to the document's** (§8.8).
+ The canonical form exists for matching and must never reach the page.
+ Three fixtures, and the second and third are the ones an author does
+ not think of: `+353 (0)1 547 7854`; the same number followed by
+ `ext. 12`; and one written with a non-breaking space. Assert the exact
+ bytes of the span in every case. Mutation: normalise ` ` to a
+ space before matching **and display the normalised span** — the shape
+ a recogniser naturally grows, and invisible to a fixture using only
+ ASCII spaces.
+37. **The label is the document's, or the fallback's, never a fragment**
+ (§8.8). Five fixtures: a labelled line (`Office: …` → `Office`); a
+ value mid-paragraph after a separator (→ the sentence-anchored run,
+ which is the fixture `rank_test.go` already holds and the case draft
+ 1 got wrong); a value mid-sentence with **no** separator before it
+ (→ the heading trail, never the preceding clause); a run one
+ character over the 60-cap (→ the heading trail); and an upload with
+ **no headings at all** (→ the document title, never the empty
+ string). Mutation: drop the separator condition and fixture 3 yields a
+ composed label; drop source 3 and fixture 5 yields `""`, which every
+ fixture carrying a heading would have hidden.
+38. **The facts table does not disturb the main ranking, and it reaches
+ what the main index cannot** (§8.8). The two halves of the retrieval
+ design, and the first is the one draft 1 would have failed:
+
+ - **Reachability, the presence half.** A chunk whose only connection
+ to the word `eircode` is that it contains one is returned for the
+ query `eircode`; a phone number written `+353 (0)1 547 7854` is
+ returned for `015477854`. Without this the whole ingest-time half
+ is unasserted.
+ - **Non-interference, measured.** Re-run `TestOrRecallStillRanksBestMatchFirst`
+ and the `NEAR` measurement in `index/rank_test.go` with the facts
+ table populated, and assert the scores are **unchanged**. Draft 1
+ folded kind words into the main index, where FTS5's table-wide IDF
+ would have diluted `phone` across every chunk carrying a number —
+ degrading the exact query that comment works through. A separate
+ table is what makes this assertable at all.
+
+ Mutation: index the kind words into the main table's body column and
+ the second half goes red on the recorded scores; drop the canonical
+ form and the first half's second case goes red.
## 11. Sequencing
**Slice 0 — buildable today, no contract, no blockers.** Scaffold, sealed
store, §7 proven end to end: chunk, seal, wake, BM25 search, scriptless
-UI with citations, gates 1–4 and 15. Content by upload and paste only, so
+UI with citations, gates 1–4 and 15. **§8.8's fact box lands here too**,
+added 2026-09-14: recognisers, the in-memory facts table, the kind
+lexicon, the box, and gates 35–38, none of which needs a sibling or a
+model. Gate 34 is the exception — its fixtures are S2 dispositions — and
+it is written with the rest and runs against slice 1's stub, which
+exists. Content by upload and paste only, so
§4.2 governs and no person ref is needed. **No embeddings, therefore no
egress at all.** This is the design Docs §15 item 2 deferred for want of
one, so proving it unblocks Docs regardless of memoria's fate.
@@ -3827,8 +4082,8 @@ commit. Fixed 2026-09-05; §1.9's lesson arriving inside the fix for §1.8.*
| **A source-of-record surface, so a hand-off can include memoria's own material** | Someone wants "save this answer as a Doc" to work for answers built on the team's own uploads. Suite §7 makes the *receiver* check every source with its app of record, and memoria serves nobody to ask (decision 7), so such a hand-off is refused today — §9.1. Opening it needs its own decision, its own §3.2 leak entry and its own gate, and it is Paul's: decision 7 is settled |
| Calendar as a source | Calendar has a spec and content; bodies off by default even then |
| Mail beyond forward-or-label | Never by sweep. A mail repo exists **and** a per-thread audience maps onto a team |
-| **A kind-restricted retrieval pass for §8.8** | The lookup class fires, the ruled results carry no candidate, and somebody reports a fact they can see in a document the box did not surface. A second query on the hot path, bought for a case nobody has observed |
-| **Postal address as a lookup kind** | Decision 15 keeps it out because there is nothing to validate. The two openings are an anchored one — an eircode certifies the lines above it, deterministically — and §8.2's extractive single-source class, which is a model and therefore a decision-15 amendment |
+| **Postal address as a fact kind** | Decision 15 keeps it out because there is nothing to validate. The two openings are an anchored one — an eircode certifies the lines above it, deterministically — and §8.2's extractive single-source class, which is a model and therefore a decision-15 amendment |
+| **A durable, sealed facts table** | Recogniser improvements need to reach old documents without a re-ingest, or the per-query extraction shows up in a profile. §8.8 rejects it today on derived-state and staleness grounds, not on leak grounds — the leak argument draft 1 gave attacked an unsealed design nobody proposed |
| Cached answers | Query cost becomes visible. Requires §5.2's provenance invalidation first. **Triggering this expires §8.4's "stored nowhere"** |
| A conversational surface | Someone wants one, and it keeps §8.4's invariant: no passage crosses a request, in a prompt or a response. Decision 14 chose the shape on decision 5's grounds, not decision 3's, so this is a product argument to have — not a rule to break. §6.5 affords it: a four-turn transcript re-authorizes in one batch, one round |
| A runtime model-call ledger | Someone needs to audit routing. Sealed and provenance-stamped when it lands (§8.3) |
@@ -3840,10 +4095,24 @@ commit. Fixed 2026-09-05; §1.9's lesson arriving inside the fix for §1.8.*
## 13. Open
-**Four items need Paul and are marked `NEEDS PAUL`:** the 12-hour session
-TTL, §8.7's unit-override switch, the CI runner's repo list, and the
+**Five items need Paul and are marked `NEEDS PAUL`:** the 12-hour session
+TTL, §8.7's unit-override switch, the CI runner's repo list, the
disclosure route for the rastrillo SSRF (`SECURITY-DRAFT-rastrillo-signin.md`,
-unfiled). **Nothing else here is a ruling; the rest is work, measurement
+unfiled), and **decision 15's scope**.
+
+ - **Decision 15 records more than Paul ruled, and says so rather than
+ quietly widening.** What he asked for on 2026-09-14 is the box, and
+ the constraint — *"do as much deterministic ordering of the data
+ before we go doing inference on it"*. The **"ever"** is the spec
+ author's: read strictly it forecloses §8.2's cheap extractive class
+ ever filling a box for a kind with no validator, which is the
+ obvious next request and the natural home for a postal address. The
+ validator rule, the five kinds and dropping `url` follow from the
+ strict reading. If Paul wants the weaker version — deterministic
+ first, a model second where nothing validates — that is one row in
+ §12 promoted and decision 15 amended, not a redesign. The gate
+ review raised this as §1.9's failure repeating: the load-bearing
+ half of a decision living in a commit message. **Nothing else here is a ruling; the rest is work, measurement
or somebody else's repo.**
*Struck items are kept with their reasoning rather than deleted. Several
@@ -4224,10 +4493,7 @@ on 2026-09-06 when the branches merged.
complex reasoning."* — not the obligation to measure it, which came from
the code review and stands. It reads §8.2 right: three of five classes
call no model, and §8.7 sends arithmetic to Sheets, so a model sees only
- what survives retrieval, filtering and authorization. *(That count was
- right when he said it. §8.8 made it four of six on 2026-09-14; the
- sentence is left as spoken, per this section's rule about struck items,
- and the ratio moved in the direction the argument wanted.)*
+ what survives retrieval, filtering and authorization.
- **Two tiers stay, and so does the measurement.** The spec asked whether
the frontier tier was a habit. Paul ruled on 2026-09-05 that two tiers
make sense *and* that measuring makes sense — two answers, not one, and