oficina / memoria Public
File too large to show in full — download it above (showing a truncated preview below).
| 1 | # 🤖 memoria — design |
| 2 | |
| 3 | The 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 |
| 7 | sharing doctrine (Appendix A.4), the TensorX provider decision, §8.4's |
| 8 | shape, and slice 1's client half. Not draft 6: draft 6 is the eleven |
| 9 | draft-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 |
| 14 | not yet reflected here as a version bump: revision 8 (the module's |
| 15 | importability, now closed) and **revision 10, which adds `AHEAD`** (§4.1). |
| 16 | The module this repository pins is `60b01a8`, which is revision 8's merge. |
| 17 | Gate 9 fails a spec more than one revision behind, so this citation is |
| 18 | owed a bump the moment the suite's branches merge; they cannot, because |
| 19 | suite CI is unconfigured (§13). Rulings 1, 3, 4, 6, 7, 9, 14 and 15 are |
| 20 | cited and never restated (suite ruling 7) — 15 at §5's custody mark, which |
| 21 | is where "only the `team` rung is team-visible, so custody keys on the rung |
| 22 | and 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 |
| 24 | material — and S8, which needs a designated-area grant memoria has no UI |
| 25 | for. S6 is cited as a specification; suite §12 step 4 builds it. |
| 26 | |
| 27 | Review history, per AGENTS.md §Review. None of these models wrote the |
| 28 | draft 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 |
| 44 | review changed, §1.4 what the review of the built code changed, §1.5 what |
| 45 | the 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 |
| 47 | shape, the gate review and the doctrine adoption — which ran in parallel |
| 48 | on its own branch and merged on 2026-09-06. |
| 49 | **Draft 5 has been reviewed once, with the attribution caveat in the table |
| 50 | above, and returned REQUEST CHANGES. Its eleven required changes are NOT |
| 51 | yet fixed** — they are recorded in §13 under "Draft-5 review, not yet |
| 52 | fixed", the same way draft 3's ten were, rather than quietly carried. |
| 53 | Draft 6 is those eleven, and four of them wait on the NEEDS PAUL rulings. |
| 54 | |
| 55 | Numbers in §7 are now measured rather than asserted, and recorded in |
| 56 | `docs/BENCHMARKS.md` with the machine and command. Doing that found draft |
| 57 | 2'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 | |
| 66 | Draft 1 said *"memoria indexes only what the whole team can already see, |
| 67 | and it never speaks a passage without re-checking that this is still |
| 68 | true"*. Both reviewers showed that sentence does not survive contact with |
| 69 | the suite: "the whole team" is not one fact (§1.2 gap 1), and the |
| 70 | re-checking was specified for generated answers but not for ordinary |
| 71 | search (§1.2 gap 2). |
| 72 | |
| 73 | The rule above replaces it, and it splits by provenance because the suite |
| 74 | does: |
| 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 | |
| 88 | The second rule is Sheets' rule, unchanged: **the bucket never holds a |
| 89 | plaintext cell.** For memoria this binds harder than it looks, because an |
| 90 | embedding 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 |
| 114 | than restating them.** Decision 1 is **suite ruling 1**, decision 3 is |
| 115 | **ruling 3**, decision 4 is **ruling 6**. They are unchanged in substance; |
| 116 | what changed is where they live. Suite ruling 7 is why: every app spec |
| 117 | restating the contract in its own words is what drifted, so an app spec |
| 118 | cites the doctrine and never restates one. Where a row below still carries |
| 119 | the reasoning, that is history and not a second copy — the binding text is |
| 120 | the doctrine's, at `oficina/suite`, revision 7, commit `2b78680`. |
| 121 | |
| 122 | **Decision 11 is superseded** by ruling 4, and decisions 13 and 14 are |
| 123 | memoria's own and stand: suite §8 adopts 13's "the recipient is the app's, |
| 124 | never the team's", and ruling 3's "never cached, at any scope" is the |
| 125 | invariant 14 rests on. |
| 126 | |
| 127 | ### 1.1 The proposal review, and what it killed |
| 128 | |
| 129 | Before draft 1 existed, the *proposal* — ingest everything, store a |
| 130 | per-viewer copy, route to whichever model OpenRouter picks — was reviewed |
| 131 | and rejected on six counts. Four attached to one choice, *store a |
| 132 | per-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 |
| 153 | this section; it is **not** in any repo file, so a reader cannot open it. |
| 154 | Draft 1 cited it as though it were a document. Nothing here should rest |
| 155 | on it that is not also argued here. |
| 156 | |
| 157 | ### 1.2 What the two reviews changed |
| 158 | |
| 159 | Three 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 |
| 163 | forbidden from being the authority (home §2.2: "Never holds an |
| 164 | authorization model") and no home call carries membership between |
| 165 | instances. 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 |
| 167 | did nothing to memoria, and memoria kept serving them Sheets content. |
| 168 | Draft 1's S2 asked *"is item X team-visible?"*, never *"is this asker |
| 169 | entitled to it?"*. |
| 170 | |
| 171 | The 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 |
| 175 | owning app *"can person P see items X at revision R?"*, and that app |
| 176 | answers from the grants join it already maintains. Membership and |
| 177 | visibility become one question answered by the only correct evaluator, so |
| 178 | there is nothing left to reconcile. |
| 179 | |
| 180 | **Gap 2 — the search floor bypassed the gate.** `GET /query` returns |
| 181 | passages with or without `answer=1`, and nothing required the |
| 182 | passage-only path to call S2. §0's promise therefore held for generated |
| 183 | answers — the smaller half of the product — and not for search, which |
| 184 | decision 5 calls the product itself. **Fixed by §8.1:** one gate, both |
| 185 | paths. |
| 186 | |
| 187 | **Gap 3 — external sources had no owner to ask.** Drive, Notion, mail and |
| 188 | uploads cannot map their native ACLs onto team-visibility, and draft 1 |
| 189 | gave them no re-check at all; revocation surfaced only on a later |
| 190 | enumeration 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 |
| 195 | memoria owns it outright, and §4.2 says so instead of implying a re-check |
| 196 | that cannot exist. Drafts 1–4 wrote that fix as "its own grants are |
| 197 | authoritative rather than a copy". That sentence was the defect it was |
| 198 | written to remove, one section over: it names a per-person grants table, |
| 199 | memoria has none, and §4.2's authority is admission plus presence. |
| 200 | |
| 201 | Beyond the three, both reviews found the same arithmetic errors (§7), the |
| 202 | same 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 |
| 205 | that the README was false about egress volume (§3.2) — the single most |
| 206 | product-damaging finding of the two. |
| 207 | |
| 208 | ### 1.3 What the draft-2 review changed |
| 209 | |
| 210 | Draft 2 was reviewed by GPT Sol via Codex. It confirmed five fixes as |
| 211 | answered (the search bypass, the pull/push contradiction, the barrier |
| 212 | count, the rotation protocol, the corpus-count inconsistency) and found |
| 213 | one 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.** |
| 216 | memoria declares the suite one trust domain and pulls plaintext bodies |
| 217 | from siblings. Three source specs currently forbid exactly that — home |
| 218 | §2.2 *"Never federates. Data does not cross an instance boundary."*, |
| 219 | Sheets PRODUCT.md principle 3 *"Never design a flow that implies data can |
| 220 | move to another instance"*, Docs §8.2 *"'Sharing is membership' forbids |
| 221 | data crossing the instance boundary."* **memoria cannot overturn another |
| 222 | app's settled decision in its own table.** Decision 1 is Paul's ruling and |
| 223 | stands, but it binds Oficina only once the source specs adopt it. Threads |
| 224 | are open (§13); until they are answered, slice 1 is blocked on agreement, |
| 225 | not on code. |
| 226 | |
| 227 | **"Nothing left to reconcile" was still false.** S2 fixed sibling |
| 228 | content, but admission used memoria's own members table and so did |
| 229 | memoria-owned content — so a person removed from Sheets and Docs, and left |
| 230 | in 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 |
| 235 | plausible-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 |
| 239 | asker, yet claimed the sibling "re-runs its grants join at fetch time". |
| 240 | There was no person in the call. **Fixed by deleting the claim, not by |
| 241 | adding a person:** S3 takes no asker, and §6.1 spells out why — custody is |
| 242 | person-less, so the call that fills the corpus can be made at all, and a |
| 243 | per-person S3 would have made ingest impossible. |
| 244 | |
| 245 | *This paragraph said the opposite until 2026-09-05 — "S3 carries the |
| 246 | person ref too" — while §6.1 three sections later said it does not. Both |
| 247 | could not be right, and the summary was the wrong one. Caught by the suite |
| 248 | doctrine's review of memoria (A.4), which is the third time in this |
| 249 | document that a §1 summary and its own body have disagreed.* |
| 250 | |
| 251 | **Derived artefacts had no algorithm for external or mixed provenance** — |
| 252 | S2 exists only at siblings. **Fixed** in §5.2. |
| 253 | |
| 254 | **Restore had no durable deletion state.** Replaying tombstones from a |
| 255 | restored backup replays a log that predates the deletion, and retiring the |
| 256 | superseded snapshot leaves a restored database pointing at a blob that is |
| 257 | gone. **Fixed** in §7.4. |
| 258 | |
| 259 | **Live authorization had no availability design** — no deadline, no |
| 260 | concurrency policy, no partial-result contract, on the product's primary |
| 261 | path. **Fixed** in §6.5, and it is the finding most likely to change the |
| 262 | product rather than the spec. |
| 263 | |
| 264 | **The gates were weaker than the claims.** Gate 5 proved an |
| 265 | authorization call happened, which stays green with a constant `VISIBLE` |
| 266 | or the wrong person ref. **§10 rewritten** accordingly. |
| 267 | |
| 268 | One finding was withdrawn by the reviewer on its own second look (S2 |
| 269 | transport failures needing a separate finding), and its substance is |
| 270 | folded into gate 6 anyway. |
| 271 | |
| 272 | ### 1.4 What the code review changed |
| 273 | |
| 274 | Slice 0 was built, then reviewed by Fable — the first review of the code |
| 275 | rather than the design. It found the spec broadly honest and the sealed |
| 276 | store real, and the gates that were supposed to prove it mostly unable to |
| 277 | fail. Three consequences reach this document. |
| 278 | |
| 279 | **§3.2 gained two entries.** The sign-in dial, which made a claim in §3.6 |
| 280 | false; and the `external_id` oracle, which put a plaintext-derived |
| 281 | identifier in a column §3.2 described as coordinates. |
| 282 | |
| 283 | **§3.6's AI-off sentence was wrong.** "Nothing leaves this instance" is |
| 284 | not 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 |
| 288 | headings in the commonest Markdown form. The spec now describes the |
| 289 | chunker that exists. |
| 290 | |
| 291 | The general lesson, and it belongs here because §10 is built on the |
| 292 | opposite assumption: **three of five gates marked "mutation-tested" could |
| 293 | not fail for the thing they named.** A gate is a claim about the world, |
| 294 | and an unmutated gate is an unverified claim — including, twice on this |
| 295 | branch, a gate whose subject had been deleted while it stayed green. |
| 296 | |
| 297 | ### 1.5 What the draft-3 review changed |
| 298 | |
| 299 | Draft 3 was reviewed by Fable: 22 findings, REQUEST CHANGES. Ten were |
| 300 | recorded in §13 as acknowledged and not fixed rather than quietly |
| 301 | carried, and draft 4 is those ten: **six weak predicates, two plain gaps, |
| 302 | one warm-up that could not be made, and one correction made inside the |
| 303 | draft itself.** Draft 4 wrote "those ten" over a list that added to nine, |
| 304 | because the warm-up and the correction were folded together; they are two |
| 305 | items and are counted as two below. |
| 306 | |
| 307 | **Six were a predicate that could not fail.** `MEMBER` meant "known to |
| 308 | this app", so a guest satisfied it. `MIXED_UNITS` could not fire on three |
| 309 | cells formatted `General`, so a wrong average got certified. The circuit |
| 310 | breaker's "*everything*" fired on the routine case, so its notice would |
| 311 | be learned and ignored before the day it was true. Gate 2 named "a |
| 312 | threshold" and never gave one. Gate 4's credential half asserted the |
| 313 | absence of a link the test app never sends. The S7 cache had no TTL, so |
| 314 | it took `rastrillo/auth`'s 30 days by omission. Each is AGENTS.md's rule |
| 315 | about guards written to pass, arriving in a different costume — and the |
| 316 | missing TTL is the other rule as well: a default is not a decision, the |
| 317 | way 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 |
| 320 | certified rather than contradicted (§8.7). Admission requires a positive |
| 321 | statement of team membership, and a sibling that cannot make that |
| 322 | statement admits nobody (§4.3). A gate asserting an absence must first |
| 323 | assert the presence of the thing whose absence it checks (§10). Where a |
| 324 | number 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 |
| 327 | sibling that is *behind* memoria, which a restore produces and which fell |
| 328 | through to the one silent value — fixed by `AHEAD` (§4.1). And derived |
| 329 | provenance 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 |
| 333 | warm-up `S7` on its own wake. `S7` takes a person ref and there is nobody |
| 334 | there on a wake, so no fixture could ever have received that call. The |
| 335 | warm-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 |
| 339 | warm-up error is draft 2's per-person `S3` again, made three sections |
| 340 | after the correction for it was written, in the same document, by the |
| 341 | same author. It is counted separately because it is a different kind of |
| 342 | finding: the item above is a call that has to change, this one is the |
| 343 | observation that writing a correction does not inoculate the next |
| 344 | section against the mistake it corrects. §10's two rules exist because of |
| 345 | it. |
| 346 | |
| 347 | ### 1.6 What the draft-4 review changed |
| 348 | |
| 349 | Draft 4 was reviewed by Claude Fable 5.1, which wrote none of the drafts: |
| 350 | REQUEST CHANGES, 16 required changes. Draft 5 is those sixteen. Four |
| 351 | groups, and the first is the one that matters. |
| 352 | |
| 353 | **A fix that renamed its problem instead of removing it.** Draft 4 |
| 354 | rewrote §8.1's circuit breaker because draft 3's fired on the routine |
| 355 | case, and the rewrite's prose promised "a property of the sibling, not of |
| 356 | one asker" — while its predicate still counted zero `VISIBLE` across |
| 357 | whoever happened to query that day. A 24-hour outage of `UNKNOWN`s |
| 358 | therefore tripped the *refusal* breaker; three Docs-only members querying |
| 359 | on 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 |
| 361 | is the third time this project has shipped prose that claimed more than |
| 362 | its 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 |
| 366 | empty list. Draft 4 then specified no reconciliation: chunks at revisions |
| 367 | the sibling no longer has were withheld one at a time, forever, while |
| 368 | still consuming top-k — so a page of 50 could render nearly empty with no |
| 369 | notice, the cardinal sin arriving through the door `AHEAD` was cut to |
| 370 | close. §4.1 now specifies the re-announce as a complete statement, |
| 371 | retirement under gate 7's rule, revision override in either direction, |
| 372 | and the removal of a reconciling source from the ranking. Gate 27 is new |
| 373 | and holds all of it. |
| 374 | |
| 375 | **Three contradictions the drafts carried rather than resolved.** §8.7 |
| 376 | said both that S6 is called only when units are certified and that S6 |
| 377 | returns `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 |
| 379 | apart; the owners list is not a roster and decision 11 now carries it as |
| 380 | a stated exception. And "memoria's own grants are authoritative" survived |
| 381 | in three places after §4.2 established there are no per-person grants — |
| 382 | the 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 |
| 387 | presence assertion, gate 4 named a route from memory, gate 23's |
| 388 | concurrency half was always-red or never-red on its own fixture, gate 24 |
| 389 | raced 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 |
| 391 | fingerprints under the DEK — verified in `internal/memoria/store.go`, |
| 392 | which calls `s.sealer.Fingerprint`, and the sealer holds the DEK. A gate |
| 393 | that goes red on correct code is worse than no gate. |
| 394 | |
| 395 | ### 1.7 What the provider review changed |
| 396 | |
| 397 | The §8.3 amendment — OpenRouter out, TensorX in — was written by Opus and |
| 398 | reviewed by Fable, which returned REQUEST CHANGES with four MAJOR findings. |
| 399 | Two of them are the same mistake in different clothes, and it is the |
| 400 | mistake §1.4 is about: **stating a rule and then breaking it in the |
| 401 | paragraph that states it.** |
| 402 | |
| 403 | **§8.2 wrote a pinning rule its own selections failed.** The rule — a model |
| 404 | id that can move under a team without the team changing it cannot be |
| 405 | printed on an answer — is right, and generalises `openrouter/auto`'s |
| 406 | disqualification properly. Then it selected `moonshotai/kimi-k3` and |
| 407 | `z-ai/glm-5.3-flash`, neither datestamped, both plausibly aliases over |
| 408 | dated checkpoints. The fix is a datestamped id where one exists, gate 30's |
| 409 | canary where none does, and §13 recording that the vendor's id-immutability |
| 410 | policy is unread. The draft had even quoted the one datestamped id in the |
| 411 | catalogue as evidence — and it was the model it did not pick. |
| 412 | |
| 413 | **§8.3 claimed as established what §13 called untested**, three paragraphs |
| 414 | apart. Worse, the verification it proposed could not have established it: |
| 415 | the live call settles whether one model *name* is proxied, not whose GPUs |
| 416 | run the weights. "One contractual counterparty" is not "one organisation |
| 417 | sees the corpus" — a subprocessor sees content in §3.2's sense — so the |
| 418 | claim 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 |
| 421 | 4096→512 renormalise would be caught by watching recall fall. But cosine |
| 422 | similarity is scale-invariant: against a true cosine, skipping the |
| 423 | renormalise changes no ranking, and the gate stays green forever. The spec |
| 424 | had never said whether search computes cosine or a raw dot product. It now |
| 425 | says dot product — which is what §7.3's flop count always assumed — and |
| 426 | gate 28 asserts unit norm deterministically instead of inferring it from |
| 427 | quality. This is the fourth gate in this project to have been written in a |
| 428 | shape 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 |
| 431 | provider in §8.3 without recording the ruling in §1, which AGENTS.md says |
| 432 | is how a settled decision gets relitigated in a drive-by. |
| 433 | |
| 434 | The general lesson, distinct from §1.4's: **a rule and its first |
| 435 | application should be reviewed by different people.** Whoever writes the |
| 436 | rule has just persuaded themselves, and is the worst-placed reader to |
| 437 | notice that the very next paragraph fails it. |
| 438 | |
| 439 | ### 1.8 What the second provider review changed |
| 440 | |
| 441 | The §8.4 amendment — *memoria is a search page, not a chat* — was reviewed |
| 442 | by Fable, which returned REQUEST CHANGES with five MAJOR findings. The |
| 443 | ruling survived; the argument for it did not, and the way it failed is |
| 444 | worth more than the ruling. |
| 445 | |
| 446 | **The case was built on a misreading of this spec.** §8.4 claimed a chat |
| 447 | could not be built correctly, because re-authorizing a transcript each turn |
| 448 | would breach §6.5's deadline. §4.1 caps a batch at **256 pairs per call** |
| 449 | and §6.5 says **one page is one round of concurrent calls, the floor is the |
| 450 | slowest sibling and not their sum**. A four-turn transcript of eight |
| 451 | passages is 32 pairs: one batch, one round, the same floor as a fresh |
| 452 | query. A correct chat is affordable. The draft had also conceded the point |
| 453 | in the act of denying it — *"re-authorizing the whole transcript on every |
| 454 | turn 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 |
| 457 | authorized passages* — true, and equally true of a search results page left |
| 458 | open in a tab, and of `/documents/{id}`, which renders a whole document and |
| 459 | stays open indefinitely. That is a fact about rendered pages, not about |
| 460 | chat; 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 |
| 464 | prompt, without §4.1 in the request that carries it. That is decision 3 |
| 465 | applied to rendering, it is what §8.5 already said about the model, and |
| 466 | gate 32 asserts it against the prompt builder. Carrying query *text* |
| 467 | forward breaks nothing. |
| 468 | |
| 469 | **And the shape is a choice, so it is recorded as one.** Decision 14 rests |
| 470 | on decision 5 — the search floor is the product — not on decision 3. A |
| 471 | conversational surface that keeps the invariant is deferred to §12, to be |
| 472 | argued on its merits. The row is attributed to the spec author with Paul |
| 473 | unconfirmed, 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 |
| 475 | who *made* a ruling and who *approved* it are different facts.) |
| 476 | |
| 477 | The lesson, distinct from §1.4's unfailable gates and §1.7's rule broken in |
| 478 | the paragraph that states it: **a preference will always find a rule that |
| 479 | seems to compel it.** The search page was the right answer for |
| 480 | decision 5's reasons. Reaching past those for decision 3 made a choice look |
| 481 | forced, and cost two misread sections to do it. When a constraint arrives |
| 482 | conveniently, 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 | |
| 486 | The branch's third review covered gate 17 and the live-key verification. |
| 487 | Fable returned REQUEST CHANGES with three MAJOR findings, and one of them |
| 488 | is the most useful thing any review has done here. |
| 489 | |
| 490 | **It mutation-tested the gate itself.** Gate 17 shipped with a comment |
| 491 | naming three mutations, each verified red. A reviewer took the test into a |
| 492 | scratch copy and tried five more — all ordinary chi and `ServeMux` idioms, |
| 493 | none exotic — and **all five passed green.** `r.Route("/chat", …)` was |
| 494 | blessed by the declared `"/"` inside it. `r.With(mw).Post` was invisible |
| 495 | because the receiver is a call, not an identifier. `r.Delete` passed on a |
| 496 | path declared only for GET. `r.Handle("/")` bound every method at once. And |
| 497 | `mux.HandleFunc("/documents", …)` served a **declared** path while |
| 498 | bypassing `requireSession` — worse than an undeclared route, because the |
| 499 | allowlist actively approved it. |
| 500 | |
| 501 | **The rule that follows, and it belongs in AGENTS.md:** mutation-testing |
| 502 | your own gate tests the mutations you thought of, and those are the ones |
| 503 | you already defended against while writing it. Three green mutations chosen |
| 504 | by the author is not evidence the gate works; it is evidence the author is |
| 505 | consistent. **The mutations must be chosen by someone who did not write the |
| 506 | gate.** This is §Review's argument, one level down: the reviewer of a rule |
| 507 | should not be its author, and neither should the adversary of a test. |
| 508 | |
| 509 | The rewrite keys on method *and* path — which §10's own list had declared |
| 510 | all along and the test had not implemented — and separates the chi and |
| 511 | `ServeMux` namespaces. Eight mutations now, all verified. The rewrite also |
| 512 | nearly shipped its own bug: following an arbitrary selector chain to find |
| 513 | the receiver made `r.URL.Query().Get("err")` resolve to `r`, reporting two |
| 514 | query parameters as undeclared routes. Caught by running it. |
| 515 | |
| 516 | **The other two MAJORs are the familiar shape.** §8.3 still asserted |
| 517 | TensorX "runs the weights it serves" on the strength of a 403 that had no |
| 518 | control probe — the reading recorded was the middle of three, and the worst |
| 519 | one is that the model name is in their routing table and merely gated. And |
| 520 | two of Paul's four rulings were announced as recorded in QUESTIONS while |
| 521 | being in neither §1 nor §13, with the interpretation of the load-bearing |
| 522 | one living only in a commit message. That is §1.8's failure by omission: |
| 523 | the strong reading asserted where reviewers do not look. |
| 524 | |
| 525 | ### 1.10 What the adoption review changed, and where an adoption goes wrong |
| 526 | |
| 527 | A.4 was reviewed three times in the suite repo before memoria adopted it. |
| 528 | memoria's *application* of it was then reviewed once, by Fable, and came |
| 529 | back REQUEST CHANGES with a blocker and nine majors. The distribution is |
| 530 | the lesson. |
| 531 | |
| 532 | **The blocker was a rule invented while citing the section that forbade |
| 533 | it.** §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 |
| 535 | named asker and then answers `NOT_VISIBLE` for anything not team-visible. |
| 536 | memoria wrote instead that it would render a passage when S2 says |
| 537 | `VISIBLE` **and** the item was team-visible *when acquired* — a rule that |
| 538 | is **vacuous** (everything memoria holds came through S3, which serves |
| 539 | only team-visible items, so the second half is true of every held item), |
| 540 | that **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. |
| 543 | The doctrine had explicitly retracted that reasoning — *"a later |
| 544 | visibility change is exactly what the defect turns on"* — in the section |
| 545 | memoria cited for it. |
| 546 | |
| 547 | **The other eight majors are one shape: the commit checked what it |
| 548 | opened.** S7 was retired in §1 and §4.3 and left alive in the §6.1 |
| 549 | contract table, in §6.5's warm-up, in §4.1's "this subsumes membership", |
| 550 | in §2's members table and in five §13 items still listed as open after |
| 551 | this commit closed them. §11 corrected the home dependency in one |
| 552 | paragraph and reasserted it twelve lines below, in the same commit. The |
| 553 | Docs §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 |
| 558 | risk in an adoption commit is concentrated in the sections you did *not* |
| 559 | open. A rewritten section has been read twice; an untouched one that |
| 560 | depended on the old rule has been read never, and it now contradicts the |
| 561 | new one while looking untouched and therefore fine. **Grep for the retired |
| 562 | thing by name before claiming it is retired** — `S7`, `person_ref`, |
| 563 | `members table`, `forbidden by §6.4` — and read every hit. |
| 564 | |
| 565 | Two smaller ones worth keeping. §4.1 had inverted *why* the asker subject |
| 566 | is typed, saying the type is fixed at configuration because instances are |
| 567 | provisioned together — which is the reasoning suite §3 names and rejects, |
| 568 | "an operational habit, not an enforced property". And gate 21 claimed its |
| 569 | fixtures had been replaced; there were never any fixtures, the gate does |
| 570 | not exist, and the entry now says so. |
| 571 | |
| 572 | **A postscript, because the same mistake then happened twice more in one |
| 573 | hour and neither instance was caught by a rule that exists to catch it.** |
| 574 | On 2026-09-06 this branch merged a local `main` that was **five commits |
| 575 | behind the remote**, having compared the wrong ref; in the same hour the |
| 576 | suite session read `617eb2a` — this branch's base — as memoria's `main` |
| 577 | and concluded draft 5 was unmerged, then made a doctrine ruling partly on |
| 578 | that belief. Remote `main` was `9b55ab8` and carried draft 5 the whole |
| 579 | time. Two sessions, two directions, one unrun command: **fetch before you |
| 580 | read a ref you did not just write.** It is in suite AGENTS.md already, |
| 581 | which is the point — a rule nobody runs is a rule that catches nothing, |
| 582 | and 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 |
| 585 | and is the more useful of the two: cite the ref, not the claim.** A claim |
| 586 | arriving from a peer gets *less* scrutiny than one arriving from a file, |
| 587 | and it should get more — a file can be opened at the commit you name, |
| 588 | while a peer's claim carries no provenance at all. You cannot see which |
| 589 | checkout they read, whether they fetched, or whether they were quoting or |
| 590 | remembering. Both of this week's cross-repository mistakes have that |
| 591 | shape: a doctrine ruling made on something memoria told the suite, and a |
| 592 | memoria merge made on something the suite implied. **When you tell another |
| 593 | repository a fact about this one, give the commit it is true at. When one |
| 594 | tells you, check it before you rule on it.** |
| 595 | |
| 596 | **And a rider the suite session earned the hard way an hour later: citing |
| 597 | the ref is not enough if you read it with a tool that sees one line at a |
| 598 | time.** Its check of this fix grepped for a rule that spans two lines, got |
| 599 | zero matches, and came within one message of reporting that nothing had |
| 600 | been fixed. A false negative from a correct ref is worse than not checking, |
| 601 | because it arrives with the authority of having looked. Read the |
| 602 | surrounding 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 |
| 605 | review could not resolve `6797bbf` or `4aea8f4` and said §13's blocker was |
| 606 | therefore not shown closed. The hashes are correct — they are |
| 607 | `refs/heads/main` on the hub, confirmed by `git ls-remote` — but every |
| 608 | local checkout on this box is behind, because `amadan branch merge` lands |
| 609 | on the remote and touches no working tree. The reviewer was right that the |
| 610 | claim was unverifiable *as written*, and §13 now says which ref the hashes |
| 611 | are on and how they were checked. A citation that only resolves for |
| 612 | whoever wrote it is not a citation. |
| 613 | |
| 614 | ### 1.11 What the merge review changed |
| 615 | |
| 616 | The branch merged eighteen commits of `main` — the Notion connector, |
| 617 | multi-file drop, draft 5 — and was then reviewed as a whole. REQUEST |
| 618 | CHANGES, eight MAJOR. **Every one of them was in something the merge did |
| 619 | not open**, which is §1.10's lesson arriving for the fifth time and the |
| 620 | reason 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` |
| 625 | was green throughout, because git parses the marker lines as harmless |
| 626 | patterns. A broken file that no gate in this repository can read. |
| 627 | |
| 628 | The egress gate's sibling exemption cited |
| 629 | `sibling.TestClientRefusesHostsOutsideTheTeam` as the runtime guard paying |
| 630 | for it. There was no such test. Every test in the package built its own |
| 631 | `&http.Client{}`, so nothing exercised `suite.NewSiblingClient` and a |
| 632 | constructor returning a client with no team check and no redirect refusal |
| 633 | would have stayed green. AGENTS.md's second named failure — a gate naming a |
| 634 | test that is not there — inside the comment claiming the payment. |
| 635 | |
| 636 | And "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 |
| 640 | fix is not a gate: `Admits` is deleted and `app.Admit` is a method on the |
| 641 | one list, so there is nothing to build twice. |
| 642 | |
| 643 | **Four more were mutations the author had not thought of**, found in the |
| 644 | sibling client after five author-chosen ones had all gone red. The worst |
| 645 | rendered a *count* for `NOT_VISIBLE` — the one value that must be silent — |
| 646 | telling a reader how many things they may not see. The most instructive |
| 647 | bounded the deadline against itself, so `Deadline = 30s` passed and a page |
| 648 | could hang for half a minute with the suite green. **A test that measures a |
| 649 | constant against itself measures nothing.** |
| 650 | |
| 651 | **And the rest were the merge's own seams.** `S7` came back into §4.3 and |
| 652 | decision 11 from a branch written before ruling 4. §2 contradicted itself |
| 653 | four lines apart. §13 carried two lists arguing — an item truncated |
| 654 | mid-sentence, the same question live in one copy and struck in another, a |
| 655 | header counting four items of which two were dead. Twelve gate citations |
| 656 | pointed at the wrong gates after the renumbering, one sentence naming the |
| 657 | same gate under two numbers. |
| 658 | |
| 659 | **The lesson that is new**, and it is about reviewing rather than writing: |
| 660 | this pass found more than the four before it combined, and it found it by |
| 661 | comparing the merge against *both parents* rather than reading the result. |
| 662 | A merge is the one change where reading the output cannot tell you what is |
| 663 | missing. |
| 664 | |
| 665 | --- |
| 666 | |
| 667 | ## 2. What memoria is |
| 668 | |
| 669 | A fourth per-team instance app, beside sheets, docs and calendar, with |
| 670 | its own DEK and its own origin. It holds the team's content in a sealed |
| 671 | index and answers questions over it with citations. |
| 672 | |
| 673 | **It has no members table**, and draft 3 said it did — in this sentence, |
| 674 | word for word from draft 2, three sections before decision 11 rejected |
| 675 | the table. A reader of §2 built it; a reader of decision 11 did not. |
| 676 | Admission is §4.3's. |
| 677 | |
| 678 | **The owners list is not a roster, and draft 4 called it one four lines |
| 679 | above saying memoria never holds one.** The app refuses to start without |
| 680 | a platform-configured owners list. What separates the two is not size: a |
| 681 | roster answers "who is on this team", changes as people join and leave, |
| 682 | and would have to be reconciled against whoever else answers it. The |
| 683 | owners list answers "who operates this instance", is fixed at |
| 684 | provisioning, does not grow one person at a time, and is reconciled |
| 685 | against nothing because nobody else holds an opinion about it. Extending |
| 686 | it per person would make it a roster, and that is the move decision 11 |
| 687 | rejects. |
| 688 | |
| 689 | **Suite ruling 4 adds a third thing, and it is not either of those.** In |
| 690 | suite shape memoria holds a *pulled copy* of the home's roster (suite call |
| 691 | 8, refreshed by epoch, §4.3). That is not a members table — memoria |
| 692 | authors none of it and reconciles none of it — and it is not the owners |
| 693 | list, which still answers a different question and still bypasses it. Three |
| 694 | distinct things, and this branch briefly collapsed two of them by writing |
| 695 | "its own roster copy" into the sentence above, four lines from the |
| 696 | paragraph warning against exactly that. |
| 697 | |
| 698 | **What it is for.** Nothing in the suite answers a question. Docs ships |
| 699 | title and metadata search; Sheets has a recency list; the home's call 3 |
| 700 | says where your things are. Asking *"what did we decide about X"* and |
| 701 | getting 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 | |
| 729 | Sealed at rest under memoria's own DEK, HKDF purpose keys, AES-GCM, as |
| 730 | Sheets §2 and home §3. What follows is what that does *not* cover. |
| 731 | |
| 732 | ### 3.1 Embeddings are content |
| 733 | |
| 734 | The case that a vector is not plaintext: nothing in the bucket contains |
| 735 | the bytes `salary 50000`; the plaintext-grep gate passes; a vector is a |
| 736 | lossy derived artefact. |
| 737 | |
| 738 | The 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 |
| 740 | of sealed inputs — and embedding inversion is not hypothetical. |
| 741 | Published vec2text-style attacks recover 32-token inputs close to |
| 742 | verbatim, 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. |
| 745 | An unsealed embedding is a plaintext cell that happens to beat a byte |
| 746 | grep — precisely the failure home §5.1 names as "turning a guarantee into |
| 747 | a comment". |
| 748 | |
| 749 | **This applies to FTS5 too, and it shapes §7.** FTS5 stores term postings |
| 750 | in plaintext, so the index cannot live in the replicated SQLite file. |
| 751 | |
| 752 | **Searching sealed vectors directly is not available.** Encrypted-ANN |
| 753 | schemes are research-grade and orders of magnitude slower. The working |
| 754 | shape is the one Sheets §4.4 uses for its handle: seal the serialised |
| 755 | index as one blob, decrypt into a refcounted in-memory handle on wake, |
| 756 | evict on idle. The graph is sealed *with* the vectors, because neighbour |
| 757 | edges 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 |
| 762 | app and coordinates per chunk, plaintext exactly as Sheets §2.2 keeps |
| 763 | coordinates; ingest and update timestamps, therefore which sources are |
| 764 | busy and when; embedding dimension — though "therefore the model", as this |
| 765 | line read until draft 3, overstated it even then: the design has always |
| 766 | stored 512, which under OpenAI was a requested `dimensions` and under §8.3 |
| 767 | is a truncation of a native 4096. Either way the stored width names a |
| 768 | choice of ours. It narrows the candidate models; it does not name one. Do |
| 769 | not lean on that. |
| 770 | |
| 771 | **From a rendered page.** A response body is a copy in someone's browser, |
| 772 | and no revocation reaches it. A results tab left open on Tuesday still |
| 773 | shows Tuesday's authorized snippets on Friday; `/documents/{id}` shows a |
| 774 | whole document for as long as it stays open. This is true of every surface |
| 775 | memoria has or could have, so it is not an argument against any particular |
| 776 | one (§8.4 tried to use it as one and was wrong). memoria's guarantee ends |
| 777 | at the response body, and §8.4's invariant is what keeps the *next* |
| 778 | response honest. |
| 779 | |
| 780 | **From a live host — the largest entry, and new.** A compromised memoria |
| 781 | host reaches the team's corpus across every app at once, plus §5.1's |
| 782 | connector 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 |
| 786 | Bob earn"* is a cell value phrased as a query. Sheets AGENTS.md's rule |
| 787 | extends to cover query text, answer text and cited snippets. Token counts |
| 788 | per query still leak length. |
| 789 | |
| 790 | **From the index structure, even sealed:** blob size tracks corpus size; |
| 791 | update cadence tracks ingest; the platform observes timing, volume and |
| 792 | which team is awake. |
| 793 | |
| 794 | **From a sibling:** bodies transit the router in plaintext on every S3 |
| 795 | fetch (§6.1). |
| 796 | |
| 797 | **From the circuit breaker's counters — a per-person record, and the only |
| 798 | one memoria keeps.** §8.1's breaker needs a 7-day baseline and memoria |
| 799 | hibernates, so its counters are durable: per `(sibling, asker, UTC day)`, |
| 800 | a `VISIBLE` count and a `NOT_VISIBLE` count. Sealed under their own |
| 801 | purpose key, so this is not a bucket leak; it is a statement about what |
| 802 | memoria *holds*. Anyone who can read them learns, per person per day, how |
| 803 | much of each sibling that person was allowed to see — which is a shape of |
| 804 | the team's permissions, not its content. Retained **8 days**, and the |
| 805 | retention is load-bearing rather than tidy: an unbounded version is a |
| 806 | year-long history of who was locked out of what. The alternative is a |
| 807 | breaker that cannot fire (in-memory, reset every wake), which §8.1 rejects |
| 808 | because 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 |
| 811 | drafts.** rastrillo's classifier resolves and fetches the submitted |
| 812 | address's domain when a sign-in begins, **before** the admission gate. The |
| 813 | entry used to stop at "the instance's existence, reachability, egress |
| 814 | address and a timing channel; no content", which is what an *arbitrary |
| 815 | domain* 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 |
| 817 | allowlist, no scheme or port restriction and no private-range check, and |
| 818 | fetched by a client that follows redirects. **So the destination is the |
| 819 | submitter's to choose, not merely the domain's** — an unauthenticated SSRF |
| 820 | whose reach is whatever this instance's network can reach. |
| 821 | |
| 822 | **memoria's half is closed** (gate 33): the admission predicate now runs |
| 823 | before the classifier, so an unadmitted address produces no DNS query and |
| 824 | no fetch. **Two things that does not fix.** An *owner* still reaches the |
| 825 | unhardened fetcher, and every other rastrillo app is untouched — the real |
| 826 | fix is upstream (§13, and `SECURITY-DRAFT-rastrillo-signin.md`). And the |
| 827 | guard sits in front of signin's per-address rate limiter, so **an owner |
| 828 | and a stranger become distinguishable after five submissions** — the |
| 829 | owner's sixth answers `?err=rate`, the stranger's answers `?sent=1` |
| 830 | forever, and refused addresses consume no per-IP budget, so enumerating |
| 831 | the owners list is unthrottled. That is a smaller leak than the dial and |
| 832 | it is a real one; the budget is inside signin and cannot be reached from |
| 833 | here. |
| 834 | |
| 835 | This is listed because draft 3 let the product claim "nothing leaves this |
| 836 | instance", which it makes false. |
| 837 | |
| 838 | **Not a leak, corrected:** a document's `external_id` was an unkeyed |
| 839 | SHA-256 of its body, which let anyone holding the bucket and a candidate |
| 840 | file confirm the team holds it, and read that row's size, timestamps and |
| 841 | chunk count. A content hash is not a coordinate, whatever it looks like. |
| 842 | It is an HMAC under its own purpose key now (`seal.Fingerprint`), which |
| 843 | keeps idempotent re-ingest and removes the oracle. |
| 844 | |
| 845 | **From the provider, when AI is on — and this is bigger than draft 1 |
| 846 | said.** Draft 1 stated only query-time egress: ~10 chunks × 500 tokens |
| 847 | per question, so that over months "the frequently-consulted part of the |
| 848 | corpus 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 |
| 851 | first.** Same recipient, same category of transmission — passages that |
| 852 | passed §4.1 in this request — but a different trigger and a bounded, |
| 853 | possibly different payload: the top eight ruled results are sent when a |
| 854 | query names a model kind (a postal address) and deterministic extraction |
| 855 | cannot serve it, **whether or not the query would have produced a |
| 856 | generated answer at all.** A search for "office address" that §8.2 routes |
| 857 | as navigational still sends passages. The disclosure has to say that in |
| 858 | the same breath as the answer path; "passages behind an answer" is no |
| 859 | longer the whole of it. |
| 860 | |
| 861 | **Embeddings come from a hosted API (§7.2). So with AI on, every chunk of |
| 862 | every document leaves at ingest, on day one.** §8.6 prices it: 24M tokens |
| 863 | for a 90k-chunk corpus. The frequently-consulted part leaving is the |
| 864 | *second* leak, not the first. |
| 865 | |
| 866 | Two 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 | |
| 878 | Asked directly by Paul, 2026-09-03, and answered here because every |
| 879 | future reader will ask it. |
| 880 | |
| 881 | **At rest there is no difference.** memoria seals under its own DEK with |
| 882 | the same construction as Sheets, and the same plaintext-grep gate runs |
| 883 | against its bucket from the first commit. |
| 884 | |
| 885 | **An earlier draft argued the difference was RAM** — that a Sheets host |
| 886 | holds decrypted handles only for *open* sheets (Sheets §4.4) while |
| 887 | memoria holds everything. That is mostly wrong and is recorded so it is not |
| 888 | reintroduced. A compromised Sheets host holds the DEK and can decrypt its |
| 889 | entire bucket on demand; the open-handle distinction is the difference |
| 890 | between reading memory and running a decrypt loop, which is no barrier to |
| 891 | anyone with code execution. |
| 892 | |
| 893 | **The real difference is that the corpus is a union.** Compromise Sheets |
| 894 | and an attacker gets that team's sheets. Compromise memoria and they get |
| 895 | sheets *and* docs *and* calendar *and* Notion *and* uploads *and* |
| 896 | forwarded mail. Same lock, more in the room. |
| 897 | |
| 898 | **Three refinements. The first favours memoria; the third draft 1 got |
| 899 | backwards.** |
| 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 |
| 923 | sibling's DEK, cannot read a sibling's individually-granted content |
| 924 | because S3 404s it, and **gains no write path except suite §7's two, which |
| 925 | are narrower than "no writes" and differently shaped** (§6.4): a person's |
| 926 | own hand-off, carried by their browser under their own session, and S8 as |
| 927 | memoria itself into an area the team owner designated for it. Neither |
| 928 | gives memoria a capability over a sibling's content; the first is not |
| 929 | memoria writing at all. |
| 930 | |
| 931 | ### 3.4 A citation is exactly as trustworthy as the app it cites |
| 932 | |
| 933 | A compromised sibling has no inbound path to memoria and holds no memoria |
| 934 | token, so it cannot read or write. What it *can* do is lie: serve |
| 935 | fabricated content on S3, answer `VISIBLE` for a person not entitled, |
| 936 | withhold tombstones. memoria's re-check asks the same lying app. |
| 937 | |
| 938 | There 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 | |
| 958 | The README's first paragraph, in substance. Rewritten after review: the |
| 959 | draft-1 version was false about egress volume, which PRODUCT.md principle |
| 960 | 1 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 |
| 963 | README yet, on purpose.** §8.9 shipped first: with `MEMORIA_AI=on` the |
| 964 | binary sends the top eight ruled passages of a search that asks for a |
| 965 | postal address, and nothing else. The text below says "every document in |
| 966 | the corpus, once, when it is indexed" — true of embeddings, not built, |
| 967 | and false of the binary today. PRODUCT.md principle 1 makes overstating |
| 968 | egress as much a defect as understating it. So the README states exactly |
| 969 | what §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 |
| 971 | their retention terms and cannot verify them" in both — and the |
| 972 | transmission disclosure stays in both, since decision 16 changes what is |
| 973 | retained 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 | |
| 1011 | Every path that would speak a passage originating in a sibling app calls |
| 1012 | **S2** first, carrying the asker: |
| 1013 | |
| 1014 | ``` |
| 1015 | S2(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 |
| 1023 | shape, a lowercased address in lone shape. *On the wire* is the point, and |
| 1024 | this spec first got it backwards by saying the type is fixed at |
| 1025 | configuration because two instances that talk were provisioned together. |
| 1026 | The doctrine names that exact reasoning and rejects it: **"provisioned |
| 1027 | together" is an operational habit, not an enforced property.** With the |
| 1028 | kind on the call, a receiver that keys on the other one answers `UNKNOWN` |
| 1029 | for the whole call rather than guessing — which is a loud failure instead |
| 1030 | of a silent mismatch between an address and a person ref that happen to |
| 1031 | collide. Suite gate 2 tests it, and memoria's gate 6 gains the case. |
| 1032 | |
| 1033 | The sibling answers from the grants join it already maintains — Sheets |
| 1034 | §4.5, "rows this team can see, plus rows granted to me individually, |
| 1035 | minus 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 |
| 1038 | doctrine revision 10, on memoria's argument.** |
| 1039 | |
| 1040 | Draft 4 added `AHEAD` for the case where the *sibling* is behind — its log |
| 1041 | stops short of the revision memoria holds, or it has no row for the item |
| 1042 | at all — and the reasoning is good: `STALE` means memoria is behind and |
| 1043 | refetching fixes it, `NOT_VISIBLE` is the silent value and would delete |
| 1044 | history the sibling still has, and `UNKNOWN` "would report no answer when |
| 1045 | the sibling answered". Three wrong homes, so a fourth value. |
| 1046 | |
| 1047 | Suite revision 7 had ruled the case `UNKNOWN`; **revision 10 replaces |
| 1048 | that with `AHEAD`**, in the shape memoria proposed and with one half |
| 1049 | declined — see below. The module carries it on an unmerged branch, so the |
| 1050 | version this repository pins (`60b01a8`) still has four constants. |
| 1051 | |
| 1052 | **So the wire shape above is four values today and five when the module |
| 1053 | ships**, and every reference to `AHEAD` in this spec is written against the |
| 1054 | five-value design. That is a shipping lag, not a disagreement, and the |
| 1055 | distinction matters: nothing here needs redesigning, and nothing here can |
| 1056 | be built until the module lands. Suite CI is unconfigured, which is what |
| 1057 | holds it (§13). |
| 1058 | |
| 1059 | **How it got settled, because the route matters more than the outcome.** |
| 1060 | |
| 1061 | Record the doctrine's reasoning as the doctrine records it: suite §4.3 |
| 1062 | attributes the `UNKNOWN` ruling to *its own GPT-6 review, finding 11* — |
| 1063 | never `VISIBLE`, because that authorises a revision nobody can vouch for; |
| 1064 | never `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 |
| 1066 | two of draft 4's three wrong homes. What it did not weigh is the third: |
| 1067 | that `UNKNOWN` reports no answer when the app of record answered, precisely |
| 1068 | about its own log. |
| 1069 | |
| 1070 | *An earlier version of this paragraph said the suite "was asked by this |
| 1071 | branch, which had not read draft 4, and answered without memoria's argument |
| 1072 | in front of it". That is wrong twice — it credits this branch with the |
| 1073 | question and the doctrine with none of the reasoning — and it is the more |
| 1074 | embarrassing way to be wrong, because it makes another repository's |
| 1075 | careful work look like an accident of mine.* |
| 1076 | |
| 1077 | The argument that carried it was not the original one. The suite's |
| 1078 | objection — that `AHEAD` for an item with **no row at all** opens an |
| 1079 | id-existence oracle — was right and that half is withdrawn (§4.1's no-row |
| 1080 | rule above). What survives is the half that does not leak: **a revision |
| 1081 | beyond the sibling's log, for an item it still admits.** |
| 1082 | |
| 1083 | And 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 |
| 1085 | unasked. A **partial** restore — a content table restored without its event |
| 1086 | log — leaves the cursor perfectly valid, so nothing fires and nothing can, |
| 1087 | because from the log's point of view nothing happened. Only a per-item |
| 1088 | answer can see it. The two mechanisms are complementary, not overlapping. |
| 1089 | |
| 1090 | Until the module ships, memoria maps the case to `UNKNOWN` and **loses the |
| 1091 | distinction between "the sibling answered, and is behind" and "the sibling |
| 1092 | did not answer"** — the collapse `AHEAD` was cut to prevent. §13 carries |
| 1093 | it as a shipping dependency rather than an open question. |
| 1094 | |
| 1095 | **The sibling intersects its own answer with custody, and memoria depends |
| 1096 | on 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 | |
| 1103 | Read Sheets §4.5 again — *"plus rows granted to me individually"* — and the |
| 1104 | reason is immediate. An item that leaves team-visibility appears in S1 as |
| 1105 | `gone`, which §4.2 requires to be **indistinguishable from deletion**. An |
| 1106 | unintersected S2 distinguishes them perfectly: `VISIBLE` for the one that |
| 1107 | went private, when a grantee is the asker, and `NOT_VISIBLE` for the one |
| 1108 | that was deleted. That is the leak the S1 visibility flag was removed to |
| 1109 | prevent and the leak S3's "404, never 403" was designed to prevent, |
| 1110 | arriving on the third call. |
| 1111 | |
| 1112 | **The cost is real and it is memoria's to disclose.** A person who still |
| 1113 | holds an individual grant loses that passage here, and any answer built on |
| 1114 | it, while the document stays open to them in the app of record. That is |
| 1115 | the price of §4.2's indistinguishability and it cannot be avoided while |
| 1116 | that promise stands. memoria's honest move is §6.5's: name the app the |
| 1117 | answer went to, rather than quietly return a shorter one. |
| 1118 | |
| 1119 | **What memoria must do about it**, since the mechanism is the sibling's: |
| 1120 | depend on it, and refuse to trust an S2 that has not demonstrated it. |
| 1121 | Before 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 |
| 1123 | transition fixture: ingested while `team`, downgraded to `granted` with |
| 1124 | the asker's grant intact, asked before `gone` arrives. |
| 1125 | |
| 1126 | *An earlier version of this paragraph put the intersection on memoria's |
| 1127 | side, as "S2 says VISIBLE **and** the item was team-visible when |
| 1128 | acquired". Three things were wrong with it and a review found all three. |
| 1129 | It was **vacuous** — everything memoria holds arrived through S3, which |
| 1130 | serves only team-visible items, so the second half is true of every held |
| 1131 | item and false of none: a guard written to pass. It **did not close the |
| 1132 | leak**, because the downgrade-with-grant case passes both halves. And it |
| 1133 | **argued against the doctrine's actual mechanism** as a ruling-3 |
| 1134 | violation, when a sibling applying its own two rules to its own answer is |
| 1135 | not authorization cached or asked per person. The doctrine had already |
| 1136 | retracted exactly that reasoning — "a later visibility change is exactly |
| 1137 | what the defect turns on" — and this spec re-adopted it while citing the |
| 1138 | section that retracted it.* |
| 1139 | |
| 1140 | **Five-valued in memoria's design, four on the wire today** (see `AHEAD` |
| 1141 | above), **and each value exists because collapsing it into another |
| 1142 | produces a plausible empty list — the failure shape sheets-core AGENTS.md |
| 1143 | names 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 |
| 1154 | revision to `NOT_VISIBLE` — which is silently omitted. An ingestion delay |
| 1155 | therefore looked exactly like "you have no matching results", recreating |
| 1156 | the failure draft 2 claimed to have removed. A synchronisation condition |
| 1157 | is 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 |
| 1160 | review found the hole. `STALE` is defined as the sibling having moved |
| 1161 | *past* the revision memoria asked about. Nothing covered the opposite: a |
| 1162 | sibling restored from backup holds revision 5 of an item memoria indexed |
| 1163 | at 7. It cannot authorize a revision it does not have, so the natural |
| 1164 | answer was `NOT_VISIBLE` — the one silent value — and the plausible empty |
| 1165 | list comes back through the restore door, on the day the suite is least |
| 1166 | healthy and least likely to be believed. |
| 1167 | |
| 1168 | **What memoria does with `AHEAD` is not what it does with `STALE`, and |
| 1169 | the difference is the whole reason for a fifth value.** `STALE` means |
| 1170 | memoria is behind, and refetching fixes it. `AHEAD` means the *sibling* |
| 1171 | lost history, so refetching that revision 404s and refetching the item |
| 1172 | yields revision 5 — an older body which is now the sibling's truth. So |
| 1173 | memoria withholds the passage loudly, marks the source for the full |
| 1174 | re-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, |
| 1176 | and the re-announce is what reconciles the two. Wholesale `AHEAD` from |
| 1177 | one sibling is a restore signal and §8.1's third breaker names it as one, |
| 1178 | rather 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`.** |
| 1181 | Corrected 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 |
| 1183 | real 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 |
| 1193 | distinguishable, so a consumer that can name an id learns which ids |
| 1194 | *exist*: `AHEAD` for one nobody holds, `NOT_VISIBLE` for one held |
| 1195 | privately. Suite gate 3 requires S2's answer to be byte-identical across |
| 1196 | private, granted, guest-only, link-shared-but-not-team **and deleted**, |
| 1197 | precisely so ids cannot be enumerated — and this rule would have opened, |
| 1198 | on 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 |
| 1200 | failure would have opened an existence leak instead. |
| 1201 | |
| 1202 | **And caller discipline does not save it.** memoria only ever asks S2 |
| 1203 | about ids it received on S1, so *memoria* can name no id it was not told |
| 1204 | about — but the S2 row is a contract **siblings implement**, answerable to |
| 1205 | any consumer holding a token, and §3.2's leak list already contemplates a |
| 1206 | compromised one. A rule that is safe only while every caller behaves is |
| 1207 | not a property of the answer. |
| 1208 | |
| 1209 | **So the cost is carried rather than avoided, and it is stated here.** A |
| 1210 | sibling whose restore lost a row entirely is now indistinguishable from |
| 1211 | one enforcing a denial, and memoria cannot tell them apart from S2 alone. |
| 1212 | What settles it is the S4 tombstone and the re-announce — reconciliation |
| 1213 | after the fact, not an answer at the point of the question. `AHEAD` |
| 1214 | survives for the shape that does not leak: **a revision beyond the log, |
| 1215 | for an item the sibling still admits**, which is the partial-restore case |
| 1216 | and the one no feed signal can see. |
| 1217 | |
| 1218 | **memoria never compares two revisions, and this is what makes `STALE` |
| 1219 | and `AHEAD` well-defined.** A revision is an opaque token issued by the |
| 1220 | sibling, like S1's cursor. Nothing in this design requires revisions to |
| 1221 | be numeric, ordered or monotonic, and memoria tests them only for |
| 1222 | equality. `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 |
| 1224 | revision against its log rather than against its current row — and a |
| 1225 | sibling whose revisions are content hashes or ULIDs answers them just as |
| 1226 | well as one that counts. Without this rule, a restored sibling holding |
| 1227 | "5" against memoria's "7" invites memoria to prefer the larger number, |
| 1228 | which is exactly the wrong answer. |
| 1229 | |
| 1230 | #### Post-restore reconciliation, and the phantom chunks |
| 1231 | |
| 1232 | Draft 4 stopped at "withhold and re-announce", and the review found what |
| 1233 | that leaves running. Between a sibling's restore and a completed |
| 1234 | re-announce, every chunk memoria holds from that sibling at a lost |
| 1235 | revision answers `AHEAD`. Each is withheld loudly — but each was *ranked* |
| 1236 | first, so it consumed a slot in top-k. A search matching fifty chunks, |
| 1237 | twenty of them from the restored sibling, rendered thirty results and a |
| 1238 | notice about twenty. In the bad case — the sibling that supplies most of |
| 1239 | the corpus — the page renders empty or nearly so, and the notice is the |
| 1240 | only thing standing between the reader and "no results". **That is the |
| 1241 | plausible empty list, arriving through the door `AHEAD` was cut to |
| 1242 | close.** Four rules close it. |
| 1243 | |
| 1244 | 1. **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. |
| 1251 | 2. **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. |
| 1259 | 3. **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. |
| 1267 | 4. **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 | |
| 1275 | Gate 27 holds all four, including the case where the announce fails |
| 1276 | mid-way and nothing is retired. |
| 1277 | |
| 1278 | **`UNKNOWN` is synthesised by the caller, not only returned by the |
| 1279 | sibling.** A sibling that never answers cannot return anything, so the |
| 1280 | client maps every non-answer to `UNKNOWN`: timeout, non-2xx, malformed |
| 1281 | body, an item missing from the response, a duplicate item, a short |
| 1282 | response to a 256-item request, and any result value the client does not |
| 1283 | recognise. **The decoder is total** — every requested |
| 1284 | pair gets exactly one result or the whole call is `UNKNOWN`. Without that |
| 1285 | rule a missing entry falls through to omission, which is the silent path |
| 1286 | again. 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 |
| 1290 | is no membership *synchronisation* and no membership table, because a |
| 1291 | non-member's items all come back `NOT_VISIBLE` from the app that knows, |
| 1292 | and §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 |
| 1295 | since suite ruling 4 it is the home's roster that answers it, not `S7` and |
| 1296 | not a sibling (§4.3). It is asked once at the door, about the team rather |
| 1297 | than about an item. The two must not be run together: authorization is per |
| 1298 | asker, per query, **never cached at any scope**; admission is a pulled copy |
| 1299 | refreshed by epoch. Collapsing them is what `S7`'s per-session cache did, |
| 1300 | and separating them is the whole point of the roster split. |
| 1301 | |
| 1302 | **Revision matters.** A document that stays team-visible but whose newer |
| 1303 | revision removed a sentence must not have its stale chunk spoken: a |
| 1304 | revision behind the sibling's current one answers **`STALE`**, and memoria |
| 1305 | refetches 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 |
| 1309 | about — an ingestion delay, and memoria refetches. The opposite happens |
| 1310 | too: a sibling restored from backup can sit *behind* it, so the app of |
| 1311 | record does not recognise the revision at all. **That answers `AHEAD`, and |
| 1312 | withholds loudly while the source re-announces.** Never `VISIBLE`, which |
| 1313 | would authorise a revision nobody can vouch for; never `NOT_VISIBLE`, the |
| 1314 | silent value and the mistake `STALE` was added to fix; and not `UNKNOWN` |
| 1315 | either, which reports that nobody answered when the app of record answered |
| 1316 | precisely about its own log. Three wrong homes, which is why the values are |
| 1317 | five and not four. |
| 1318 | |
| 1319 | *This paragraph said `UNKNOWN` until 2026-09-06, citing suite §4.3 and |
| 1320 | GPT-6's finding 11 — correctly, for revision 7. Revision 10 replaces that |
| 1321 | ruling with `AHEAD`, so the citation stood while the thing it cited had |
| 1322 | moved. Until the module ships the fifth constant the CODE maps this case |
| 1323 | to `UNKNOWN`; the spec no longer pretends that is the design.* |
| 1324 | |
| 1325 | **Everything below `AHEAD` in this section is specified against the |
| 1326 | five-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 |
| 1328 | all name a value the pinned `suite.Authorization` does not carry. That is |
| 1329 | one dependency with one date, not five open questions. |
| 1330 | |
| 1331 | **Batch cap: 256 pairs per call**, multiple calls permitted. Draft 1's 64 |
| 1332 | was tied to answer top-k and did not survive paginated search or |
| 1333 | derived-artefact expansion (§5.2). The number's reason: one page of |
| 1334 | search results (50) plus the provenance fan-out of the derived artefacts |
| 1335 | on it — measured before the constant is committed. |
| 1336 | |
| 1337 | ### 4.2 memoria-owned content: memoria is the app of record |
| 1338 | |
| 1339 | Notion, Drive, forwarded mail and uploads have no sibling to ask. For |
| 1340 | this content **memoria is the authority** — not a copy, so there is |
| 1341 | nothing 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 |
| 1345 | members table (decision 11, §2) and there are no per-person grants: |
| 1346 | holding grants would be an authorization model, which §4.3 says memoria |
| 1347 | does not have. The test is two facts and nothing else: |
| 1348 | |
| 1349 | 1. **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 |
| 1353 | 2. **the document is present in memoria and not deleted.** |
| 1354 | |
| 1355 | So memoria-owned content is team-wide by construction. Anything a |
| 1356 | connector or an upload puts in is offered to everyone the siblings call a |
| 1357 | member of this team, and the only revocation is deletion. That is a |
| 1358 | strong thing to say of a Drive folder, which is why §3.6's README says it |
| 1359 | in those words, why §3.5 makes least-privilege connector credentials a |
| 1360 | requirement rather than a nicety, and why §4.3's predicate has to mean |
| 1361 | member rather than guest: fact 1 is the whole of the door. |
| 1362 | |
| 1363 | This is the honest resolution of gap 3, and it costs something that must |
| 1364 | be said plainly rather than implied: **revoking a Notion page from the |
| 1365 | integration does not revoke it in memoria.** The content was published |
| 1366 | into memoria and memoria now owns it; removing it is an act performed *in |
| 1367 | memoria*. §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 |
| 1371 | enumeration diff is a convenience that eventually notices upstream |
| 1372 | deletions, not an authorization mechanism". It no longer does, because |
| 1373 | the first connector built under it does not delete on absence at all. |
| 1374 | The reason is that memoria is being adopted as a *replacement* for |
| 1375 | Notion, not a mirror of it: under a replacement, archiving a page |
| 1376 | upstream after migrating it is the intended workflow, and a |
| 1377 | diff-and-delete connector reads that as "forget it" — deleting from the |
| 1378 | app of record the document the migration existed to preserve. A |
| 1379 | convenience that eventually notices deletions is the wrong convenience |
| 1380 | when deletion upstream is the goal. |
| 1381 | |
| 1382 | The cost is real and is carried rather than buried: a page deleted |
| 1383 | upstream *because it should not have been shared* stays indexed and |
| 1384 | readable by every admitted person, with no notice. Connectors therefore |
| 1385 | record `last_seen_at` per item on every enumeration, so "no longer in |
| 1386 | the source" is answerable later without a migration. Keeping the |
| 1387 | documents is a decision; being unable to list them is not licensed by |
| 1388 | it. |
| 1389 | |
| 1390 | ### 4.3 Admission comes from the roster |
| 1391 | |
| 1392 | #### Optional Home sign-in for an existing local account (2026-09-10) |
| 1393 | |
| 1394 | Home authentication can be enabled while admission still uses the existing |
| 1395 | `MEMORIA_OWNERS` list. This is an authentication adapter for lone shape, not |
| 1396 | roster activation. An opaque Home subject gains no membership or content access |
| 1397 | until the person independently proves their existing local account and confirms |
| 1398 | its connection to Home. Shared `home/connect` handles fresh proofs, browser |
| 1399 | binding and assertion replay protection. |
| 1400 | |
| 1401 | Memoria has no numeric people table. The adapter therefore allocates a stable |
| 1402 | local identity ID only at the explicit linking entry, after a fresh verified |
| 1403 | magic-link or Keymail session. Its address is encrypted with a distinct, |
| 1404 | row-bound purpose; a keyed fingerprint provides the lookup. Ordinary assertion |
| 1405 | handling and account lookups never allocate identities. These bindings are |
| 1406 | per-person records in addition to the existing encrypted breaker tallies. They |
| 1407 | contain no content, membership or grants. Current admission is rechecked at each |
| 1408 | lookup and protected request; removing an address from configured owners also |
| 1409 | refuses existing Home sessions. |
| 1410 | |
| 1411 | Home sessions carry the opaque Home subject and a distinct method. The adapter |
| 1412 | resolves that subject through the confirmed binding to the original verified |
| 1413 | local address. Existing owner checks and S2 `SubjectAddress` questions use that |
| 1414 | address; nothing rewrites documents, provenance, source coordinates or custody. |
| 1415 | Disabling Home refuses remaining Home sessions instead of interpreting their |
| 1416 | subjects as email addresses. |
| 1417 | |
| 1418 | The 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 |
| 1421 | is a dedicated exchange key, separate from the content DEK. |
| 1422 | |
| 1423 | The 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 |
| 1427 | cross-origin issuer; all other mutations retain CSRF protection. The normal |
| 1428 | `/signin` starts Home handoff when enabled. Local email verification remains |
| 1429 | available for first-time connection, including explicit `force=1` fallback. |
| 1430 | Sign-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 |
| 1432 | Oficina session too and returns the browser to `/signed-out`. |
| 1433 | `HOME_DIRECTORY_ORIGIN` (optional, validated with the rest) names the hub |
| 1434 | directory, 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 |
| 1438 | members table of its own and asks no sibling. In **suite shape** it pulls |
| 1439 | the roster (suite call 8, consumer token, roster epoch in every response) |
| 1440 | and admits from it. The epoch rides the home's own responses — calls 2, 5 |
| 1441 | and 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, |
| 1444 | exactly as slice 0 does today. |
| 1445 | |
| 1446 | Draft 2 had memoria keep its own members table, and the draft-2 review |
| 1447 | showed 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 |
| 1449 | removed everywhere else could still sign in and read the team's Notion |
| 1450 | pages, Drive files, forwarded mail and uploads. That failure is real and |
| 1451 | the 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 |
| 1454 | person if **at least one sibling answered `MEMBER`** to a call `S7`. Two |
| 1455 | things were wrong with it. The small one: it cached membership for the life |
| 1456 | of a session, and ruling 3 now says authorization is never cached at any |
| 1457 | scope — the word "session" in the doctrine's first draft came from *this |
| 1458 | rule*, and folding admission into authorization is precisely the |
| 1459 | conflation the roster split exists to remove. **Custody and admission are |
| 1460 | person-less or roster-shaped; authorization is per asker, per query, never |
| 1461 | cached. They are three different things and S7 blurred two of them.** |
| 1462 | |
| 1463 | The large one: **`MEMBER` has no value for a guest.** A guest is not on any |
| 1464 | roster, so "at least one sibling says MEMBER" is undefined for exactly the |
| 1465 | person the rule most needed to exclude — and memoria's own §13 carried |
| 1466 | that as an open question it could not answer from inside decision 11. |
| 1467 | Under ruling 4 it answers itself: guests are never on the roster. **S7 is |
| 1468 | retired.** |
| 1469 | |
| 1470 | Three 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 |
| 1494 | the roster is the right answer rather than merely the doctrine's.** It |
| 1495 | found that `MEMBER` had to mean *member of this team* and not *known to |
| 1496 | this app*, because Sheets §8.2 and Docs §8.2 both describe sharing as |
| 1497 | membership — so a person granted a single sheet is a member there, in |
| 1498 | those specs' own words. Under the old rule that person was admitted to |
| 1499 | memoria, and §4.2 then handed them every Notion page, Drive file, |
| 1500 | forwarded mail and upload the team holds. **One shared sheet bought the |
| 1501 | whole corpus.** |
| 1502 | |
| 1503 | Asking a sibling could never cleanly separate the two questions, because |
| 1504 | the sibling only knows its own. A roster held by the home answers *member |
| 1505 | of this team* and nothing else, which is the property draft 4 was reaching |
| 1506 | for and could not get from S7. |
| 1507 | |
| 1508 | **Slice 0 has no home and no siblings, so admission is the owners list |
| 1509 | and nothing else**, and the spec should say so rather than leave a reader |
| 1510 | to infer a table. Until a home exists, the only people admitted are the |
| 1511 | platform-configured owners the app refuses to start without. That list |
| 1512 | admits operators, it is not a roster, and **it does not grow**: when a home |
| 1513 | exists, everyone who is not an owner comes from the pulled roster (suite |
| 1514 | call 8). Extending the owners list one person at a time would be the |
| 1515 | members 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 |
| 1518 | comes from S7" until 2026-09-06 — inside the section that retires S7, |
| 1519 | reintroduced by the merge from a branch written before ruling 4. §1.10's |
| 1520 | rule is to grep the retired thing by name; this is what it catches.* |
| 1521 | |
| 1522 | No guests, no anonymous link viewers — and after this section, no guest |
| 1523 | of a *sibling* either. Admission is not authorization: an admitted person |
| 1524 | still sees only what §4.1 and §4.2 allow. |
| 1525 | |
| 1526 | The suite attribution page (`GET /help/open-source`, 2026-09-11) used |
| 1527 | this 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 |
| 1529 | out. It is now **public**: it serves embedded software notices and |
| 1530 | nothing about the team — no content, no passage counts, no document |
| 1531 | names. Signed in, it still shows the viewer's own bar and account menu; |
| 1532 | signed out, the bar's Sign in. It creates no session. Its stylesheet is |
| 1533 | a 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 |
| 1542 | team-visible. That mark is the *custody* gate of decision 3 — it bounds |
| 1543 | what 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 |
| 1546 | it sounds.** An item sits on one of three rungs — *private*, *granted* to |
| 1547 | named people, or *team* — and **only the `team` rung is team-visible**. So |
| 1548 | custody keys on the rung and **never on a read-only link**: a link |
| 1549 | capability makes an item readable by whoever holds the URL, which is not |
| 1550 | the same as the team being able to find it. Treating a link as |
| 1551 | team-visible would turn a capability held by whoever was sent it into a |
| 1552 | row every team member reaches by search — which is the specific mistake |
| 1553 | ruling 15 rejects, and it would arrive here, at ingest, where memoria |
| 1554 | decides what to hold. |
| 1555 | |
| 1556 | Two consequences memoria must not paper over. A `granted` item is outside |
| 1557 | custody **even when the asker holds the grant**, which is why §4.1's |
| 1558 | intersection sits at the sibling and why a person can lose a passage here |
| 1559 | while the document stays open to them at the source. And an item that |
| 1560 | moves from `team` to `granted` leaves custody: it arrives as `gone` in S1, |
| 1561 | indistinguishable from deletion by §4.2's design, and memoria deletes |
| 1562 | rather than downgrades. |
| 1563 | |
| 1564 | **From outside the suite:** an admin configures a connector, and the |
| 1565 | sharing act at the far end is the publish. |
| 1566 | |
| 1567 | ### 5.1 What a connector must supply |
| 1568 | |
| 1569 | A stable external id, a change feed (or an enumeration cheap enough to |
| 1570 | diff), a mapping onto memoria's custody rule, an extractor, and a delete |
| 1571 | signal. |
| 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 |
| 1582 | re-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; |
| 1585 | the team is twenty. Mail enters by forward-or-label only. |
| 1586 | |
| 1587 | **An enumeration diff must distinguish an authoritative empty result from |
| 1588 | a failure** before deleting anything. A timed-out, rate-limited, |
| 1589 | partially-paginated or credential-expired enumeration returns *no |
| 1590 | information*, not *no documents*; treating the second as the first purges |
| 1591 | the 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 |
| 1596 | assumed `/v1/search` could enumerate a workspace. Notion documents that |
| 1597 | it cannot: search "is not guaranteed to return everything", and **no |
| 1598 | field in the response says the result was partial**. An enumerator built |
| 1599 | on it reports a complete import while missing pages — this design's worst |
| 1600 | failure 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 |
| 1603 | cursor-verifiable and yields ancestry as a side effect. |
| 1604 | |
| 1605 | *Notion's delete.* Now "none, and none used" — see the amendment in §4.2. |
| 1606 | Gate 7's rule above stands unchanged for connectors that *do* delete; |
| 1607 | Notion's connector satisfies it trivially by having no path that deletes, |
| 1608 | which is a stronger position than a completeness predicate that has to be |
| 1609 | right every time. |
| 1610 | |
| 1611 | ### 5.2 Deletion, and the hard case |
| 1612 | |
| 1613 | Chunks and vectors are rows under memoria's DEK; they go on the tombstone |
| 1614 | or the next diff. §7.4 covers the snapshot ordering that makes a deletion |
| 1615 | survive a restore. |
| 1616 | |
| 1617 | The 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 |
| 1621 | them 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 |
| 1625 | is *regenerated* when a source moves, so the model that made the row a |
| 1626 | reader is looking at is often not the one that made it first. Without the |
| 1627 | producing model stored alongside, a regenerated artefact silently inherits |
| 1628 | the name of its predecessor — a naming requirement that reports the wrong |
| 1629 | name is worse than none, because it is believed. Draft 1's rule was |
| 1630 | id-level, which left "the Q3 target is X" standing after the source |
| 1631 | changed to Y — provenance that resolves and is wrong. |
| 1632 | |
| 1633 | **A derived artefact expands to its full provenance before §4.1 runs**, |
| 1634 | and **every source must pass, under whichever authority owns it.** One |
| 1635 | failure withholds the whole artefact. Without this a summary is a |
| 1636 | laundering channel for a single downgraded input. |
| 1637 | |
| 1638 | Draft 2 said this and specified it only for siblings, which the draft-2 |
| 1639 | review caught: S2 exists nowhere else, so an external source had no check |
| 1640 | at all and a *mixed* artefact had no rule for combining two authorities. |
| 1641 | The full algorithm: |
| 1642 | |
| 1643 | 1. **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. |
| 1659 | 2. **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. |
| 1665 | 3. **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. |
| 1680 | 4. **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. |
| 1683 | 5. **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 |
| 1689 | otherwise transitive.** Draft 3's algorithm partitioned by owner into two |
| 1690 | classes, and a derived artefact is memoria-owned — so an artefact built |
| 1691 | from another artefact landed in step 3, whose entire test is presence in |
| 1692 | memoria. The first artefact's sibling sources never reached S2. Two hops |
| 1693 | launder exactly what one hop is written to stop: summarise a Sheets |
| 1694 | document, then summarise the summary, and the second summary is speakable |
| 1695 | to a person Sheets would refuse. Nothing in draft 3 forbade the second |
| 1696 | hop, and nothing would have noticed it. |
| 1697 | |
| 1698 | So 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 | |
| 1713 | Three 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 | |
| 1726 | Flattening is also what makes §4.1's 256-pair cap load-bearing rather |
| 1727 | than decorative: an artefact three hops deep fans out to every root of |
| 1728 | every ancestor, and that fan-out is the number §4.1 says to measure |
| 1729 | before committing the constant. |
| 1730 | |
| 1731 | **Where derived artefacts live:** memoria's own store, sealed like any |
| 1732 | chunk, indexed and searchable — and therefore subject to §4.1 through |
| 1733 | their provenance, never on their own account. They exist only with AI on, |
| 1734 | and their regeneration is a model call whose cost §8.6 counts. |
| 1735 | |
| 1736 | **memoria never fine-tunes**, because weights are the one copy that |
| 1737 | cannot be forgotten. |
| 1738 | |
| 1739 | ### 5.3 What "continuously evolving memory" honestly means |
| 1740 | |
| 1741 | Re-index on change — a search index, honestly named. Derived notes |
| 1742 | regenerated when sources change — a materialised view with provenance. |
| 1743 | Updated weights — never. |
| 1744 | |
| 1745 | **The smallest honest version is the first two.** It is a cache over |
| 1746 | sources. For sibling content it forgets when the sources do, because |
| 1747 | §4.1 asks them. **For memoria-owned content it forgets when someone tells |
| 1748 | it to** (§4.2); draft 1's claim that it "forgets exactly when the sources |
| 1749 | do" was false for that half. |
| 1750 | |
| 1751 | --- |
| 1752 | |
| 1753 | ## 6. The sibling contract |
| 1754 | |
| 1755 | Decision 1 makes cross-app pull legal. Nothing yet makes it possible: |
| 1756 | every home contract call is instance→home or person→home, home §6 |
| 1757 | rejected a re-announce sweep because it "has no channel to reach |
| 1758 | instances", and `aud` binds every assertion to one host. |
| 1759 | |
| 1760 | ### 6.1 The calls |
| 1761 | |
| 1762 | All are memoria → sibling, all pulls. **Push is rejected on three |
| 1763 | grounds:** a sibling outbox drains on wake and share mutation, not on a |
| 1764 | ticker (Sheets §9.1.1), adequate for a hint and useless for content; push |
| 1765 | would wake a hibernating memoria on every keystroke in three apps; and it |
| 1766 | inverts custody, since each sibling would hold a memoria token and |
| 1767 | memoria would grow an inbound surface it does not have. |
| 1768 | |
| 1769 | memoria 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 |
| 1783 | leak.** With it, a `visible=false, gone=false` row tells memoria that a |
| 1784 | guest-only item exists, at what revision, and when it was touched — |
| 1785 | exactly what S3's "404s, never 403s" was designed to hide, and unnamed in |
| 1786 | the 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 |
| 1789 | stated it unconditionally and it was false for retraction-by-edit. Draft 2 |
| 1790 | fixed that with a revision on S2 but mapped the failure to `NOT_VISIBLE`, |
| 1791 | which is silently omitted — so it cost a *silent* miss, which is the |
| 1792 | failure it was supposed to prevent. It holds now only because S2 carries |
| 1793 | the revision **and** answers `STALE` distinctly. All three clauses travel |
| 1794 | together or the sentence is false. |
| 1795 | |
| 1796 | **S3 takes no person, and the reasoning went wrong twice before landing |
| 1797 | here.** |
| 1798 | |
| 1799 | Draft 2's S3 took `(id, revision)` and *claimed* the sibling "re-runs its |
| 1800 | grants join at fetch time". The draft-2 review correctly called that |
| 1801 | impossible: no asker, no grants join. |
| 1802 | |
| 1803 | Draft 3 fixed it by adding `person_ref`. That was the wrong fix, and a |
| 1804 | review of the built code found two consequences. **There is no person on |
| 1805 | the ingest path** — memoria pulls on its own wake, and the warm-up, the |
| 1806 | rebuild and derived-artefact regeneration have no asker either — so the |
| 1807 | call 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 |
| 1810 | would pull into memoria's store exactly the content §6.4 forbids and §3.3 |
| 1811 | claims it cannot hold. |
| 1812 | |
| 1813 | **The error was collapsing two rules decision 3 keeps apart.** Custody |
| 1814 | bounds what memoria may *hold*: team-visible only, no person involved, |
| 1815 | and 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 |
| 1817 | S3 had the right signature and a false comment; the fix was to delete the |
| 1818 | comment, not to add a person to the call. |
| 1819 | |
| 1820 | So: **S3 is custody-scoped and person-less. S2 is the only per-person |
| 1821 | gate, and nothing is spoken without it.** |
| 1822 | |
| 1823 | **The definitions above are no longer memoria's to state.** Suite ruling 7: |
| 1824 | one contract, one Go module, fixtures as the interface — every app imports |
| 1825 | `amadan.net/oficina/suite`, serves its half, and passes the conformance |
| 1826 | suite in CI. An app spec cites the doctrine and never restates it, because |
| 1827 | each spec restating the contract in its own words is exactly what drifted. |
| 1828 | So **S1, S2, S3 and S6 are the module's**, and what survives here is |
| 1829 | memoria's own reasoning about them: why S3 carries no person, why the four |
| 1830 | S2 values are four, what a slow sibling costs. The wire shapes are the |
| 1831 | module's and the fixtures are the interface. |
| 1832 | |
| 1833 | **S6 is cited as a specification, not as a fixture, and that is |
| 1834 | deliberate.** The module on `main` is S1, S2 and S3; there is no S6 fixture |
| 1835 | to conform to yet. Suite §12 **step 4** builds it — after memoria's slice 1 |
| 1836 | and before slice 2 — so §8.7's numeric path cites a call that is scheduled |
| 1837 | rather than one that exists, and picks up the fixture when it lands. |
| 1838 | Nothing 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 |
| 1841 | let a numbered call read as a finished one; revision 6 gave it a step and |
| 1842 | put it before slice 1, implying a dependency that does not exist; revision |
| 1843 | 7 placed it at step 4 and said so in the step. Recorded because "cite S6" |
| 1844 | meant 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 |
| 1849 | platform config at provisioning.** Home §9 already works this way. The |
| 1850 | drain mints memoria's tokens and writes each sibling's `consumers` row. |
| 1851 | The home never sees a sibling token. |
| 1852 | |
| 1853 | Three alternatives rejected: **the home issues the token** (breaks home |
| 1854 | §7's "the home holds no provisioning credential", and breaches home §2.2 the |
| 1855 | moment it decides which origin gets one); **a team-scoped shared secret** |
| 1856 | (cannot rotate per consumer); **mutual TLS** (TLS terminates at Carloku's |
| 1857 | router under a wildcard certificate per app zone, home §4, so the app |
| 1858 | never sees a client certificate). |
| 1859 | |
| 1860 | **Rotation is a three-phase handover, and draft 1 specified none.** A |
| 1861 | single-token row cannot rotate without a window in which one side is |
| 1862 | wrong: update the sibling first and the old token breaks; update memoria |
| 1863 | first and it sends one the sibling will not accept. So a `consumers` row |
| 1864 | holds **up to two hashed tokens, each with an epoch**, and the drain: |
| 1865 | |
| 1866 | 1. **Expands acceptance** — writes the new token alongside the old at |
| 1867 | every sibling. Both accepted. |
| 1868 | 2. **Switches the sender** — delivers the new token to memoria as |
| 1869 | platform config. |
| 1870 | 3. **Contracts acceptance** — retires the old token at every sibling, |
| 1871 | only after memoria is observed using the new one. |
| 1872 | |
| 1873 | Each phase is idempotent and re-runnable, so a partial delivery across |
| 1874 | three siblings is recovered by repeating the phase. Emergency revocation |
| 1875 | skips to phase 3 and accepts the outage. |
| 1876 | |
| 1877 | **The mutable `(cursor, TTL, epoch)` state is created lazily by the |
| 1878 | sibling on first contact.** Platform config delivers the token, not the |
| 1879 | row: home §6 is explicit that no channel exists to push one. |
| 1880 | |
| 1881 | ### 6.3 What stops team X reaching team Y |
| 1882 | |
| 1883 | Team 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 |
| 1887 | this and they were right: |
| 1888 | |
| 1889 | 1. **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. |
| 1893 | 2. **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 |
| 1899 | attacker with code execution on memoria bypasses memoria's own client |
| 1900 | trivially. It is a real guard against accidental cross-team dialling and |
| 1901 | against a mis-provisioned host, and that is what it is claimed as. |
| 1902 | |
| 1903 | Same-team membership is *provenance*, not a runtime query: the drain |
| 1904 | provisioned the hosts together. **The home is not on this path at all**, |
| 1905 | which is how home §2.2 survives decision 1. |
| 1906 | |
| 1907 | ### 6.4 What decision 1 does not license |
| 1908 | |
| 1909 | Any 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 |
| 1911 | itself into its designated area.** Any sibling item outside the |
| 1912 | team-visible custody rule. The home in the loop. Content leaving the four |
| 1913 | hosts, except §8.3's named provider, which is a disclosed exception and not |
| 1914 | a sibling. |
| 1915 | |
| 1916 | **The write exception is narrow and it is suite ruling 9's: a write names |
| 1917 | its real actor, and no sibling ever acts as a person.** Two forms and no |
| 1918 | third — the hand-off (suite §7.1) and S8 (suite §7.2) — and **the wire |
| 1919 | shapes are the doctrine's, cited here and not restated.** An earlier |
| 1920 | version of this paragraph described the hand-off as "memoria renders a |
| 1921 | form that POSTs to the receiving app's create endpoint; memoria is not in |
| 1922 | the write at all, it only drew the button", which is the drifted |
| 1923 | restatement ruling 7 exists to prevent, and it drifted in the direction |
| 1924 | that 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 | |
| 1940 | This spec previously forbade all writes, which was simpler and also |
| 1941 | forbade 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 | |
| 1945 | Draft 2 had none of this, and the draft-2 review was right that it is the |
| 1946 | finding most likely to change the product rather than the spec. Every |
| 1947 | search 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 |
| 1949 | is 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 |
| 1991 | of them.** Gate 23 holds the deadline and the concurrency together, |
| 1992 | because either alone passes on the other's failure: a sibling sleeping |
| 1993 | past the deadline must not add its latency to the page, *and* three |
| 1994 | siblings called at once must not cost three times one sibling's time. |
| 1995 | Gate 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 |
| 1997 | query issued at all.** |
| 1998 | |
| 1999 | *This read "before the query handler serves its first request" until |
| 2000 | 2026-09-07, which is the ordering §10's own gate 24 works through and |
| 2001 | rejects as unwritable either way — asynchronous it is a race between |
| 2002 | goroutines, synchronous it puts three sibling wakes in front of the first |
| 2003 | reader and undoes the warm-up's entire purpose. §10 had the corrected |
| 2004 | form; this sentence did not, and the code cites §10. Found by a review of |
| 2005 | the branch that built it.* |
| 2006 | |
| 2007 | --- |
| 2008 | |
| 2009 | ## 7. The index |
| 2010 | |
| 2011 | The design Docs §15 item 2 recorded as missing: *"An FTS5 index is |
| 2012 | plaintext in SQLite and would fail our own gate; decrypting the corpus |
| 2013 | per query is O(corpus)."* |
| 2014 | |
| 2015 | **Hybrid BM25 + dense vectors, fused by reciprocal rank, both sealed at |
| 2016 | rest 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 |
| 2021 | binary 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 | |
| 2026 | Per §3.1 its postings are plaintext, so the FTS table lives on |
| 2027 | non-replicated scratch or in memory and is snapshotted to the bucket as a |
| 2028 | sealed blob. **Whether CARLOS provides non-replicated scratch is |
| 2029 | unverified — §13.** |
| 2030 | |
| 2031 | ### 7.2 The arithmetic, derived |
| 2032 | |
| 2033 | **One corpus assumption drives every number below.** Draft 1 used 90k |
| 2034 | chunks for memory and 60M tokens for cost, which are inconsistent; both |
| 2035 | reviewers 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 |
| 2041 | wrote "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 |
| 2043 | of the kind measured**, not 30,000. Any figure quoted per-document is |
| 2044 | wrong by about 4×. The ceiling is a chunk count and only a chunk count. |
| 2045 | |
| 2046 | The token figure follows from the measured chunk (202 words ≈ 270 tokens), |
| 2047 | not 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 |
| 2052 | parenthetical this replaced — *"OpenAI's `-3` models accept a `dimensions` |
| 2053 | parameter"* — 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 |
| 2057 | Matryoshka. |
| 2058 | |
| 2059 | **Search computes a raw dot product over vectors stored unit-normalised**, |
| 2060 | not a cosine that divides by norms at query time. §7.3's `2 × N × D` flop |
| 2061 | figure 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 |
| 2063 | changes no ranking at all** — a gate written over cosine could not fail, |
| 2064 | which is the shape AGENTS.md says this codebase has already shipped three |
| 2065 | times. Over a raw dot product the same omission silently reorders results. |
| 2066 | So the earlier draft of this paragraph, which said "cosine similarity then |
| 2067 | degrades smoothly", was wrong twice: cosine does not degrade, and the thing |
| 2068 | that does degrade is the form the spec had not committed to. |
| 2069 | |
| 2070 | Gate 28 is therefore deterministic and cannot be argued past — *every |
| 2071 | stored vector has L2 norm 1 ± ε; mutation: skip the renormalise, expect |
| 2072 | red* — and gate 29 carries the measurement it cannot do. |
| 2073 | |
| 2074 | **It costs ingest bandwidth, not storage.** Truncation happens before |
| 2075 | storing, 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 |
| 2077 | get by mis-reading the kept width as the f32@512 intermediate (184 MB is |
| 2078 | what 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 |
| 2081 | 2026-09-05: `encoding_format: "base64"` is **accepted and silently |
| 2082 | ignored** — the response returns JSON floats, matching the request that |
| 2083 | omits the parameter. Not an error, just dropped. So the ~2 GB base64 figure |
| 2084 | an earlier draft hedged with is unavailable. |
| 2085 | **The replacement figure is derived, not measured, and the range is wide.** |
| 2086 | At ~11 bytes a float it is ~4 GB across 90k chunks; servers of this family |
| 2087 | often emit 17-significant-digit reprs at ~20 bytes, which is ~7 GB. A probe |
| 2088 | response was in hand and nobody counted its bytes — measure bytes-per- |
| 2089 | vector once and multiply, rather than guessing at a serialiser's precision. |
| 2090 | **And "no opt-out" overstates it:** `Content-Encoding: gzip` collapses |
| 2091 | digit strings, Go's client asks for it by default, and whether TensorX |
| 2092 | honours it is one response header on the same probe (§13). Note the behaviour as well as the number: **a provider that |
| 2093 | accepts a parameter and discards it will do so again**, which is an |
| 2094 | argument for asserting on what comes back rather than on what was asked |
| 2095 | for. |
| 2096 | |
| 2097 | This is ingest-time, not wake-time, so §7.3's ceiling is untouched. It is |
| 2098 | not free either: neither the first embed's wall-clock nor the batch size |
| 2099 | the endpoint accepts is written down anywhere, and §13 asks for the |
| 2100 | re-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 | |
| 2108 | Draft 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 |
| 2111 | one 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 |
| 2122 | reported 104 MB from a synthetic corpus of random letters. The draft-2 |
| 2123 | review showed that figure could not describe the stated schema, because a |
| 2124 | default FTS5 table keeps its own copy of the inserted text. Measured |
| 2125 | properly, 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 |
| 2134 | return column values — so `snippet()` does not work and memoria must build |
| 2135 | its own highlight from the sealed chunk.** That is the trade, recorded as a |
| 2136 | decision: slice 0 uses the default table because a snippet is free there |
| 2137 | and the corpus is tiny; the snapshot design uses contentless, because 49 MB |
| 2138 | against 200 MB decides the wake budget. |
| 2139 | |
| 2140 | **A chunk is smaller than draft 2 assumed.** Measured mean: **202 words / |
| 2141 | 1325 bytes**, not 400 words / 1.6 KB. The chunker targets 400 words but |
| 2142 | folds runts and never straddles a heading, and real documents have |
| 2143 | headings every few paragraphs. Every figure here follows from the measured |
| 2144 | size. |
| 2145 | |
| 2146 | **The chunker is line-based, and three bugs made it so** (found reviewing |
| 2147 | the built code, not the spec). A heading is a heading whether or not a |
| 2148 | blank line follows it — `## Revenue` immediately above its text is the |
| 2149 | commonest Markdown style, and a paragraph-based splitter filed the whole |
| 2150 | thing as body with an empty trail, so those documents indexed with no |
| 2151 | heading at all. A `#` inside a fenced code block is a shell comment, and |
| 2152 | was becoming a heading that captured everything after it. And a file with |
| 2153 | no blank lines — a CSV, a log, hard-wrapped prose — became one chunk of |
| 2154 | the entire file, whose only citation read "passage 0" for everything. So: |
| 2155 | fences are tracked, headings close a chunk without needing a blank line, |
| 2156 | and **`MaxWords` (1000) is a hard ceiling on both a paragraph and a |
| 2157 | chunk**. The measured mean above is from the fixed chunker. |
| 2158 | |
| 2159 | **Rebuild vs snapshot.** Building the index took **6.8 s** at 90k chunks. |
| 2160 | Loading a sealed snapshot does not. **Snapshot, never rebuild** — above |
| 2161 | slice 0, where a rebuild is milliseconds. |
| 2162 | |
| 2163 | ### 7.3 The wake budget, and the real ceiling |
| 2164 | |
| 2165 | Snapshot 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, |
| 2173 | AES-GCM at ~1 GB/s. So 150 MB loads in **1.5–3.0 s plus 0.15 s decrypt**. |
| 2174 | |
| 2175 | A **5 s budget** at the conservative bandwidth admits ~250 MB. Against the |
| 2176 | measured footprints that is **about 90,000–110,000 chunks**. |
| 2177 | |
| 2178 | **Both earlier drafts got this wrong, in opposite directions, and the |
| 2179 | correction is worth keeping.** Draft 1 said ~100k and called it "the same |
| 2180 | ceiling the brute-force arithmetic gives, which is tidy" — a coincidence it |
| 2181 | had manufactured. Draft 2 called that out and derived ~150k instead, from |
| 2182 | an FTS figure that was wrong by half. So **draft 2 moved the number the |
| 2183 | wrong way while claiming to have fixed the arithmetic, and draft 1's |
| 2184 | ~100k was closer by accident.** Measurement, not rederivation, is what |
| 2185 | settled it. |
| 2186 | |
| 2187 | Brute-force cosine remains nowhere near binding: §7.2 measures 250k chunks |
| 2188 | at 80 ms. |
| 2189 | |
| 2190 | **The honest conclusion draft 1 missed: the wake budget binds first, and |
| 2191 | ANN does not help it.** An HNSW graph makes the snapshot *larger*. So the |
| 2192 | deferred item is not "ANN at 250k" but **an on-disk sealed index, or |
| 2193 | partial 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 |
| 2198 | deletion it predates.** GPT Sol's finding 6, which neither the other |
| 2199 | reviewer 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, |
| 2243 | snippets, generated answers, derived artefacts, API responses. There is |
| 2244 | no passage-only path that skips it, which is the whole of gap 2's fix. |
| 2245 | |
| 2246 | With AI off, the floor: a GET form, `?q=`, ranked passages, each carrying |
| 2247 | app, title, locator, 👨/🤖 marker, date, and a deep link. Scriptless, |
| 2248 | paginated, rastrillo `server` shape. Lexical only, since embeddings need |
| 2249 | a 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 |
| 2252 | calls no model in its deterministic layer (§8.8, decision 15 as |
| 2253 | amended), so that layer is present in exactly the mode this |
| 2254 | paragraph describes; gate 15 covers it with everything else here, and it |
| 2255 | renders 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 | |
| 2406 | Retrieval is free, so the router runs on results. Five classes, three |
| 2407 | calling 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 | |
| 2417 | Draft 1 wrote "filtered to what the asker can see" on the third row, |
| 2418 | which either restated §4.1 or smuggled a second per-person filter |
| 2419 | alongside 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 |
| 2422 | first draft.** A row here is a route, and a route is terminal: a query |
| 2423 | naming a fact kind would have stopped at the box, so *"what's the office |
| 2424 | phone number?"* with nothing validated in reach would get no answer where |
| 2425 | the single-source class would have given one. The box runs **before** this |
| 2426 | table and does not consume the query — Paul's constraint is deterministic |
| 2427 | *first*, not deterministic *instead*. It is a fourth way memoria answers |
| 2428 | without a model; it is not a fourth destination. |
| 2429 | |
| 2430 | Two configuration names — `model.cheap`, `model.frontier` — with the |
| 2431 | route printed on the answer. |
| 2432 | |
| 2433 | **The pinning rule bit the first two ids this section chose**, and the |
| 2434 | catalogue since settled how to satisfy it. The first pair were |
| 2435 | `z-ai/glm-5.3-flash` and `moonshotai/kimi-k3`, neither datestamped. **The |
| 2436 | live catalogue proves the concern rather than merely suggesting it: it |
| 2437 | lists `deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-pro-0813` side |
| 2438 | by side** — the same model as a bare alias and as a dated snapshot, which |
| 2439 | is 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 |
| 2446 | gate 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 |
| 2453 | off two undated ids. It did *not* force DeepSeek twice: the survey above |
| 2454 | lists a dated Qwen (`qwen3-235b-a22b-2507`), so a two-lab pair satisfies |
| 2455 | the rule equally. Choosing DeepSeek for both is a judgement about price, |
| 2456 | recency and family coherence — defensible, and **made without putting the |
| 2457 | Qwen option in front of Paul**, which §13 records rather than quietly |
| 2458 | keeps. |
| 2459 | |
| 2460 | **The concentration gets a stated response, not just a note.** Both tiers |
| 2461 | are now one lab as well as one host, so a DeepSeek withdrawal moves both at |
| 2462 | once. Name the fallback now — `qwen/qwen3-235b-a22b-2507` for the frontier |
| 2463 | tier, `z-ai/glm-5.3-flash` for the cheap one with gate 30 carrying it — so |
| 2464 | a withdrawal is a config change through §Review on a normal day rather than |
| 2465 | a research task on a bad one. |
| 2466 | |
| 2467 | **The canary is not demoted, and an earlier draft of this section demoted |
| 2468 | it wrongly.** At first configuration, embed a fixed set of public, |
| 2469 | non-content sentences — not team content, so §3.2 is untouched — seal the |
| 2470 | vectors, re-embed on wake, and **state an anomaly when cosine drift exceeds |
| 2471 | a stated ε**. Gate 24, covering all three pinned ids, and §11's sentence |
| 2472 | stands unamended: it is the only thing standing between a stable id and a |
| 2473 | silent swap. |
| 2474 | |
| 2475 | **A dated id at a reseller pins a label, not a behaviour.** DeepSeek does |
| 2476 | ship dated release artefacts, so `-0813` plausibly names lab weights — but |
| 2477 | TensorX did not train them, §13 records that id immutability *at TensorX* |
| 2478 | is unread, and everything short of the weights that moves a chat model's |
| 2479 | output is theirs to change under an unchanged id: quantisation, chat |
| 2480 | template, sampling defaults, context truncation. A dated id buys a name to |
| 2481 | print on an answer. It does not buy a detector. |
| 2482 | |
| 2483 | **`openrouter/auto` is disqualified**, and so is anything shaped like it. |
| 2484 | It selects by trailing-7-day community spend, changes weekly, and cannot be |
| 2485 | attributed — failing AGENTS.md §Review's "record which model" and decision |
| 2486 | 4's naming requirement. State the rule generally, because a router is not |
| 2487 | the only thing that breaks it: **a model id that can change under a team |
| 2488 | without that team changing it cannot be printed on an answer.** A floating |
| 2489 | alias is disqualified on identical grounds, which is why the two config |
| 2490 | names hold ids and not tiers — pinned where the catalogue permits it, and |
| 2491 | canaried where it does not. |
| 2492 | |
| 2493 | **Noted against ourselves:** OpenAI's dated snapshots satisfy this rule |
| 2494 | more cleanly than the catalogue §8.3 selects. That is a real cost of the |
| 2495 | provider choice, and §8.3 records it as one rather than talking past it. |
| 2496 | |
| 2497 | ### 8.3 Egress |
| 2498 | |
| 2499 | Per decision 4: one provider, fixed by the team owner, named on every |
| 2500 | answer, 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`) — |
| 2504 | open-weight models served on EU infrastructure in Dublin, Helsinki and |
| 2505 | Paris, with **advertised** zero retention. Advertised: no account exists, |
| 2506 | no terms have been read, and §3.6 tells the reader we cannot verify a |
| 2507 | retention promise. That sentence keeps saying so. |
| 2508 | |
| 2509 | **The provider is not team-configurable**, ruled by Paul on 2026-09-04. |
| 2510 | Supporting one is a code change; a team owner's choice is on or off. This |
| 2511 | is narrower than decision 4's "fixed by the team owner", and deliberately: |
| 2512 | a configurable base URL on an OpenAI-compatible client would let an owner |
| 2513 | point memoria at a proxy while the answer still printed "one named |
| 2514 | provider", which is the failure this whole section is about. Decision 13 |
| 2515 | records it. |
| 2516 | |
| 2517 | **Swapping later is cheap on one side and not the other.** Generation is |
| 2518 | one client, one base URL and two model ids, through code and §Review, where |
| 2519 | the two-parties question gets asked again. **Embeddings are a third id and |
| 2520 | the expensive one:** a new host that serves the same open `qwen3-embedding-8b` |
| 2521 | weights yields compatible vectors and costs a migration, and any other host |
| 2522 | costs a re-embed of the whole corpus — 24M tokens at an unmeasured |
| 2523 | wall-clock (§13). Saying "swapping stays cheap" without that split would |
| 2524 | contradict §13 two pages later. |
| 2525 | |
| 2526 | **Why this one, honestly stated.** Not because it beats OpenRouter and |
| 2527 | OpenAI 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 |
| 2558 | weights — 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). |
| 2560 | Going direct to OpenAI satisfies decision 4's letter at least as well: one |
| 2561 | counterparty, pinnable dated ids, zero-retention obtainable on request, and |
| 2562 | the subprocessor point cuts both ways since it runs on Azure. It loses on |
| 2563 | jurisdiction, on open weights, and on cost. Written that way the choice |
| 2564 | stands; 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.** |
| 2567 | Embeddings at ingest — every chunk, once (§7.2, not built). Answers — the |
| 2568 | passages behind a generated answer (§8.2, not built). And **§8.9's |
| 2569 | metadata extraction** — up to eight ruled passages of a search request |
| 2570 | that asks for a kind with no validator, a postal address, whether or not |
| 2571 | an answer would follow and including live searches while typing. The |
| 2572 | third was built first, on 2026-09-27, and the README discloses it alone |
| 2573 | because it is the only one the binary performs. |
| 2574 | |
| 2575 | **What it does not change.** This is still whole-corpus egress at ingest, |
| 2576 | and §3.2's leak list is unaltered: a provider that sees the corpus sees the |
| 2577 | corpus, whatever its jurisdiction. EU jurisdiction and zero retention |
| 2578 | constrain what may lawfully be done with the content *after* it arrives — |
| 2579 | the first thing on this page that constrains anything — but they do not |
| 2580 | reduce 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 |
| 2582 | floor 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 |
| 2585 | than 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 | |
| 2592 | That one sentence replaces what stood here — a qualification of Docs §11.2 |
| 2593 | written from memoria's side — and Docs cites the same sentence rather than |
| 2594 | its 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' |
| 2596 | rejection to be recorded rather than the rejection reversed, and a shared |
| 2597 | sentence 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 |
| 2600 | the rule and not our position under it: per §3.2, **memoria's embedding |
| 2601 | ingest is whole-corpus egress**, not merely answer-time top-k. Draft 1 |
| 2602 | compared Docs' whole documents against memoria's retrieved passages, which |
| 2603 | flattered memoria. Under suite §8 the difference is opt-in, naming, and the |
| 2604 | AI-off floor. **It is not a difference in what the provider sees, nor in |
| 2605 | how much.** |
| 2606 | |
| 2607 | **A "model call ledger" is content.** Any runtime record of prompts holds |
| 2608 | retrieved passages, so it falls under §3.2's log rule and §5.2's |
| 2609 | provenance rule. It is not the development prompt ledger of docs |
| 2610 | AGENTS.md, which records prompts to `refs/amadan/ledger/<branch>`; draft |
| 2611 | 1's "the prompt ledger" ambiguously named both. If a runtime ledger is |
| 2612 | built it is sealed, provenance-stamped, and expires with its sources. |
| 2613 | |
| 2614 | ### 8.4 Citations with an optional answer, never the reverse |
| 2615 | |
| 2616 | A citation list is always served without an answer. **An answer is never |
| 2617 | served without its list.** With AI on, a 🤖 paragraph appears above the |
| 2618 | list; every sentence carries a passage id from it, and a sentence citing |
| 2619 | nothing is dropped before render. **If every sentence is dropped, say |
| 2620 | so** — 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 | |
| 2628 | This is decision 3 applied to rendering, and §8.5 already states its other |
| 2629 | half — *"authorization runs before the model exists"*. It is a rule about |
| 2630 | what may enter a response or a prompt **per request**. It says nothing |
| 2631 | about interface shape, and it is enforceable: gates 17 and 26. |
| 2632 | |
| 2633 | What it forbids is **re-speaking**. A surface that puts previously |
| 2634 | retrieved passages back into a model prompt, re-renders them server-side, |
| 2635 | or stores them in a transcript row is memoria speaking those passages |
| 2636 | again, at a moment when nobody has asked whether this person may still see |
| 2637 | them. Carrying *query text* forward is not re-speaking and is not |
| 2638 | forbidden: retrieval and S2 then run from scratch, which is the whole point |
| 2639 | of retrieval being free (§8.2). |
| 2640 | |
| 2641 | **An earlier draft of this section argued the case badly, and the argument |
| 2642 | is worth recording as wrong.** It said a chat transcript is a cache of |
| 2643 | authorized passages because turn 1's snippets sit on screen while turn 4 is |
| 2644 | asked. That proves too much: it is equally true of a search results page |
| 2645 | left open in a tab, and of `/documents/{id}`, which renders a whole |
| 2646 | document's passages and stays open as long as the reader likes. **A |
| 2647 | rendered page is a copy in a browser and memoria's revocation never reaches |
| 2648 | it** — 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 |
| 2650 | transcript would breach §6.5's deadline. It would not: §4.1 batches 256 |
| 2651 | pairs per call and §6.5 makes a page one round of concurrent calls, so a |
| 2652 | four-turn transcript of eight passages is 32 pairs — one batch, one round, |
| 2653 | the same floor as a fresh query. **A correct chat is affordable under this |
| 2654 | spec.** 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 |
| 2659 | one page out**, with `/documents/{id}` as the only other passage surface. |
| 2660 | That follows from decision 5 — the search floor *is* the product — and not |
| 2661 | from decision 3, which a conversational surface could satisfy. Recorded as |
| 2662 | decision 14 — the spec author's ruling, in answer to a question Paul asked |
| 2663 | on 2026-09-04 and **confirmed by him on 2026-09-05**. A conversational |
| 2664 | surface that keeps the invariant above is not forbidden by this section; it |
| 2665 | is deferred by §12, where it can be argued on its merits rather than |
| 2666 | smuggled in as a corollary. Parking it there rather than ruling it out was |
| 2667 | the half that needed confirming, and it was confirmed explicitly. |
| 2668 | |
| 2669 | Two 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 |
| 2680 | floor: `index.html` renders source, title, ordinal, snippet and deep link, |
| 2681 | but **no 👨/🤖 marker, no date, and no pagination** — `Search(ctx, q, 25)` |
| 2682 | is 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, |
| 2684 | listed rather than glossed. |
| 2685 | |
| 2686 | Hybrid, then, in the sense that matters: **the search page is the product |
| 2687 | and 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.** `` 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 | |
| 2709 | On §7.2's measured corpus — 90k chunks, **24M tokens**: |
| 2710 | |
| 2711 | **Ingest:** 24M × $0.02/M (`qwen/qwen3-embedding-8b`) = **$0.48 once**. |
| 2712 | Re-embed only changed chunks. The figure survives the move off |
| 2713 | `text-embedding-3-small` unchanged because the two list prices happen to |
| 2714 | match — a coincidence, not a reason to stop rechecking it. |
| 2715 | |
| 2716 | This has been wrong twice, in opposite directions. Draft 1 priced 60M |
| 2717 | tokens 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 |
| 2719 | from §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 |
| 2722 | instructions — ~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 |
| 2724 | query**, 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 | |
| 2728 | Read that against one baseline, because an earlier draft used two. Draft 3 |
| 2729 | carried **~$5 cheap and ~$100 frontier** against a generic provider. So the |
| 2730 | frontier month fell about **4×** and the cheap month about **1.6×** — |
| 2731 | both moved, and the earlier claim that "the ceilings fell, the estimates |
| 2732 | did not" was true only of the intermediate glm/kimi pair and is now |
| 2733 | retired. Per query the cheap figure still sits inside the $0.001–0.003 |
| 2734 | range this section always carried; the frontier one, at $0.012, has fallen |
| 2735 | below its $0.02–0.10 range rather than sitting in it. |
| 2736 | |
| 2737 | Most of that came from §8.2 pinning dated ids from one family, not from the |
| 2738 | provider switch, and none of it is a reason to route more queries to the |
| 2739 | frontier tier — §8.2's three model-free classes remain the design, and this |
| 2740 | section's closing sentence still holds. |
| 2741 | |
| 2742 | Derived-artefact regeneration (§5.2) adds a synthesis-class call per |
| 2743 | invalidated 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 |
| 2745 | whose recognisers found nothing — a small fraction of a small fraction. |
| 2746 | **Cheap tier by choice, not by necessity**: Paul's ruling says "best value |
| 2747 | models", and extraction from eight passages is the cheap tier's work under |
| 2748 | §8.2. A better model would extract better — more complete addresses, |
| 2749 | cleaner boundaries, fewer omissions — so this is a cost-quality trade to be |
| 2750 | re-measured, not a consequence of the verbatim check. |
| 2751 | |
| 2752 | The money is recurring query tokens by an order of magnitude — the |
| 2753 | economic argument for §8.2's router and its three model-free classes. |
| 2754 | |
| 2755 | ### 8.7 Numeric and aggregate questions |
| 2756 | |
| 2757 | Paul's motivating example, 2026-09-03: *"what's average revenue in June the |
| 2758 | last 3 years"*. Ruled independently by Fable and GPT Sol, which converged; |
| 2759 | where they differ, the stricter reading is taken. |
| 2760 | |
| 2761 | **The tension this resolves.** §2 says memoria never recomputes a number, |
| 2762 | because a second calculator eventually disagrees with the first. But no |
| 2763 | cell contains "average June revenue over three years", so the question |
| 2764 | either gets forbidden or the rule gets read properly. What the rule |
| 2765 | protects is not arithmetic — averaging three doubles is deterministic — |
| 2766 | it is **selection, units and formatting**: actual versus budget, one year |
| 2767 | in thousands, `1234567.833` versus `€1,234,568`. Sheets §5 already names a |
| 2768 | second 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 |
| 2773 | author and cannot grow into one. Rejected alternatives: memoria computing |
| 2774 | and showing its working (a second calculator, arrived at one `mean` at a |
| 2775 | time); writing a formula into a scratch sheet — **and note this rejection |
| 2776 | now 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 |
| 2778 | is exactly that shape, so "forbidden by §6.4" is no longer the reason. The |
| 2779 | reason is decision 10 itself: a formula memoria authored is a calculation |
| 2780 | memoria owns, and a citation that cites itself. |
| 2781 | |
| 2782 | **Chunking a spreadsheet**, which §13 carried as unsolved. Two chunk kinds |
| 2783 | per 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 | |
| 2797 | That is what makes `revenue`, `June` and `2024` literal BM25 terms for a |
| 2798 | cell whose content is the bare number `1350000`. **Header detection is |
| 2799 | deterministic and conservative: where competing header bands make the |
| 2800 | meaning ambiguous, index the alternatives as structure and do not |
| 2801 | manufacture a fact.** |
| 2802 | |
| 2803 | **Retrieval, because top-k cannot prove completeness.** Decompose into |
| 2804 | named slots — `(Revenue, June, 2024)`, `(…, 2025)`, `(…, 2026)` — and |
| 2805 | resolve each by exact metadata lookup against the structure catalogue, |
| 2806 | using top-k only to find candidate regions. Recall becomes a per-slot |
| 2807 | count: |
| 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 |
| 2814 | certified unit.** Any gap, ambiguity, `STALE`, `AHEAD` or `UNKNOWN` |
| 2815 | yields the slot table with citations and **no number**: *"Found June 2024 |
| 2816 | and June 2023; nothing matched June 2025. No average shown."* A |
| 2817 | two-of-three average is the plausible-wrong answer that §8.1's loud |
| 2818 | withholding exists to prevent, and it is worse than an empty result |
| 2819 | because it is actionable. |
| 2820 | |
| 2821 | **"Compatible units" was unfalsifiable in draft 3, and this section's own |
| 2822 | example is the case it cannot catch.** Three cells formatted `General`, |
| 2823 | one of them typed in thousands, carry no signal that distinguishes them. |
| 2824 | `MIXED_UNITS` therefore never fires, the three numbers average cleanly, |
| 2825 | and memoria certifies a figure wrong by a factor of a thousand while |
| 2826 | citing all three cells correctly. A check that cannot fire is a check |
| 2827 | written to pass, and this one guards the number the whole section exists |
| 2828 | to protect. |
| 2829 | |
| 2830 | **So the test is inverted: a unit is certified, not contradicted.** Every |
| 2831 | operand 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 |
| 2841 | that 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 |
| 2844 | only when units are certified" *and* "S6 gains `UNIT_UNKNOWN`" — which |
| 2845 | cannot both be true, because a value S6 returns is a value that arrives |
| 2846 | after S6 was called. The review was right to force the choice, and it |
| 2847 | falls 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 |
| 2849 | neither-half-only.** The `format_code` comes from Sheets, but memoria |
| 2850 | already receives it (S6 returns it, and §8.7's structure chunk indexes it |
| 2851 | at ingest). The header word comes from memoria's own chunking, and Sheets |
| 2852 | has no idea what memoria's structure chunks say. An S6 that decided units |
| 2853 | would be deciding on half the evidence. |
| 2854 | |
| 2855 | So the certification rule is memoria's, and it is a fixed table rather |
| 2856 | than 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 | |
| 2866 | And the two refusals are memoria's verdicts, delivered before S6 is |
| 2867 | called: |
| 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 |
| 2875 | them.** This is the case the review asked to be ruled, and the ruling is |
| 2876 | the strict one: recognising `€k` and `€#,##0` as the same unit means |
| 2877 | multiplying one operand by a thousand, which is memoria doing arithmetic |
| 2878 | on a cell value — the exact thing decision 10 forbids, arriving as a |
| 2879 | helpful conversion. So they do not aggregate, `MIXED_UNITS` fires, and |
| 2880 | the refusal names both formats so the reader can see it is a formatting |
| 2881 | difference and fix it in Sheets. **memoria performs no unit algebra of |
| 2882 | any kind**: no scaling, no currency conversion, no percent-to-fraction. |
| 2883 | Tokens are compared for equality or they are not compatible. |
| 2884 | |
| 2885 | **A `General` cell under a `€k` header aggregates, and here is exactly |
| 2886 | what is displayed.** The header word certifies the operand as `€k`; if |
| 2887 | the 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 |
| 2890 | header** — *"Average June revenue, 2024–2026: 1,350 (€k, from the column |
| 2891 | header "Jun 2024 €k")."* It does not fold the unit into the number, does |
| 2892 | not render `€1,350,000`, and does not re-format anything: folding the |
| 2893 | unit in is formatting, and §8.7 exists because a second formatter is the |
| 2894 | same bug class as a second calculator. Where the unit came from a header |
| 2895 | word rather than a format code, the answer says so, because that is a |
| 2896 | weaker provenance and the reader should be able to check it. |
| 2897 | |
| 2898 | **Re-certification on the way back, because the indexed format can be |
| 2899 | stale.** memoria certifies from the format code it indexed, then compares |
| 2900 | it against the `format_code` S6 returns for the same cell. A |
| 2901 | disagreement means the sheet was reformatted since ingest: **no number**, |
| 2902 | the slot table, and the source marked for re-ingest. This is `STALE`'s |
| 2903 | logic applied to formatting rather than to content, and without it the |
| 2904 | certification runs on a snapshot of the thing it is certifying. |
| 2905 | |
| 2906 | **All three outcomes can fail, which is the point.** Three cells |
| 2907 | formatted `€#,##0` aggregate. One `€#,##0` against one `k€#,##0` fires |
| 2908 | `MIXED_UNITS`. Three `General` cells under a header that says nothing |
| 2909 | fire `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 |
| 2911 | answered wrongly and confidently. |
| 2912 | |
| 2913 | **The cost is real and it is not hidden.** Plenty of real spreadsheets |
| 2914 | are `General` throughout, and on those memoria refuses every aggregate |
| 2915 | until someone formats the cells or writes the unit into the header. That |
| 2916 | is a worse product than one which answers, and a better one than one |
| 2917 | which answers wrongly: the refusal names the cells and says what would |
| 2918 | fix them, in Sheets, where the fix belongs. **The spec's position is that |
| 2919 | there is no override switch**, because an override is the `General` case |
| 2920 | with a checkbox in front of it, and a checkbox nobody reads is how the |
| 2921 | wrong 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 |
| 2924 | trades a product cost nobody has measured against a correctness |
| 2925 | guarantee, 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 |
| 2927 | everything, the answer this spec would argue for is better header |
| 2928 | indexing rather than an override; but "we refuse most numeric questions" |
| 2929 | is a product decision, not a spec detail. |
| 2930 | |
| 2931 | **One limit, stated plainly:** a cell whose *value* was typed in |
| 2932 | thousands under a `General` format is undetectable to memoria, to S6 and |
| 2933 | to Sheets. Nothing in the suite knows. The header word is the only |
| 2934 | remaining signal, and where there is none, `UNIT_UNKNOWN` is the correct |
| 2935 | answer rather than a cautious one. |
| 2936 | |
| 2937 | **The answer names its basis**, including which years "last three" meant, |
| 2938 | because that is ambiguous on any given date. |
| 2939 | |
| 2940 | **What must not be built.** A retrieval-augmented calculator: retrieve |
| 2941 | top-k, let a model pick numeric strings, average them, present it as |
| 2942 | grounded. Both reviewers named this unprompted. It stacks three silent |
| 2943 | errors — incomplete recall, wrong operand selection, arithmetic and |
| 2944 | formatting outside Sheets — and every one of them cites correctly while |
| 2945 | being wrong. Also forbidden: memoria parsing a display string back into a |
| 2946 | number (`"€1.2M"`), which is a second parser and therefore a second |
| 2947 | calculator; and caching an S6 result, which outlives its cells. |
| 2948 | |
| 2949 | Gate 14 extends: **every digit in an answer sentence traces to a cited |
| 2950 | passage or an S6 result.** |
| 2951 | |
| 2952 | ### 8.8 The fact box: deterministic extraction above the list |
| 2953 | |
| 2954 | Paul's motivating example, 2026-09-14: a search for `phone number` should |
| 2955 | put the answer at the top of the page — *"Tito office number: +353 (0)1 |
| 2956 | 547 7854"* — and the same for an eircode, a VAT number. And his |
| 2957 | constraint, given in the same breath, which is the whole of this section's |
| 2958 | shape: **do as much deterministic ordering of the data as possible before |
| 2959 | any inference runs on it.** |
| 2960 | |
| 2961 | **Called the fact box, and not the lookup box, which is what draft 1 |
| 2962 | called it.** `query.IsQuestion`'s complement is already a lookup in this |
| 2963 | codebase — `TestALookupPrimesTheFirstResult` is about Enter, not about |
| 2964 | this — and a second meaning for the word in the same package is the name |
| 2965 | collision this repo's reviews keep finding. |
| 2966 | |
| 2967 | **This section is the deterministic layer, and it calls no model.** The |
| 2968 | kinds it serves are the kinds that have a validator. Decision 15 originally |
| 2969 | said the whole fact box calls no model, *ever*, and excluded any kind |
| 2970 | without a validator; **Paul amended it on 2026-09-19** and §8.9 is the |
| 2971 | model half he ruled in. Nothing in this section changes because of that: |
| 2972 | a validator-backed kind never reaches a model, and every guarantee below |
| 2973 | is a guarantee about *this* layer. Where a sentence here says "no model", |
| 2974 | it means this layer and not the request — §8.9 may call one afterwards, |
| 2975 | for 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, |
| 2978 | and it is not a class.** Drafts 1 and 2 both called it *"§8.2's fourth model-free class"* |
| 2979 | while also calling it a layer, which cannot both be true: a row in §8.2's |
| 2980 | table is a **route**, and a route is terminal. A query naming a kind would |
| 2981 | have stopped at the box, so *"what's the office phone number?"* with |
| 2982 | nothing validated in reach would get no answer where the single-source |
| 2983 | class would have given one. The box is attempted, and then §8.2 routes the |
| 2984 | query 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 |
| 2988 | request.** §8.9 now sits between this box and the router and may call the |
| 2989 | cheap tier for a postal address. A navigational query that names |
| 2990 | `address` can therefore cause a model call that no router class did — |
| 2991 | the router's three model-free classes are still model-free; the request |
| 2992 | carrying them may not be. |
| 2993 | |
| 2994 | **This section's own path touches nothing in §3.2.** No egress, no |
| 2995 | provider, no embedding — the deterministic box works on an instance with |
| 2996 | AI switched off, which is decision 5's floor and therefore the whole |
| 2997 | product 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 |
| 2999 | fact, its label and its kind are all content, and the query that named |
| 3000 | the kind is content too. |
| 3001 | |
| 3002 | **That claim is now load-bearing in a way it was not, because decision 15 |
| 3003 | has been amended and §8.9 adds a model.** What survives the amendment |
| 3004 | without qualification is everything in this section: recogniser, |
| 3005 | validator, label, cap, collision, citation. What §8.9 adds sits *after* |
| 3006 | all 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 |
| 3008 | egress" — and §8.9 exists as its own section rather than a paragraph here |
| 3009 | precisely 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 |
| 3014 | already returned and §4.1 already allowed.** There is no second index, no |
| 3015 | second query, no ingest-time half and nothing stored. `Store.Search` |
| 3016 | produces `Page.Results`; the box is extracted from those and from nothing |
| 3017 | else. |
| 3018 | |
| 3019 | That makes §8.4's invariant structural rather than careful: every passage |
| 3020 | in a response passed §4.1 **in the request that produced it**, and a |
| 3021 | candidate that is not in `Page.Results` does not exist. There is no |
| 3022 | second retrieval path, so there is no second authorization path to get |
| 3023 | wrong. |
| 3024 | |
| 3025 | **Both earlier drafts built a retrieval mechanism for reachability, and |
| 3026 | the cap is what killed it.** Draft 1 folded kind words into the main |
| 3027 | index; draft 2, after the IDF objection below, gave facts their own |
| 3028 | in-memory table queried by kind. Writing the retrieval step out |
| 3029 | concretely 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 | |
| 3037 | So the cap fires on the first query anyone types, on every kind, |
| 3038 | permanently, and the box never appears. **The only way to bound a |
| 3039 | corpus-wide fact query is to rank the candidates and keep the best three, |
| 3040 | which is exactly what "never by picking" forbids** — and a `LIMIT` before |
| 3041 | the ruling is worse still, because a withheld document then eats one of |
| 3042 | the slots and "count after the ruling" becomes unimplementable. The gate |
| 3043 | review reached this independently and from the other side. |
| 3044 | |
| 3045 | The ruled top-25 is already a query-scoped candidate set. Once candidates |
| 3046 | come from it, the facts table has no query-time job left, and its only |
| 3047 | other job was the ingest-time ranking lift. |
| 3048 | |
| 3049 | **Why the deferred fix is not "index the kind words", measured rather |
| 3050 | than asserted.** FTS5 computes a term's IDF across the table, so a term |
| 3051 | present in most rows scores near zero. On `modernc.org/sqlite`, 20 rows, |
| 3052 | best `bm25()` for `"phone"`: **-2.7169** with the term in one row, |
| 3053 | **-0.0000** with it injected into all twenty. A collapse, not a |
| 3054 | degradation. |
| 3055 | |
| 3056 | **Be exact about what that measures, because draft 2 overclaimed it.** |
| 3057 | Draft 2 said the injection would degrade *"exactly the query the NEAR |
| 3058 | comment works through"*, and on that comment's own fixture it would not: |
| 3059 | the chunk holding the office number already contains the word `phone`, so |
| 3060 | injecting it there moves that chunk's term frequency and length, not the |
| 3061 | corpus IDF. The real exposure is a corpus where **many chunks carry |
| 3062 | numbers and few say `phone`** — which is the shape a large real corpus has |
| 3063 | and the fixture does not. The number stands; the sentence it was attached |
| 3064 | to 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 |
| 3069 | it.** A fact in a document that does not reach the ruled top-25 is a fact |
| 3070 | the box does not have: an eircode written `D02 XY45` in a document that |
| 3071 | never says "eircode" is found only if something else about it ranks. |
| 3072 | Draft 1 named this and deferred the fix; draft 2's table did not actually |
| 3073 | remove it, since that table ran only when a kind was named and was inert |
| 3074 | corpus-wide under the cap. §12 carries it. |
| 3075 | |
| 3076 | **The box's presence depends on the retrieval limit, which is a real |
| 3077 | edge and is stated rather than discovered.** `Search(ctx, q, 25)` is a |
| 3078 | hard cap, so a fourth distinct value sitting at rank 26 leaves a box of |
| 3079 | three, and the same value at rank 25 removes the box entirely. That is |
| 3080 | not wrong — the box describes what this search found — but it means the |
| 3081 | box is a function of the page, not of the corpus, and nobody should read |
| 3082 | it as the latter. |
| 3083 | |
| 3084 | **Whether the cap made the box inert on the corpus that motivated it was |
| 3085 | unmeasured until Paul hit it, 2026-09-29.** He searched *"what is the |
| 3086 | office phone number?"* on the live instance and got 25 results and no |
| 3087 | box. The Office page alone carries the office number and a list of |
| 3088 | unused ones, so the ruled top-25 held four or more distinct valid numbers |
| 3089 | and the cap fired — on the very query this section was written for. This |
| 3090 | section used to say the remedy was *"a narrower kind lexicon or a |
| 3091 | per-kind cap, **not** a rule that picks"*. Neither of those would have |
| 3092 | helped: the lexicon was already right (the query asked for a phone |
| 3093 | number), and any cap large enough to pass would show a list of unused |
| 3094 | numbers as the answer. **Paul ruled the remedy instead: narrow by the |
| 3095 | reader'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 |
| 3098 | passages are withheld draws candidates from a smaller set, never a larger |
| 3099 | one — `rule` drops withheld results and §12's over-fetch-and-refill is |
| 3100 | deliberately not built, so the slot stays lost. Fewer candidates cannot |
| 3101 | be a leak. |
| 3102 | |
| 3103 | **A chunk row that is gone by the time the box reads it is skipped |
| 3104 | silently**, exactly as `Store.Search`'s `livePassage` check drops a stale |
| 3105 | hit, and for the reason recorded there. It does not fail the search: the |
| 3106 | box 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; |
| 3110 | chunk bodies are opened only by `indexDocument` and `Passages`. FTS5 |
| 3111 | snippets are 24 tokens with `<mark>` in them — too short and too marked-up |
| 3112 | to take a span or a label from. So the box re-reads the sealed chunk rows |
| 3113 | for the ruled results, after the ruling, under `seal.PurposeChunk`: at |
| 3114 | most 25 rows, one `IN` query, 25 AEAD opens. |
| 3115 | |
| 3116 | #### Why nothing is stored |
| 3117 | |
| 3118 | The rejected design is a **sealed, durable** `facts` table, written at |
| 3119 | ingest, 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 | |
| 3134 | Draft 1's third ground was that a plaintext `kind` column would be an |
| 3135 | inventory of which kinds of sensitive data the team holds — true, and |
| 3136 | irrelevant, because the proposal was for a sealed table. The honest |
| 3137 | residual for a sealed one is the per-chunk fact-row count, which is real, |
| 3138 | much smaller, and not on its own a reason. |
| 3139 | |
| 3140 | **Nothing new exists at rest, and nothing new exists in the index |
| 3141 | either.** That is the strongest property this section has and it came |
| 3142 | from deleting things. |
| 3143 | |
| 3144 | #### The recognisers, and what is not one |
| 3145 | |
| 3146 | A kind is in the box when a query word names it **and** a candidate |
| 3147 | validates. |
| 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 |
| 3159 | rather than implying five.** An eircode's routing key is a closed set, an |
| 3160 | IBAN has a mod-97 check, an Irish VAT number has a check letter derived |
| 3161 | from 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 |
| 3164 | here rather than left to the code: a bare run of digits is never a phone |
| 3165 | number.** The span must carry a `+`, or separators grouping it. This is |
| 3166 | not a hack bolted on to a weak validator — it is how documents write phone |
| 3167 | numbers, and without it `rank_test.go`'s own corpus yields false |
| 3168 | positives: `DUNS/D-U-N-S number is 985632364` is nine digits and a |
| 3169 | plausible Irish mobile. **The cost is stated too:** a document writing |
| 3170 | `0871234567` with no separators is not boxed. That is the safe direction, |
| 3171 | and it is the direction a refusal should fail in. |
| 3172 | |
| 3173 | **Three structural rules came out of building it, and each one is here |
| 3174 | because ordinary document text was boxed without it.** The implementation |
| 3175 | review 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 |
| 3206 | heading.** `SEE FORM P45 THEN P60` still yields `P45 THEN`, because |
| 3207 | case-sensitivity cannot separate an eircode from uppercase English, and |
| 3208 | uploads do carry all-caps headings. No regex fixes this; only a label or |
| 3209 | a dictionary would, and both are bigger than the problem. Recorded so the |
| 3210 | next person finds the limit rather than the surprise. |
| 3211 | |
| 3212 | **And eircodes are matched case-sensitively**, which is not fussiness. |
| 3213 | The identifier alphabet is most of the consonants, so case-insensitively |
| 3214 | it matches lowercase English after a road number or a form code: *"Take |
| 3215 | the N11 then exit at Bray"* boxed `N11 then`, and *"Form P45 then P60"* |
| 3216 | boxed `P45 then`. A cited fact that is really an English word reads |
| 3217 | exactly like a correct answer — §8.7's *"cites correctly while being |
| 3218 | wrong"*, arriving from a character class. Eircodes are written uppercase; |
| 3219 | a lowercase one going unrecognised is the cost and it is the right way |
| 3220 | round. |
| 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 |
| 3224 | corpus of uploads and Notion pages it is useful — one support address, |
| 3225 | cited. On a corpus carrying forwarded mail (§4.2's source list) every |
| 3226 | signature qualifies, the cap is passed, and the box is inert for that |
| 3227 | kind. That is the cap working, and the failure direction is silence rather |
| 3228 | than a wrong address. |
| 3229 | |
| 3230 | **`url` was in draft 1 and is dropped.** `scheme://host` is |
| 3231 | pattern-spotting with nothing behind it, so every link in every document |
| 3232 | qualifies 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 |
| 3236 | the box carries is its citation, to `/documents/{id}#p{n}`. No `mailto:`, |
| 3237 | no `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 |
| 3241 | address has no checksum, no fixed grammar and no length, so this layer has |
| 3242 | nothing to validate it with and produces nothing for it. **It is §8.9's |
| 3243 | first model kind**, which is what decision 15's amendment changed: before |
| 3244 | it, "no validator" meant "not a kind"; now it means "not this layer's |
| 3245 | kind". |
| 3246 | |
| 3247 | **`number` on its own primes nothing either.** The lexicon maps |
| 3248 | distinctive words and phrases; generic words count only in combination. |
| 3249 | |
| 3250 | **This is `query.openers`' trade made in the opposite direction, and draft |
| 3251 | 1 claimed it was the same one.** `openers` is deliberately stingy — *"the |
| 3252 | safe direction is not priming"* — because a wrong prime navigates away |
| 3253 | from a page somebody was reading. The fact lexicon can afford to be |
| 3254 | generous, and the reason is the validator, not a shared instinct: "mobile |
| 3255 | app" 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 |
| 3259 | an interrogative, so "what's the office phone number?" navigates nowhere |
| 3260 | today, and those are disproportionately the queries a fact box answers. |
| 3261 | Where both fire — "office phone number", no question mark — **Enter still |
| 3262 | goes to the first result, not to the box's citation.** The box is a thing |
| 3263 | to read, not a destination; changing what Enter means depending on whether |
| 3264 | a 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 |
| 3270 | punctuation, the `(0)`, and any extension written after it. It is what the |
| 3271 | box displays and the only thing the box displays. Separators inside a span |
| 3272 | include **U+00A0**, because Word and Notion paste non-breaking spaces and |
| 3273 | the chunker does not normalise interior ones — a recogniser written with |
| 3274 | Go's `\s`, which does not match U+00A0, silently finds nothing in pasted |
| 3275 | text. |
| 3276 | |
| 3277 | **The canonical form is match-only.** It is what candidate identity is |
| 3278 | keyed on, and what the validator runs on. It is **never rendered and never |
| 3279 | an 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 |
| 3281 | than two candidates. |
| 3282 | |
| 3283 | Two of this spec's own rules have to be read against that, and both are |
| 3284 | ruled 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 |
| 3298 | keeping visible: `+353 (0)1 547 7854` is not E.164 — E.164 has no spaces |
| 3299 | and no `(0)` — so "validated by E.164" was a rule the motivating case did |
| 3300 | not 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 |
| 3305 | span's position in the chunk text: |
| 3306 | |
| 3307 | 1. **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. |
| 3320 | 2. **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.** |
| 3323 | 3. **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. |
| 3328 | 4. **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. |
| 3336 | 5. **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).** |
| 3341 | Rule 1 anchors the run at the start of the line, and a list item's line |
| 3342 | starts with its marker. So `- Office: 01 547 7854` was labelled `- Office`, |
| 3343 | and a to-do item `- [x] Office: …` was labelled `- [x] Office`. For a |
| 3344 | Notion page, memoria's own renderer wrote that marker |
| 3345 | (`notion/markdown.go` renders bulleted items and toggles as `- `, to-do |
| 3346 | items as `- [ ] `/`- [x] `, quotes as `> `), so the label carried memoria's |
| 3347 | syntax as though the document had written it. (A numbered item's `3. ` |
| 3348 | was already dropped, because the sentence split treats a full stop after |
| 3349 | a digit as a sentence end. An earlier draft of this paragraph said |
| 3350 | otherwise.) |
| 3351 | |
| 3352 | **The rule:** before rule 2 runs, the run — from where rules 1 and 1's |
| 3353 | earlier-span clause start it, up to the value — loses its leading |
| 3354 | indentation and one leading marker: `-`, `*`, `+` or `•`, each followed |
| 3355 | by 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 |
| 3357 | 7854`) is untouched: its run is trimmed on the right to `-`, which has no |
| 3358 | space after the marker and so is not one, and rule 3 still sends it to |
| 3359 | the trail, exactly as before. The |
| 3360 | cost is stated beside narrowing's other limitations: a literal prefix |
| 3361 | shaped 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 |
| 3364 | 40, 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 | |
| 3371 | Under draft 1 both numbers are mid-paragraph, both fall to the heading |
| 3372 | trail, and both come back labelled `Office Address`: two identical labels, |
| 3373 | one of which the document explicitly calls **unused**. The box would have |
| 3374 | shown 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 |
| 3376 | anchoring fixes it by doing less: the labels become *"The office phone |
| 3377 | number is"* and *"We also have the following unused phone numbers"*, and |
| 3378 | the 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 |
| 3381 | false.** A boundary placed too *late* yields a shorter run that still |
| 3382 | passes 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, |
| 3385 | produced by the rule whose entire justification is that it never |
| 3386 | composes.** A short fixed exception list — `Dr. Mr. Mrs. Ms. St. No. ext. |
| 3387 | Ltd. Co.` — covers the common cases and is **stated as incomplete**. It |
| 3388 | must not grow into a sentence tokeniser; where it is wrong it is wrong in |
| 3389 | the composing direction, and saying so is the difference between a known |
| 3390 | limit and a discovered one. |
| 3391 | |
| 3392 | **60 characters is a guess.** What is not a guess is that it must clear |
| 3393 | the 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 |
| 3398 | signatures is **one** candidate, cited to the best-ranked passage carrying |
| 3399 | it. Two different numbers in one chunk are **two** — the fixture above is |
| 3400 | exactly that shape, and treating a chunk as the unit would hide the |
| 3401 | second. |
| 3402 | |
| 3403 | **Every rule below is applied per kind, and a multi-kind query renders one |
| 3404 | box per kind.** A cap counted across kinds would let two phone numbers and |
| 3405 | two email addresses suppress each other, and a collision check across |
| 3406 | kinds 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 |
| 3409 | never collapsed"*, and it resolves that by showing no number. **The fact |
| 3410 | box shows all of them instead, and the difference is not inconsistency.** |
| 3411 | An average built from two of three operands is a *wrong number*: the |
| 3412 | question had one answer and memoria produced a different one. Two phone |
| 3413 | numbers are two *right answers* to an underspecified question. §8.7's |
| 3414 | ambiguity is incompleteness, which can only be refused; this one is |
| 3415 | underspecification, honestly served by showing both, labelled and cited. |
| 3416 | |
| 3417 | **Never by picking.** Not highest BM25, not first, not longest. Rank order |
| 3418 | sorts a result list and carries no claim about which number is the office |
| 3419 | one. **The one narrowing allowed is a match between the reader's words |
| 3420 | and the document's own label, and only once the cap has fired** (Paul, |
| 3421 | 2026-09-29, below). That is not picking in this paragraph's sense: no |
| 3422 | score and no order are involved, every candidate is treated the same, |
| 3423 | and when two labels match, nothing renders. |
| 3424 | |
| 3425 | **A box row carries the document title and the heading trail as well as |
| 3426 | the label**, exactly as a result row carries both and for the reason |
| 3427 | recorded there: two pages called "Contact" under different documents are |
| 3428 | the same row to a reader who cannot see which is which. |
| 3429 | |
| 3430 | **Two candidates whose whole citation line collides — same title, same |
| 3431 | trail, same label — render no box.** Keyed on the line rather than the |
| 3432 | label alone, because a supplier page and the office page both falling to |
| 3433 | the trail `Contact` are perfectly disambiguated by their titles, and |
| 3434 | suppressing that box would be losing a real answer to a rule aimed at a |
| 3435 | different problem. Where the whole line does collide, the page would show |
| 3436 | one identical row twice against two different values — not an enumeration |
| 3437 | but a contradiction with no way for the reader to resolve it, and the |
| 3438 | results 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 |
| 3445 | no 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 |
| 3447 | he was shown: |
| 3448 | |
| 3449 | 1. *"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."* |
| 3452 | 2. *"Treat " - " as a break when working out labels."* |
| 3453 | 3. *"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 |
| 3457 | differently, for a reason he has not yet been told.** Part 2's premise |
| 3458 | was wrong. The ` - ` he saw was the search snippet flattening Notion's |
| 3459 | list lines. The stored text has real line breaks, which the label rule |
| 3460 | already treats as a boundary. The actual defect is the list marker |
| 3461 | those lines start with, and the list-marker rule above fixes that. **An |
| 3462 | earlier draft of this section went further than the ruling**: it |
| 3463 | narrowed below the cap, and it showed every match rather than exactly |
| 3464 | one. 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 |
| 3467 | than three candidates after the §4.1 ruling, it would render nothing, |
| 3468 | and narrowing is attempted. Otherwise nothing changes: a kind with three |
| 3469 | or fewer candidates renders or is suppressed by collision or rule 4 |
| 3470 | exactly as before, and the reader's words play no part. |
| 3471 | |
| 3472 | **The qualifiers** are the query's tokens (`fact.tokens`: lowercased, split |
| 3473 | on 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 | |
| 3486 | Pinned 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 |
| 3493 | every 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 | |
| 3524 | It names the matched qualifiers and the number of candidates left out, |
| 3525 | which is always at least three because the cap had fired. The count is |
| 3526 | taken after the §4.1 ruling, like every other count in this section, so |
| 3527 | a withheld document neither adds to it nor is revealed by it. It carries |
| 3528 | no 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 |
| 3531 | defence, and the line is what keeps that visible.** *"Injection"* below |
| 3532 | says the ambiguity rule fails safe against poisoning: a planted second |
| 3533 | number makes the box show both, or nothing. Narrowing changes that in |
| 3534 | one case. On a page already over the cap, a planted number whose label |
| 3535 | alone contains the reader's word becomes the only row: a single, |
| 3536 | confident, cited answer. The count line is the mitigation. It tells the |
| 3537 | reader that other numbers exist and that the box chose among them on |
| 3538 | the reader's own word. It is not a fix, and this spec does not claim |
| 3539 | one. |
| 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 |
| 3562 | reading passages, so there is no document-authored separator label to |
| 3563 | match. They keep §8.9's own rules. |
| 3564 | |
| 3565 | **Nothing here is logged**, as in the rest of this section: the |
| 3566 | qualifiers are words from the query, and the query is content. |
| 3567 | |
| 3568 | #### Where it sits on the page |
| 3569 | |
| 3570 | Inside the `results` template definition, not above it. `results.html` is |
| 3571 | rendered two ways from one definition — inside the page on a scriptless |
| 3572 | submit, alone on `GET /search` for `search.js` — and a box outside it |
| 3573 | would sit above a fragment that swapped underneath it, leaving query N−1's |
| 3574 | answer over query N's results on every keystroke. |
| 3575 | |
| 3576 | **Below §6.5's notices and above the list.** `results.html` already puts |
| 3577 | the withholding callout above the results because *"a reader who scrolls |
| 3578 | past the last result has already decided what the page means"*. A |
| 3579 | confident single answer above the line saying the page is partial is the |
| 3580 | same 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 |
| 3584 | one of them. |
| 3585 | |
| 3586 | **Zero candidates renders nothing at all** — not "no phone number found". |
| 3587 | The corpus is not the world, and a confident absence is this codebase's |
| 3588 | own worst failure shape. |
| 3589 | |
| 3590 | **Gate 14's first clause extends to the box** — every character of it |
| 3591 | traces to a cited passage. **Its second does not.** "An all-dropped answer |
| 3592 | says so rather than rendering empty" exists because an empty 🤖 paragraph |
| 3593 | is indistinguishable from AI being off; a box that does not appear is |
| 3594 | indistinguishable 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 |
| 3599 | openable citation, and that it is a partial one. The box sharpens the |
| 3600 | problem: it promotes a single document's assertion to the top of the page |
| 3601 | with a validated-looking format around it. `Office phone: +353 1 000 0000` |
| 3602 | in an uploaded file is a fact box, and no model was involved in believing |
| 3603 | it. |
| 3604 | |
| 3605 | Two 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 |
| 3625 | and gates 35–37 need no sibling and no model. **Gate 34 needs a custody |
| 3626 | fake that rules per item**, which `fixedCustody` is not — it returns one |
| 3627 | disposition for every candidate from a single field — so the gate lands |
| 3628 | with a fake extended to answer per item id, in `internal/memoria` at store |
| 3629 | level, which is where its "assert the struct, not the page" demand is |
| 3630 | natural 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 |
| 3632 | that is the renamed-test failure §10 opens with, committed twice in the |
| 3633 | section that cites it. |
| 3634 | |
| 3635 | ### 8.9 The model half: metadata deterministic analysis cannot reach |
| 3636 | |
| 3637 | Paul's ruling, 2026-09-19, amending decision 15: *"do as much as possible |
| 3638 | with deterministic analysis, and then attempt to build metadata with the |
| 3639 | best value models via TensorX that make sense."* |
| 3640 | |
| 3641 | The question put to him was narrow — should a postal address, which has |
| 3642 | no checksum and therefore no validator, be extractable by a model? **The |
| 3643 | answer he gave is general**: the deterministic layer is the floor and not |
| 3644 | the 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**, |
| 3646 | and 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 |
| 3650 | string comparison disposes" and that a hallucinated address "cannot reach |
| 3651 | the page". The check it described proves where a value's **bytes** came |
| 3652 | from. It does not prove the value is an address, that it is the whole |
| 3653 | address, that it is the relevant one, or that the model returned every |
| 3654 | address it was shown. Those are four model judgements, and this section |
| 3655 | now 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 |
| 3660 | kinds* — today exactly one, `address` — may be sent to a model. **A |
| 3661 | validator-backed kind never is**, whatever the deterministic layer did |
| 3662 | with it: produced candidates, produced none, or produced some that the cap, |
| 3663 | a collision or an over-long label then suppressed (§8.8). Draft 1 said the |
| 3664 | model runs "where the deterministic layer produced nothing", which invites |
| 3665 | treating an empty box as nothing; the box is empty for five different |
| 3666 | reasons in §8.8, and none of them is licence to ask a model for a phone |
| 3667 | number. |
| 3668 | |
| 3669 | **Kinds are independent.** "Phone number and postal address" is two |
| 3670 | requests: the phone goes to §8.8, the address to this section, and a |
| 3671 | validated 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 |
| 3675 | regardless; §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. |
| 3678 | Decision 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 |
| 3682 | section's extraction path and gate 39 — and nothing of the embedding |
| 3683 | half. §11 sequences provider calls into slice 2; this is the first of |
| 3684 | them to land. Draft 1 said gate 31 enforced the sequencing; it did not — |
| 3685 | gate 31 forbids logging a provider's response body and says nothing about |
| 3686 | when a provider may be called. What keeps content in the instance today |
| 3687 | is the switch: with `MEMORIA_AI` unset no extractor is wired and gate 39 |
| 3688 | asserts, 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 |
| 3693 | passages §4.1 allowed in this request, bounded at eight to match §8.6's |
| 3694 | answer-time budget. **Bounded by rank first and then thinned, never |
| 3695 | refilled**: a chunk deleted between the ruling and the read makes the |
| 3696 | payload smaller; it does not let result nine in. (The first build counted |
| 3697 | survivors instead, and a review deleted result one and watched 2-9 go |
| 3698 | out.) Not the twenty-five §8.8 scans: an address in result eleven is a |
| 3699 | recall limit this section accepts in exchange for a bounded payload and a |
| 3700 | response inside the deadline. Each passage is sent with an index, and |
| 3701 | nothing else about it — no title, no path, no id. |
| 3702 | |
| 3703 | **Per request, and that includes live search.** `GET /search` renders the |
| 3704 | results region on each pause in typing and runs this path like the full |
| 3705 | page does, so a query that asks for an address sends passages on every |
| 3706 | debounced keystroke that reaches the server. The request context bounds |
| 3707 | it — `search.js` aborts a superseded fetch and the call is cancelled with |
| 3708 | it — 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 |
| 3711 | both halves: the same recipient and the same kind of transmission as the |
| 3712 | answer path, but sent when a query names a model kind whether or not a |
| 3713 | generated answer would follow. **The README names it now**, because |
| 3714 | unlike the answer path it exists: it says what is sent, to whom, when, |
| 3715 | and that live searches count. §3.6 is the full assistant's paragraph and |
| 3716 | records why it is not yet the README's; §8.3 names this trigger beside |
| 3717 | embeddings and answers. |
| 3718 | |
| 3719 | **If decision 16 lands** — TensorX as zero-data-retention, treated as |
| 3720 | equivalent to local inference, proposed on `design/zdr-treat-as-local` — |
| 3721 | the retention wording here and in §3.2 aligns with it. **The transmission |
| 3722 | disclosure does not go**: decision 16 changes what is retained, not what |
| 3723 | is 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 |
| 3728 | passages, as a list of `{passage: <index>, text: <exact bytes>}`, and |
| 3729 | nothing else. **A response the provider marks truncated is rejected |
| 3730 | whole**, not partially used; a malformed one likewise. |
| 3731 | |
| 3732 | **Each proposal becomes a coordinate or is dropped.** For every returned |
| 3733 | `{passage, text}`: |
| 3734 | |
| 3735 | 1. `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. |
| 3738 | 2. `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. |
| 3741 | 3. **`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). |
| 3747 | 4. **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. |
| 3749 | 5. 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. |
| 3752 | 6. 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 |
| 3759 | two spellings are two candidates; the same bytes in two passages are one, |
| 3760 | cited to the better-ranked passage, as §8.8 does. |
| 3761 | |
| 3762 | **Cap, collision and over-long labels then apply to the accepted |
| 3763 | proposals**, per §8.8, with the same results: four accepted addresses |
| 3764 | render nothing, a colliding citation line renders nothing, an over-long |
| 3765 | label silences the kind. |
| 3766 | |
| 3767 | #### What does not survive, stated rather than implied |
| 3768 | |
| 3769 | Draft 1 said every §8.8 guarantee survives. Three do not, and a reader |
| 3770 | of 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 |
| 3792 | marked 🤖 and names **both the provider and the model** — "TensorX · |
| 3793 | deepseek/deepseek-v4-flash-0731" — and is visually distinct from a row a |
| 3794 | checksum vouched for. Both, because decision 4 names the *provider*: a |
| 3795 | row naming only the model names DeepSeek's weights and never the company |
| 3796 | that received the passages. (Draft 2 said "names the model" while citing |
| 3797 | the requirement to name the provider; a review caught the substitution.) A reader must be able to tell "validated" from "a model |
| 3798 | pointed 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 |
| 3801 | byte-exact span of a passage that passed §4.1 in this request and was |
| 3802 | sent; its label is the document's; its citation opens on it. **Its |
| 3803 | classification, 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 |
| 3808 | of writing) **is the chosen initial tier, not a consequence**. Paul asked |
| 3809 | for "best value models"; extraction from eight passages is the cheap |
| 3810 | tier's work under §8.2. A better model would extract better, and draft 1's |
| 3811 | claim that a string-checked task "does not improve with a better model" |
| 3812 | was wrong. Re-measure against labelled cases before treating the tier as |
| 3813 | settled. |
| 3814 | |
| 3815 | **Deadline: 800 ms**, §6.5's sibling deadline reused. The call rides the |
| 3816 | request's context, so an abandoned keystroke on the fragment route cancels |
| 3817 | it. Past the deadline, or on any provider error, the box renders without |
| 3818 | model rows and the page is otherwise unchanged. **Nothing from the request |
| 3819 | or the response is logged** — status and a fixed string only (gates 4, 31). |
| 3820 | |
| 3821 | #### Four surfaces the first build missed |
| 3822 | |
| 3823 | Found by the final pre-merge review (Codex GPT-6-Astra, 2026-09-27), each |
| 3824 | reproduced before it was fixed, and each a place content could leave or a |
| 3825 | claim 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 |
| 3858 | omission case above added to it. **The checks defend against invented |
| 3859 | bytes; they defend against neither the corpus lying nor the model being |
| 3860 | steered into silence.** |
| 3861 | |
| 3862 | #### What "that make sense" leaves open, and it is Paul's |
| 3863 | |
| 3864 | Which metadata is worth a query-time model call. Postal address is ruled |
| 3865 | in. Opening hours, a person's role, an effective date, a registration |
| 3866 | number in a format memoria does not know are plausible and unruled. **The |
| 3867 | proposed test** is "is the answer a verbatim span somebody would want |
| 3868 | cited" — anything needing summary is an answer, not metadata, and §8.2 |
| 3869 | owns answers. §13 records it. |
| 3870 | |
| 3871 | **And the deterministic half of Address is not built yet, which is the |
| 3872 | next step Paul's rule asks for.** A validated eircode certifies the |
| 3873 | address block it ends; extracting that block deterministically would serve |
| 3874 | most 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 |
| 3879 | locate, and never touches a validator-backed kind** (§8.9). Against a stub |
| 3880 | provider that records every request body and returns scripted responses. |
| 3881 | **Every case asserts on the recorded requests as well as on the page**, |
| 3882 | because draft 1's version could not see an egress that had already |
| 3883 | happened 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 | |
| 3919 | Draft 1 listed "render the model's string instead of the source slice" as |
| 3920 | a mutation this gate catches. **It cannot, and it does not matter:** for an |
| 3921 | exact match the bytes are identical. What matters is the coordinate — which |
| 3922 | occurrence, which passage, which citation — and the tuple assertions above |
| 3923 | are what see a wrong one. |
| 3924 | |
| 3925 | --- |
| 3926 | |
| 3927 | ## 9. Surfaces |
| 3928 | |
| 3929 | ### 9.1 What memoria exposes |
| 3930 | |
| 3931 | Draft 1's §9 specified `POST /ingest`, `DELETE /ingest/{doc}` and |
| 3932 | `GET /feed?since=` with per-consumer bearer tokens — **the inbound push |
| 3933 | surface decision 7 forbids and §6.1 argues against**. Both reviewers |
| 3934 | caught the contradiction; it was carried over from an earlier pass that |
| 3935 | predated the pull-only ruling. |
| 3936 | |
| 3937 | memoria 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 |
| 3950 | 2026-09-05 — `TestNoUndeclaredRoutes` walks the router's registrations |
| 3951 | and fails any this list does not name. main's copy of this paragraph |
| 3952 | said it was specified and not implemented, which was true of that |
| 3953 | branch and stopped being true on merge. The list stays written down |
| 3954 | anyway, because a gate read from a list that matches the app is worth |
| 3955 | more 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 |
| 3959 | deletes), the connector routes under `/connectors/…`, public |
| 3960 | `GET /help/open-source`, `POST /appearance` (the shared chrome's |
| 3961 | Appearance 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 |
| 3964 | page the person asked for). |
| 3965 | |
| 3966 | Two of those need saying out loud. **`GET /query?q=` above is not served |
| 3967 | and never has been** — the search page is `GET /?q=`, and the paragraph |
| 3968 | above 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 |
| 3970 | alone, from the same template `GET /?q=` renders it inside a page, so |
| 3971 | that live search can replace that region on a keystroke. It is a |
| 3972 | narrower surface than the page, under the same session gate, and it adds |
| 3973 | no passage the page did not already speak. It is not a second query |
| 3974 | surface and not a step toward one; delete the script and nothing calls |
| 3975 | it. |
| 3976 | |
| 3977 | **A hand-off whose sources include memoria-owned material is refused, and |
| 3978 | that is a real product cost.** Suite §7 lets a person hand an artefact to |
| 3979 | another app through their own session — "save this answer as a Doc" — but |
| 3980 | it requires the *receiver* to check every source with its app of record. |
| 3981 | For a passage from Docs or Sheets that works. For memoria's own uploads, |
| 3982 | Notion pages and forwarded mail (§4.2), **memoria is the app of record and |
| 3983 | serves nobody to ask**, because decision 7 gives it no sibling-facing |
| 3984 | surface. 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 |
| 3987 | implication. Opening it means a new decision with its own §3.2 leak entry |
| 3988 | and its own gate, exactly as §12 requires of any consumer of memoria — and |
| 3989 | decision 7 is settled, so widening it is Paul's and not a corollary. The |
| 3990 | cost is concrete and belongs in front of him: **an answer built on the |
| 3991 | team's own uploads cannot be saved into Docs**, while one built purely on |
| 3992 | sibling passages can. That asymmetry will look like a bug to a reader long |
| 3993 | before it looks like a ruling. *(Raised by GPT-6's review of the doctrine, |
| 3994 | finding 3, via A.4.)* |
| 3995 | |
| 3996 | **No feed. Nothing consumes memoria.** If something ever does, that is a |
| 3997 | new decision with its own leak-list entry, not an endpoint left lying |
| 3998 | around for it. |
| 3999 | |
| 4000 | **MCP is deferred** as a thin adapter over the query surface. Trigger: |
| 4001 | the first consumer that speaks it. |
| 4002 | |
| 4003 | ### 9.2 The home links; it never renders |
| 4004 | |
| 4005 | Home §2.2 forbids the home rendering content from an instance, and a |
| 4006 | generated answer is content; it also forbids it holding an authorization |
| 4007 | model, which choosing whose answer to show would be. memoria is per-team, |
| 4008 | so "the home knows everything about all the apps" across teams is |
| 4009 | federation. |
| 4010 | |
| 4011 | **The home gets one directory row (`app = memoria`) and a search box that |
| 4012 | is a GET form redirecting to the team's memoria**, call-6 style. memoria |
| 4013 | renders 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 |
| 4020 | gates 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 | |
| 4031 | The gates: |
| 4032 | |
| 4033 | 1. **Plaintext grep** over raw SQLite bytes and bucket objects, from the |
| 4034 | first commit. `plaintextExempt` starts empty. |
| 4035 | 2. **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. |
| 4084 | 3. **Feed rows carry no content and no visibility flag** — grep extended |
| 4085 | over S1 rows, asserting the absence of the field draft 1 had. |
| 4086 | 4. **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. |
| 4112 | 5. **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. |
| 4119 | 6. **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. |
| 4133 | 7. **An enumeration diff distinguishes empty from failed**, and deletes |
| 4134 | nothing on a timeout, credential error or partial page. |
| 4135 | 8. **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. |
| 4178 | 9. **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. |
| 4195 | 10. **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. |
| 4199 | 11. **Cross-team dial refusal**, including redirects. |
| 4200 | 12. **Token rotation** — all three phases, each interrupted and resumed, |
| 4201 | across a partial multi-sibling delivery. |
| 4202 | 13. **Two-consumer isolation** on the sibling endpoints. |
| 4203 | 14. **Answer sentences cite listed passages**; an all-dropped answer says |
| 4204 | so rather than rendering empty. |
| 4205 | 15. **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. |
| 4208 | 16. **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. |
| 4211 | 17. **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. |
| 4245 | 18. **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. |
| 4267 | 19. **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. |
| 4270 | 20. **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. |
| 4274 | 21. **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. |
| 4296 | 22. **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. |
| 4321 | 23. **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. |
| 4348 | 24. **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. |
| 4370 | 25. **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. |
| 4391 | 26. **`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`. |
| 4409 | 27. **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 |