oficina / memoria Public
File too large to show in full — download it above (showing a truncated preview below).
🤖 memoria — design
The Oficina suite's memory. One instance per team, at <team>.memoria.oficina.works.
Status: draft 5, 2026-09-04; amended 2026-09-06 with the suite sharing doctrine (Appendix A.4), the TensorX provider decision, §8.4's shape, and slice 1's client half. Not draft 6: draft 6 is the eleven draft-5 findings, which are still unfixed and still listed in §13.
Consumed suite rulings. This spec consumes oficina/suite docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md at revision 7, commit 2b78680 — with two amendments adopted since and not yet reflected here as a version bump: revision 8 (the module's importability, now closed) and revision 10, which adds AHEAD (§4.1). The module this repository pins is 60b01a8, which is revision 8's merge. Gate 9 fails a spec more than one revision behind, so this citation is owed a bump the moment the suite's branches merge; they cannot, because suite CI is unconfigured (§13). Rulings 1, 3, 4, 6, 7, 9, 14 and 15 are cited and never restated (suite ruling 7) — 15 at §5's custody mark, which is where "only the team rung is team-visible, so custody keys on the rung and never on the link" decides what memoria may hold. Not consumed yet: the hand-off (suite §7) — §9.1 records why memoria refuses one built on its own material — and S8, which needs a designated-area grant memoria has no UI for. S6 is cited as a specification; suite §12 step 4 builds it.
Review history, per AGENTS.md §Review. None of these models wrote the draft they reviewed; all drafts were written by Opus.
| Draft | Reviewers | Verdict | |---|---|---| | 1 | GPT Sol (commandcode), Fable | REQUEST CHANGES ×2 — 12 and 21 findings, three structural gaps found by both | | 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 | | 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.) | | 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 | | 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 | | 3, §8.3 provider amendment | Fable | REQUEST CHANGES — 4 MAJOR, 4 MINOR, 2 NIT. All applied; §1.7 records what it changed | | 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 | | 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 | | 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 | | 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 |
§1.2 records what the draft-1 reviews changed, §1.3 what the draft-2 review changed, §1.4 what the review of the built code changed, §1.5 what the draft-3 review changed, and §1.6 what the draft-4 review changed. §1.7 to §1.10 record the suite strand — the provider decision, §8.4's shape, the gate review and the doctrine adoption — which ran in parallel on its own branch and merged on 2026-09-06. Draft 5 has been reviewed once, with the attribution caveat in the table above, and returned REQUEST CHANGES. Its eleven required changes are NOT yet fixed — they are recorded in §13 under "Draft-5 review, not yet fixed", the same way draft 3's ten were, rather than quietly carried. Draft 6 is those eleven, and four of them wait on the NEEDS PAUL rulings.
Numbers in §7 are now measured rather than asserted, and recorded in docs/BENCHMARKS.md with the machine and command. Doing that found draft 2's FTS figure wrong by half.
---
0. The one rule
> memoria never speaks a passage without asking whoever owns it > whether this person may see it, right now.
Draft 1 said "memoria indexes only what the whole team can already see, and it never speaks a passage without re-checking that this is still true". Both reviewers showed that sentence does not survive contact with the suite: "the whole team" is not one fact (§1.2 gap 1), and the re-checking was specified for generated answers but not for ordinary search (§1.2 gap 2).
The rule above replaces it, and it splits by provenance because the suite does:
- Content copied from a sibling app — the sibling is the authority. memoria asks it about this person on every path that speaks a passage. memoria evaluates nothing.
- Content that has no other owner — Notion, Drive, forwarded mail, uploads. There is nobody to ask, so memoria is the app of record. What that authority consists of is §4.2's two facts and nothing else — the asker is admitted, and the document is present and not deleted. memoria holds no per-person grants, here or anywhere: holding them would be the authorization model §4.3 says it does not have. Drafts 1 through 4 all said "its own grants are authoritative", which named a table that does not exist and never will.
The second rule is Sheets' rule, unchanged: the bucket never holds a plaintext cell. For memoria this binds harder than it looks, because an embedding is a plaintext cell in a costume — §3.1.
---
1. Settled decisions (don't relitigate casually)
| # | Decision | Rejected alternative | Who, when | |---|---|---|---| | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 12 | Name: memoria. House convention is plain Spanish office nouns. A memoria is both a memory and, in Spanish office usage, a written record. | archivo; cerebro; base; anything English | Paul, 2026-09-03 | | 13 | The provider is TensorX, and memoria supports exactly one. A team owner's choice is on or off, not which — a configurable base URL would let an owner reinstate a proxy while the answer still printed "one named provider". Chosen for EU jurisdiction and open weights, criteria decision 4 did not state, at a known cost in id-pinning and frontier quality (§8.3). | OpenRouter (a proxy: two parties see content); OpenAI direct (better id-pinning, worse jurisdiction and cost) | Paul, 2026-09-04, both halves — "we go with exactly one for now: TensorX. If we want to swap that later we can." Swapping the generation provider is a code change and a review, not a setting; swapping the embedding provider is that plus a full corpus re-embed unless the new host serves the same open weights (§8.3, §13) | | 14 | memoria is a search page, not a chat. One GET search form, ?q=, one query in and one page out, with /documents/{id} as the only other passage surface. This follows from decision 5, not from decision 3 — §8.4's invariant is what decision 3 requires, and a conversational surface could satisfy it. | A chat UI with citations; a threaded follow-up. Deferred to §12, not forbidden — the invariant forbids the leak, this row chooses the shape | Spec author, 2026-09-04, answering a question Paul asked; confirmed by Paul 2026-09-05, parking the conversational surface in §12 rather than forbidding it | | 15 | ~~The 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 |
Three of these are now suite rulings, and this spec cites them rather than restating them. Decision 1 is suite ruling 1, decision 3 is ruling 3, decision 4 is ruling 6. They are unchanged in substance; what changed is where they live. Suite ruling 7 is why: every app spec restating the contract in its own words is what drifted, so an app spec cites the doctrine and never restates one. Where a row below still carries the reasoning, that is history and not a second copy — the binding text is the doctrine's, at oficina/suite, revision 7, commit 2b78680.
Decision 11 is superseded by ruling 4, and decisions 13 and 14 are memoria's own and stand: suite §8 adopts 13's "the recipient is the app's, never the team's", and ruling 3's "never cached, at any scope" is the invariant 14 rests on.
1.1 The proposal review, and what it killed
Before draft 1 existed, the proposal — ingest everything, store a per-viewer copy, route to whichever model OpenRouter picks — was reviewed and rejected on six counts. Four attached to one choice, store a per-viewer copy. They remain live traps for anyone widening this design.
- A per-viewer ACL on a synthesised answer is not expressible. Sheets §4.5's access is a hand-written grants join; memoria cannot evaluate it and a copy drifts. Killed by decision 3 — memoria now evaluates nothing, it asks.
- Revoke has a weeks-long tail. Sheets §9.1.1: the outbox drains on wake and on share mutation, "not on a ticker… a failed upsert may wait weeks". Killed by decisions 3 and 7: authorization is asked live, never cached, never pushed.
- The home cannot query memoria. Home §0, home §2.2. Accepted, not worked around — §9.2.
- ~~Blocked on person refs (home §13 step 4).~~ No longer true, and it was the more expensive kind of wrong. S2 needs a typed asker subject (suite §3), which in lone shape is a lowercased address; sibling tokens are a switch independent of the home token. So slice 1 runs against Sheets and Docs before any home exists — suite §12 steps 2 and 3. memoria had recorded itself blocked on something that was not in its way. Corrected 2026-09-05 via A.4; §11.
A citation note. That review is recorded in session memory and in this section; it is not in any repo file, so a reader cannot open it. Draft 1 cited it as though it were a document. Nothing here should rest on it that is not also argued here.
1.2 What the two reviews changed
Three structural gaps, each found by both reviewers independently.
Gap 1 — membership was four unreconciled tables. Sheets has its own members, Docs its own memberships, memoria its own. The home is forbidden from being the authority (home §2.2: "Never holds an authorization model") and no home call carries membership between instances. Draft 1's §4 claimed a person removed from the team is "refused at the door with zero latency"; in fact removing them in Sheets did nothing to memoria, and memoria kept serving them Sheets content. Draft 1's S2 asked "is item X team-visible?", never "is this asker entitled to it?".
The one-ACL ruling did kill per-item drift. It moved the drift onto the membership predicate, and draft 1 did not notice.
Fixed by decision 3: S2 now carries the asker. memoria asks the owning app "can person P see items X at revision R?", and that app answers from the grants join it already maintains. Membership and visibility become one question answered by the only correct evaluator, so there is nothing left to reconcile.
Gap 2 — the search floor bypassed the gate. GET /query returns passages with or without answer=1, and nothing required the passage-only path to call S2. §0's promise therefore held for generated answers — the smaller half of the product — and not for search, which decision 5 calls the product itself. Fixed by §8.1: one gate, both paths.
Gap 3 — external sources had no owner to ask. Drive, Notion, mail and uploads cannot map their native ACLs onto team-visibility, and draft 1 gave them no re-check at all; revocation surfaced only on a later enumeration diff, and memoria runs no timers. Draft 1's claim that it "forgets exactly when the sources do" was false for exactly the sources §3.3 says make memoria a bigger target.
Fixed by §0's split: for this content there is nobody to ask, so memoria owns it outright, and §4.2 says so instead of implying a re-check that cannot exist. Drafts 1–4 wrote that fix as "its own grants are authoritative rather than a copy". That sentence was the defect it was written to remove, one section over: it names a per-person grants table, memoria has none, and §4.2's authority is admission plus presence.
Beyond the three, both reviews found the same arithmetic errors (§7), the same contradiction between §9 and decision 7, and the same over-claim in §6.3. GPT Sol alone found the restore-serves-deleted-content ordering bug (§7.4) and the missing token-rotation handover (§6.2). Fable alone found that the README was false about egress volume (§3.2) — the single most product-damaging finding of the two.
1.3 What the draft-2 review changed
Draft 2 was reviewed by GPT Sol via Codex. It confirmed five fixes as answered (the search bypass, the pull/push contradiction, the barrier count, the rotation protocol, the corpus-count inconsistency) and found one blocker plus six live findings. Draft 3 answers them as follows.
The blocker is not mine to close, and that is the honest position. memoria declares the suite one trust domain and pulls plaintext bodies from siblings. Three source specs currently forbid exactly that — home §2.2 "Never federates. Data does not cross an instance boundary.", Sheets PRODUCT.md principle 3 "Never design a flow that implies data can move to another instance", Docs §8.2 "'Sharing is membership' forbids data crossing the instance boundary." memoria cannot overturn another app's settled decision in its own table. Decision 1 is Paul's ruling and stands, but it binds Oficina only once the source specs adopt it. Threads are open (§13); until they are answered, slice 1 is blocked on agreement, not on code.
"Nothing left to reconcile" was still false. S2 fixed sibling content, but admission used memoria's own members table and so did memoria-owned content — so a person removed from Sheets and Docs, and left in memoria, could still read the team's Notion, Drive, mail and uploads. Fixed by decision 11: admission is derived from the siblings.
A stale revision is not a denial. Draft 2 mapped it to NOT_VISIBLE, which §4.1 silently omits — recreating the plausible-empty-list failure the same draft claimed to have removed. Fixed by adding STALE (§4.1), which withholds loudly and refetches.
S3 could not do what it promised. It took (id, revision) with no asker, yet claimed the sibling "re-runs its grants join at fetch time". There was no person in the call. Fixed by deleting the claim, not by adding a person: S3 takes no asker, and §6.1 spells out why — custody is person-less, so the call that fills the corpus can be made at all, and a per-person S3 would have made ingest impossible.
This paragraph said the opposite until 2026-09-05 — "S3 carries the person ref too" — while §6.1 three sections later said it does not. Both could not be right, and the summary was the wrong one. Caught by the suite doctrine's review of memoria (A.4), which is the third time in this document that a §1 summary and its own body have disagreed.
Derived artefacts had no algorithm for external or mixed provenance — S2 exists only at siblings. Fixed in §5.2.
Restore had no durable deletion state. Replaying tombstones from a restored backup replays a log that predates the deletion, and retiring the superseded snapshot leaves a restored database pointing at a blob that is gone. Fixed in §7.4.
Live authorization had no availability design — no deadline, no concurrency policy, no partial-result contract, on the product's primary path. Fixed in §6.5, and it is the finding most likely to change the product rather than the spec.
The gates were weaker than the claims. Gate 5 proved an authorization call happened, which stays green with a constant VISIBLE or the wrong person ref. §10 rewritten accordingly.
One finding was withdrawn by the reviewer on its own second look (S2 transport failures needing a separate finding), and its substance is folded into gate 6 anyway.
1.4 What the code review changed
Slice 0 was built, then reviewed by Fable — the first review of the code rather than the design. It found the spec broadly honest and the sealed store real, and the gates that were supposed to prove it mostly unable to fail. Three consequences reach this document.
§3.2 gained two entries. The sign-in dial, which made a claim in §3.6 false; and the external_id oracle, which put a plaintext-derived identifier in a column §3.2 described as coordinates.
§3.6's AI-off sentence was wrong. "Nothing leaves this instance" is not true of an app whose sign-in asks a stranger's domain a question. "No document leaves" is, and is still the claim worth making.
§7's chunker description was aspirational. What was built dropped headings in the commonest Markdown form. The spec now describes the chunker that exists.
The general lesson, and it belongs here because §10 is built on the opposite assumption: three of five gates marked "mutation-tested" could not fail for the thing they named. A gate is a claim about the world, and an unmutated gate is an unverified claim — including, twice on this branch, a gate whose subject had been deleted while it stayed green.
1.5 What the draft-3 review changed
Draft 3 was reviewed by Fable: 22 findings, REQUEST CHANGES. Ten were recorded in §13 as acknowledged and not fixed rather than quietly carried, and draft 4 is those ten: six weak predicates, two plain gaps, one warm-up that could not be made, and one correction made inside the draft itself. Draft 4 wrote "those ten" over a list that added to nine, because the warm-up and the correction were folded together; they are two items and are counted as two below.
Six were a predicate that could not fail. MEMBER meant "known to this app", so a guest satisfied it. MIXED_UNITS could not fire on three cells formatted General, so a wrong average got certified. The circuit breaker's "everything" fired on the routine case, so its notice would be learned and ignored before the day it was true. Gate 2 named "a threshold" and never gave one. Gate 4's credential half asserted the absence of a link the test app never sends. The S7 cache had no TTL, so it took rastrillo/auth's 30 days by omission. Each is AGENTS.md's rule about guards written to pass, arriving in a different costume — and the missing TTL is the other rule as well: a default is not a decision, the way a failed count is not a zero.
Draft 4's answer is to make each of them fail by default. A unit is certified rather than contradicted (§8.7). Admission requires a positive statement of team membership, and a sibling that cannot make that statement admits nobody (§4.3). A gate asserting an absence must first assert the presence of the thing whose absence it checks (§10). Where a number was missing, draft 4 gives one and says what it costs.
Two were plain gaps rather than weak guards. S2 had no value for a sibling that is behind memoria, which a restore produces and which fell through to the one silent value — fixed by AHEAD (§4.1). And derived provenance was not transitive, so two hops laundered what one hop cannot — fixed by flattening provenance to roots at generation time (§5.2).
One was a warm-up that could not be made. §6.5 had memoria fire a warm-up S7 on its own wake. S7 takes a person ref and there is nobody there on a wake, so no fixture could ever have received that call. The warm-up is an S1 poll now, which is the call that already has no asker — and which a gate can therefore observe (gate 24).
And one is §1.4's lesson repeating itself inside the same draft. The warm-up error is draft 2's per-person S3 again, made three sections after the correction for it was written, in the same document, by the same author. It is counted separately because it is a different kind of finding: the item above is a call that has to change, this one is the observation that writing a correction does not inoculate the next section against the mistake it corrects. §10's two rules exist because of it.
1.6 What the draft-4 review changed
Draft 4 was reviewed by Claude Fable 5.1, which wrote none of the drafts: REQUEST CHANGES, 16 required changes. Draft 5 is those sixteen. Four groups, and the first is the one that matters.
A fix that renamed its problem instead of removing it. Draft 4 rewrote §8.1's circuit breaker because draft 3's fired on the routine case, and the rewrite's prose promised "a property of the sibling, not of one asker" — while its predicate still counted zero VISIBLE across whoever happened to query that day. A 24-hour outage of UNKNOWNs therefore tripped the refusal breaker; three Docs-only members querying on a day the Sheets user was away tripped it too. The predicate counts NOT_VISIBLE now, and the signal is a per-asker transition (§8.1). This is the third time this project has shipped prose that claimed more than its predicate delivered, which is why it is first.
A silent-omission hole in the fix for a silent-omission hole. AHEAD was added in draft 4 to stop a sibling's restore reading as an empty list. Draft 4 then specified no reconciliation: chunks at revisions the sibling no longer has were withheld one at a time, forever, while still consuming top-k — so a page of 50 could render nearly empty with no notice, the cardinal sin arriving through the door AHEAD was cut to close. §4.1 now specifies the re-announce as a complete statement, retirement under gate 7's rule, revision override in either direction, and the removal of a reconciling source from the ranking. Gate 27 is new and holds all of it.
Three contradictions the drafts carried rather than resolved. §8.7 said both that S6 is called only when units are certified and that S6 returns UNIT_UNKNOWN; memoria certifies now and S6 does not (§8.7). §2 said memoria holds the owners list and holds no roster four lines apart; the owners list is not a roster and decision 11 now carries it as a stated exception. And "memoria's own grants are authoritative" survived in three places after §4.2 established there are no per-person grants — the same defect draft 3 shipped between §2 and decision 11, relocated (§0, §1.2, gate 18).
Nine gates that could not fail for the thing they named, which is §1.4's lesson landing on §10 itself: gate 2 asserted an absence with no presence assertion, gate 4 named a route from memory, gate 23's concurrency half was always-red or never-red on its own fixture, gate 24 raced the first query, gate 25's field-name regex let principal and who through, and gate 26 named the instance key where the code fingerprints under the DEK — verified in internal/memoria/store.go, which calls s.sealer.Fingerprint, and the sealer holds the DEK. A gate that goes red on correct code is worse than no gate.
1.7 What the provider review changed
The §8.3 amendment — OpenRouter out, TensorX in — was written by Opus and reviewed by Fable, which returned REQUEST CHANGES with four MAJOR findings. Two of them are the same mistake in different clothes, and it is the mistake §1.4 is about: stating a rule and then breaking it in the paragraph that states it.
§8.2 wrote a pinning rule its own selections failed. The rule — a model id that can move under a team without the team changing it cannot be printed on an answer — is right, and generalises openrouter/auto's disqualification properly. Then it selected moonshotai/kimi-k3 and z-ai/glm-5.3-flash, neither datestamped, both plausibly aliases over dated checkpoints. The fix is a datestamped id where one exists, gate 30's canary where none does, and §13 recording that the vendor's id-immutability policy is unread. The draft had even quoted the one datestamped id in the catalogue as evidence — and it was the model it did not pick.
§8.3 claimed as established what §13 called untested, three paragraphs apart. Worse, the verification it proposed could not have established it: the live call settles whether one model name is proxied, not whose GPUs run the weights. "One contractual counterparty" is not "one organisation sees the corpus" — a subprocessor sees content in §3.2's sense — so the claim is now written as narrowed rather than closed.
§7.2 specified a gate that could not fail. It said a mutation of the 4096→512 renormalise would be caught by watching recall fall. But cosine similarity is scale-invariant: against a true cosine, skipping the renormalise changes no ranking, and the gate stays green forever. The spec had never said whether search computes cosine or a raw dot product. It now says dot product — which is what §7.3's flop count always assumed — and gate 28 asserts unit norm deterministically instead of inferring it from quality. This is the fourth gate in this project to have been written in a shape that could not go red, and the first caught before it was built.
Decision 13 exists because of this review. The amendment named a provider in §8.3 without recording the ruling in §1, which AGENTS.md says is how a settled decision gets relitigated in a drive-by.
The general lesson, distinct from §1.4's: a rule and its first application should be reviewed by different people. Whoever writes the rule has just persuaded themselves, and is the worst-placed reader to notice that the very next paragraph fails it.
1.8 What the second provider review changed
The §8.4 amendment — memoria is a search page, not a chat — was reviewed by Fable, which returned REQUEST CHANGES with five MAJOR findings. The ruling survived; the argument for it did not, and the way it failed is worth more than the ruling.
The case was built on a misreading of this spec. §8.4 claimed a chat could not be built correctly, because re-authorizing a transcript each turn would breach §6.5's deadline. §4.1 caps a batch at 256 pairs per call and §6.5 says one page is one round of concurrent calls, the floor is the slowest sibling and not their sum. A four-turn transcript of eight passages is 32 pairs: one batch, one round, the same floor as a fresh query. A correct chat is affordable. The draft had also conceded the point in the act of denying it — "re-authorizing the whole transcript on every turn is the only correct version" says a correct version exists.
Its other argument proved too much. A chat transcript is a cache of authorized passages — true, and equally true of a search results page left open in a tab, and of /documents/{id}, which renders a whole document and stays open indefinitely. That is a fact about rendered pages, not about chat; it now sits in §3.2's leak list where it applies to everything.
What survived is narrower and enforceable. The real rule is about re-speaking: no passage crosses a request, in a response or in a model prompt, without §4.1 in the request that carries it. That is decision 3 applied to rendering, it is what §8.5 already said about the model, and gate 32 asserts it against the prompt builder. Carrying query text forward breaks nothing.
And the shape is a choice, so it is recorded as one. Decision 14 rests on decision 5 — the search floor is the product — not on decision 3. A conversational surface that keeps the invariant is deferred to §12, to be argued on its merits. The row is attributed to the spec author with Paul unconfirmed, because unlike decision 13's two halves it was not his ruling. (He confirmed it on 2026-09-05; the attribution stands as written, since who made a ruling and who approved it are different facts.)
The lesson, distinct from §1.4's unfailable gates and §1.7's rule broken in the paragraph that states it: a preference will always find a rule that seems to compel it. The search page was the right answer for decision 5's reasons. Reaching past those for decision 3 made a choice look forced, and cost two misread sections to do it. When a constraint arrives conveniently, check it against the text rather than the memory of the text.
1.9 What the gate review changed, and the rule it produced
The branch's third review covered gate 17 and the live-key verification. Fable returned REQUEST CHANGES with three MAJOR findings, and one of them is the most useful thing any review has done here.
It mutation-tested the gate itself. Gate 17 shipped with a comment naming three mutations, each verified red. A reviewer took the test into a scratch copy and tried five more — all ordinary chi and ServeMux idioms, none exotic — and all five passed green. r.Route("/chat", …) was blessed by the declared "/" inside it. r.With(mw).Post was invisible because the receiver is a call, not an identifier. r.Delete passed on a path declared only for GET. r.Handle("/") bound every method at once. And mux.HandleFunc("/documents", …) served a declared path while bypassing requireSession — worse than an undeclared route, because the allowlist actively approved it.
The rule that follows, and it belongs in AGENTS.md: mutation-testing your own gate tests the mutations you thought of, and those are the ones you already defended against while writing it. Three green mutations chosen by the author is not evidence the gate works; it is evidence the author is consistent. The mutations must be chosen by someone who did not write the gate. This is §Review's argument, one level down: the reviewer of a rule should not be its author, and neither should the adversary of a test.
The rewrite keys on method and path — which §10's own list had declared all along and the test had not implemented — and separates the chi and ServeMux namespaces. Eight mutations now, all verified. The rewrite also nearly shipped its own bug: following an arbitrary selector chain to find the receiver made r.URL.Query().Get("err") resolve to r, reporting two query parameters as undeclared routes. Caught by running it.
The other two MAJORs are the familiar shape. §8.3 still asserted TensorX "runs the weights it serves" on the strength of a 403 that had no control probe — the reading recorded was the middle of three, and the worst one is that the model name is in their routing table and merely gated. And two of Paul's four rulings were announced as recorded in QUESTIONS while being in neither §1 nor §13, with the interpretation of the load-bearing one living only in a commit message. That is §1.8's failure by omission: the strong reading asserted where reviewers do not look.
1.10 What the adoption review changed, and where an adoption goes wrong
A.4 was reviewed three times in the suite repo before memoria adopted it. memoria's application of it was then reviewed once, by Fable, and came back REQUEST CHANGES with a blocker and nine majors. The distribution is the lesson.
The blocker was a rule invented while citing the section that forbade it. §4.1 needed the custody intersection. The doctrine puts it in the app of record, inside S2: the sibling runs its grants join for the named asker and then answers NOT_VISIBLE for anything not team-visible. memoria wrote instead that it would render a passage when S2 says VISIBLE and the item was team-visible when acquired — a rule that is vacuous (everything memoria holds came through S3, which serves only team-visible items, so the second half is true of every held item), that does not close the leak (a document downgraded from team to granted with the asker's grant intact passes both halves), and that argued against the doctrine's own mechanism as a ruling-3 violation. The doctrine had explicitly retracted that reasoning — "a later visibility change is exactly what the defect turns on" — in the section memoria cited for it.
The other eight majors are one shape: the commit checked what it opened. S7 was retired in §1 and §4.3 and left alive in the §6.1 contract table, in §6.5's warm-up, in §4.1's "this subsumes membership", in §2's members table and in five §13 items still listed as open after this commit closed them. §11 corrected the home dependency in one paragraph and reasserted it twelve lines below, in the same commit. The Docs §11.2 qualification was deleted from §8.3 and still owed in §11 and §13. The blanket write ban went from §6.4 and stayed load-bearing in §3.3, §8.5 and decision 10's rejected column.
The rule, and it is specific to adopting someone else's document: the risk in an adoption commit is concentrated in the sections you did not open. A rewritten section has been read twice; an untouched one that depended on the old rule has been read never, and it now contradicts the new one while looking untouched and therefore fine. Grep for the retired thing by name before claiming it is retired — S7, person_ref, members table, forbidden by §6.4 — and read every hit.
Two smaller ones worth keeping. §4.1 had inverted why the asker subject is typed, saying the type is fixed at configuration because instances are provisioned together — which is the reasoning suite §3 names and rejects, "an operational habit, not an enforced property". And gate 21 claimed its fixtures had been replaced; there were never any fixtures, the gate does not exist, and the entry now says so.
A postscript, because the same mistake then happened twice more in one hour and neither instance was caught by a rule that exists to catch it. On 2026-09-06 this branch merged a local main that was five commits behind the remote, having compared the wrong ref; in the same hour the suite session read 617eb2a — this branch's base — as memoria's main and concluded draft 5 was unmerged, then made a doctrine ruling partly on that belief. Remote main was 9b55ab8 and carried draft 5 the whole time. Two sessions, two directions, one unrun command: fetch before you read a ref you did not just write. It is in suite AGENTS.md already, which is the point — a rule nobody runs is a rule that catches nothing, and it is now three-for-three at catching nothing.
And a second lesson from the same exchange, which is the suite session's and is the more useful of the two: cite the ref, not the claim. A claim arriving from a peer gets less scrutiny than one arriving from a file, and it should get more — a file can be opened at the commit you name, while a peer's claim carries no provenance at all. You cannot see which checkout they read, whether they fetched, or whether they were quoting or remembering. Both of this week's cross-repository mistakes have that shape: a doctrine ruling made on something memoria told the suite, and a memoria merge made on something the suite implied. When you tell another repository a fact about this one, give the commit it is true at. When one tells you, check it before you rule on it.
And a rider the suite session earned the hard way an hour later: citing the ref is not enough if you read it with a tool that sees one line at a time. Its check of this fix grepped for a rule that spans two lines, got zero matches, and came within one message of reporting that nothing had been fixed. A false negative from a correct ref is worse than not checking, because it arrives with the authority of having looked. Read the surrounding lines; a count of zero is a claim like any other.
One finding was wrong, and how it was wrong is worth as much. The review could not resolve 6797bbf or 4aea8f4 and said §13's blocker was therefore not shown closed. The hashes are correct — they are refs/heads/main on the hub, confirmed by git ls-remote — but every local checkout on this box is behind, because amadan branch merge lands on the remote and touches no working tree. The reviewer was right that the claim was unverifiable as written, and §13 now says which ref the hashes are on and how they were checked. A citation that only resolves for whoever wrote it is not a citation.
1.11 What the merge review changed
The branch merged eighteen commits of main — the Notion connector, multi-file drop, draft 5 — and was then reviewed as a whole. REQUEST CHANGES, eight MAJOR. Every one of them was in something the merge did not open, which is §1.10's lesson arriving for the fifth time and the reason it is now a rule in AGENTS.md rather than an observation.
Three were guards that could not have worked.
.gitignore was committed with conflict markers in it, and make ci was green throughout, because git parses the marker lines as harmless patterns. A broken file that no gate in this repository can read.
The egress gate's sibling exemption cited sibling.TestClientRefusesHostsOutsideTheTeam as the runtime guard paying for it. There was no such test. Every test in the package built its own &http.Client{}, so nothing exercised suite.NewSiblingClient and a constructor returning a client with no team check and no redirect refusal would have stayed green. AGENTS.md's second named failure — a gate naming a test that is not there — inside the comment claiming the payment.
And "one predicate" was not true of production: main.go passed Admits(cfg.Owners) to NewAuth and separately called SetOwners(cfg.Owners), two closures over two copies of one rule, while handlers.go asserted there was "no second value to disagree with". The fix is not a gate: Admits is deleted and app.Admit is a method on the one list, so there is nothing to build twice.
Four more were mutations the author had not thought of, found in the sibling client after five author-chosen ones had all gone red. The worst rendered a count for NOT_VISIBLE — the one value that must be silent — telling a reader how many things they may not see. The most instructive bounded the deadline against itself, so Deadline = 30s passed and a page could hang for half a minute with the suite green. A test that measures a constant against itself measures nothing.
And the rest were the merge's own seams. S7 came back into §4.3 and decision 11 from a branch written before ruling 4. §2 contradicted itself four lines apart. §13 carried two lists arguing — an item truncated mid-sentence, the same question live in one copy and struck in another, a header counting four items of which two were dead. Twelve gate citations pointed at the wrong gates after the renumbering, one sentence naming the same gate under two numbers.
The lesson that is new, and it is about reviewing rather than writing: this pass found more than the four before it combined, and it found it by comparing the merge against both parents rather than reading the result. A merge is the one change where reading the output cannot tell you what is missing.
---
2. What memoria is
A fourth per-team instance app, beside sheets, docs and calendar, with its own DEK and its own origin. It holds the team's content in a sealed index and answers questions over it with citations.
It has no members table, and draft 3 said it did — in this sentence, word for word from draft 2, three sections before decision 11 rejected the table. A reader of §2 built it; a reader of decision 11 did not. Admission is §4.3's.
The owners list is not a roster, and draft 4 called it one four lines above saying memoria never holds one. The app refuses to start without a platform-configured owners list. What separates the two is not size: a roster answers "who is on this team", changes as people join and leave, and would have to be reconciled against whoever else answers it. The owners list answers "who operates this instance", is fixed at provisioning, does not grow one person at a time, and is reconciled against nothing because nobody else holds an opinion about it. Extending it per person would make it a roster, and that is the move decision 11 rejects.
Suite ruling 4 adds a third thing, and it is not either of those. In suite shape memoria holds a pulled copy of the home's roster (suite call 8, refreshed by epoch, §4.3). That is not a members table — memoria authors none of it and reconciles none of it — and it is not the owners list, which still answers a different question and still bypasses it. Three distinct things, and this branch briefly collapsed two of them by writing "its own roster copy" into the sentence above, four lines from the paragraph warning against exactly that.
What it is for. Nothing in the suite answers a question. Docs ships title and metadata search; Sheets has a recency list; the home's call 3 says where your things are. Asking "what did we decide about X" and getting an answer grounded in the team's own documents is the gap.
What it never does.
- Never decides who may read sibling content. It asks (§4.1).
- *Never authors a roster, which is the claim that survives ruling 4. It holds the home's, as a pulled copy refreshed by epoch (§4.3), and it reconciles none of it and writes none of it. In lone shape there is no roster at all and admission is the owners list. This bullet said "never holds a team roster. Admission is relayed from the siblings" until 2026-09-06, four lines below the paragraph describing the pulled copy — and §4.3 by then asked no sibling anything.*
- Never writes to a sibling, and exposes no sibling-facing surface.
- Never acts as a person. It holds no signing key; every assertion's
audbinds to "app + instance host" (home §5.3). - Never crosses a team label (§6.3).
- Never holds an individually-granted sibling item. S3 answers on team-visibility alone, so such an item cannot enter the corpus.
- Never trains or fine-tunes. Weights are the one copy that cannot be forgotten (decision 8).
- Never recomputes a number. A question with an arithmetic answer cites the cell; Sheets' engine is the app of record. A brain that recalculates is a second calculator that disagrees with the first.
---
3. Trust model and the leak list
Sealed at rest under memoria's own DEK, HKDF purpose keys, AES-GCM, as Sheets §2 and home §3. What follows is what that does not cover.
3.1 Embeddings are content
The case that a vector is not plaintext: nothing in the bucket contains the bytes salary 50000; the plaintext-grep gate passes; a vector is a lossy derived artefact.
The case that it is: this suite already seals derived artefacts — Sheets §2.1 seals the cached computed value even though it is only a function of sealed inputs — and embedding inversion is not hypothetical. Published vec2text-style attacks recover 32-token inputs close to verbatim, and 32 tokens is the size of a cell, a title, or a sentence.
Ruled: chunk text, embeddings and the index graph are all §0 content. An unsealed embedding is a plaintext cell that happens to beat a byte grep — precisely the failure home §5.1 names as "turning a guarantee into a comment".
This applies to FTS5 too, and it shapes §7. FTS5 stores term postings in plaintext, so the index cannot live in the replicated SQLite file.
Searching sealed vectors directly is not available. Encrypted-ANN schemes are research-grade and orders of magnitude slower. The working shape is the one Sheets §4.4 uses for its handle: seal the serialised index as one blob, decrypt into a refcounted in-memory handle on wake, evict on idle. The graph is sealed with the vectors, because neighbour edges alone reveal which passages resemble each other.
3.2 The leak list
From the bucket: document and chunk counts; sealed blob sizes; source app and coordinates per chunk, plaintext exactly as Sheets §2.2 keeps coordinates; ingest and update timestamps, therefore which sources are busy and when; embedding dimension — though "therefore the model", as this line read until draft 3, overstated it even then: the design has always stored 512, which under OpenAI was a requested dimensions and under §8.3 is a truncation of a native 4096. Either way the stored width names a choice of ours. It narrows the candidate models; it does not name one. Do not lean on that.
From a rendered page. A response body is a copy in someone's browser, and no revocation reaches it. A results tab left open on Tuesday still shows Tuesday's authorized snippets on Friday; /documents/{id} shows a whole document for as long as it stays open. This is true of every surface memoria has or could have, so it is not an argument against any particular one (§8.4 tried to use it as one and was wrong). memoria's guarantee ends at the response body, and §8.4's invariant is what keeps the next response honest.
From a live host — the largest entry, and new. A compromised memoria host reaches the team's corpus across every app at once, plus §5.1's connector credentials. §3.3 works through how this compares to Sheets; §3.5 lists what shrinks it.
From logs, errors and metrics: a question is content — "what does Bob earn" is a cell value phrased as a query. Sheets AGENTS.md's rule extends to cover query text, answer text and cited snippets. Token counts per query still leak length.
From the index structure, even sealed: blob size tracks corpus size; update cadence tracks ingest; the platform observes timing, volume and which team is awake.
From a sibling: bodies transit the router in plaintext on every S3 fetch (§6.1).
From the circuit breaker's counters — a per-person record, and the only one memoria keeps. §8.1's breaker needs a 7-day baseline and memoria hibernates, so its counters are durable: per (sibling, asker, UTC day), a VISIBLE count and a NOT_VISIBLE count. Sealed under their own purpose key, so this is not a bucket leak; it is a statement about what memoria holds. Anyone who can read them learns, per person per day, how much of each sibling that person was allowed to see — which is a shape of the team's permissions, not its content. Retained 8 days, and the retention is load-bearing rather than tidy: an unbounded version is a year-long history of who was locked out of what. The alternative is a breaker that cannot fire (in-memory, reset every wake), which §8.1 rejects because a guard that is off reads as a guard that found nothing.
From the sign-in form — and this entry understated it for three drafts. rastrillo's classifier resolves and fetches the submitted address's domain when a sign-in begins, before the admission gate. The entry used to stop at "the instance's existence, reachability, egress address and a timing channel; no content", which is what an arbitrary domain buys. Reading the fetcher on 2026-09-05 showed it buys more: the host= in a domain's _keymail TXT record is taken verbatim, with no allowlist, no scheme or port restriction and no private-range check, and fetched by a client that follows redirects. So the destination is the submitter's to choose, not merely the domain's — an unauthenticated SSRF whose reach is whatever this instance's network can reach.
memoria's half is closed (gate 33): the admission predicate now runs before the classifier, so an unadmitted address produces no DNS query and no fetch. Two things that does not fix. An owner still reaches the unhardened fetcher, and every other rastrillo app is untouched — the real fix is upstream (§13, and SECURITY-DRAFT-rastrillo-signin.md). And the guard sits in front of signin's per-address rate limiter, so an owner and a stranger become distinguishable after five submissions — the owner's sixth answers ?err=rate, the stranger's answers ?sent=1 forever, and refused addresses consume no per-IP budget, so enumerating the owners list is unthrottled. That is a smaller leak than the dial and it is a real one; the budget is inside signin and cannot be reached from here.
This is listed because draft 3 let the product claim "nothing leaves this instance", which it makes false.
Not a leak, corrected: a document's external_id was an unkeyed SHA-256 of its body, which let anyone holding the bucket and a candidate file confirm the team holds it, and read that row's size, timestamps and chunk count. A content hash is not a coordinate, whatever it looks like. It is an HMAC under its own purpose key now (seal.Fingerprint), which keeps idempotent re-ingest and removes the oracle.
From the provider, when AI is on — and this is bigger than draft 1 said. Draft 1 stated only query-time egress: ~10 chunks × 500 tokens per question, so that over months "the frequently-consulted part of the corpus has left in full". That understated it by the corpus.
§8.9 adds a second query-time trigger, and it is not the same as the first. Same recipient, same category of transmission — passages that passed §4.1 in this request — but a different trigger and a bounded, possibly different payload: the top eight ruled results are sent when a query names a model kind (a postal address) and deterministic extraction cannot serve it, whether or not the query would have produced a generated answer at all. A search for "office address" that §8.2 routes as navigational still sends passages. The disclosure has to say that in the same breath as the answer path; "passages behind an answer" is no longer the whole of it.
Embeddings come from a hosted API (§7.2). So with AI on, every chunk of every document leaves at ingest, on day one. §8.6 prices it: 24M tokens for a 90k-chunk corpus. The frequently-consulted part leaving is the second leak, not the first.
Two consequences the spec carries rather than buries:
- A chunk later downgraded or deleted has already left. Decision 8's provenance-or-deletion governs memoria's copy. It cannot reach the provider's.
- Decision 5's AI-off floor is therefore a strong position, not a degraded one. With AI off, BM25 over sealed chunks, nothing leaves the instance — not at query time and not at ingest. That is why §11 builds it first.
3.3 memoria is not less sealed than Sheets. It is a bigger target.
Asked directly by Paul, 2026-09-03, and answered here because every future reader will ask it.
At rest there is no difference. memoria seals under its own DEK with the same construction as Sheets, and the same plaintext-grep gate runs against its bucket from the first commit.
An earlier draft argued the difference was RAM — that a Sheets host holds decrypted handles only for open sheets (Sheets §4.4) while memoria holds everything. That is mostly wrong and is recorded so it is not reintroduced. A compromised Sheets host holds the DEK and can decrypt its entire bucket on demand; the open-handle distinction is the difference between reading memory and running a decrypt loop, which is no barrier to anyone with code execution.
The real difference is that the corpus is a union. Compromise Sheets and an attacker gets that team's sheets. Compromise memoria and they get sheets and docs and calendar and Notion and uploads and forwarded mail. Same lock, more in the room.
Three refinements. The first favours memoria; the third draft 1 got backwards.
- Per sibling app, memoria holds strictly less than the app does. Decision 3's custody rule keeps individually-granted content out, so memoria's copy of Sheets is a strict subset. The union is larger; every sibling part of it is smaller.
- The RAM point is real but narrow. memoria's index must be fully decrypted to be searchable (§3.1), so its corpus sits in cleartext memory as routine operation. Against RCE this is marginal. It bites for bounded memory over-reads — a Heartbleed-class bug reads live memory, not the key's reach. Draft 1 also listed core files, swap and hypervisor snapshots; that was too broad, since those capture the DEK too and reduce to the decrypt loop. Narrowed on review.
- Connector credentials are blast radius, not window, and draft 1 said the opposite. Draft 1 claimed a compromised memoria "yields the sibling read tokens… the same content memoria already holds, so it widens the window rather than the blast radius". True of sibling tokens. False of §5.1's connector credentials: a Drive OAuth grant reaches the authorising account's whole Drive, and a Gmail label connector needs mailbox-wide read scope. Neither is bounded by what memoria ingested. A compromised memoria reaches further than memoria ever held. §3.5 mitigates it.
What memoria does not do is weaken its siblings' sealing. It holds no sibling's DEK, cannot read a sibling's individually-granted content because S3 404s it, and gains no write path except suite §7's two, which are narrower than "no writes" and differently shaped (§6.4): a person's own hand-off, carried by their browser under their own session, and S8 as memoria itself into an area the team owner designated for it. Neither gives memoria a capability over a sibling's content; the first is not memoria writing at all.
3.4 A citation is exactly as trustworthy as the app it cites
A compromised sibling has no inbound path to memoria and holds no memoria token, so it cannot read or write. What it can do is lie: serve fabricated content on S3, answer VISIBLE for a person not entitled, withhold tombstones. memoria's re-check asks the same lying app.
There is no mitigation, only disclosure. The README says this sentence.
3.5 Cheap mitigations
- memoria hibernates. Home §2.1 made the home the estate's one exception; memoria is not another.
- Idle eviction of the decrypted handle — the mitigation that matters most for §3.3's bounded-over-read class.
- Per-source subsets, owner-selected, default all.
- A published-only mode, in which the sibling carries an explicit "offer to memoria" mark that it evaluates.
- Refusing sources by kind. Calendar bodies off by default.
- Least-privilege connector credentials — now a requirement, not a nicety (§3.3). Prefer per-folder or per-label scopes over account-wide ones; prefer a dedicated service identity over a person's OAuth grant; store them under a distinct HKDF purpose key so a chunk-store disclosure does not yield them.
3.6 The honest claim
The README's first paragraph, in substance. Rewritten after review: the draft-1 version was false about egress volume, which PRODUCT.md principle 1 treats as a defect rather than a wording choice.
This is the paragraph for the full assistant, and it is not in the README yet, on purpose. §8.9 shipped first: with MEMORIA_AI=on the binary sends the top eight ruled passages of a search that asks for a postal address, and nothing else. The text below says "every document in the corpus, once, when it is indexed" — true of embeddings, not built, and false of the binary today. PRODUCT.md principle 1 makes overstating egress as much a defect as understating it. So the README states exactly what §8.9 sends, and this paragraph replaces it the day embeddings land. If decision 16 lands first, its retention wording replaces "we rely on their retention terms and cannot verify them" in both — and the transmission disclosure stays in both, since decision 16 changes what is retained and not what is sent.
> memoria's index and passages are sealed at rest under this team's own > key, the same way your sheets and documents are. Nothing readable > reaches storage. What is different about memoria is not the lock, it is > what is behind it: memoria gathers your team's work from every app into > one place, so whoever breaks into it gets all of it at once rather than > one app's worth. That is the point of it and the risk of it. > > With the assistant switched off, no document leaves this instance — > memoria searches your content where it sits. > > Signing in is the one exception, and it is not about your documents: > memoria asks your email address's own domain how to verify you, so that > domain learns this instance exists. Nothing of yours is in that request. > > What you connect, memoria keeps. Disconnecting a Notion space or a > Drive folder, or revoking our access to it, stops memoria seeing new > changes — it does not remove what memoria already holds. That copy > stays readable to your team until someone deletes it in memoria. > Documents in Sheets and Docs work the other way round: those apps stay > in charge, and memoria asks them on every search. > > Switching the assistant on sends your content to the provider named > in your settings — not just the passages behind an answer, but every > document in the corpus, once, when it is indexed. That is how the > search understands meaning rather than matching words. The provider can > read what it receives; we rely on their retention terms and cannot > verify them, and content sent before you deleted something cannot be > recalled. We never claim we can't read your data, and with the > assistant on we never claim nobody else can either.
---
4. Authorization
4.1 Sibling content: memoria asks, never decides
Every path that would speak a passage originating in a sibling app calls S2 first, carrying the asker:
S2(subject, [(item_id, revision), …])
→ per item: VISIBLE | NOT_VISIBLE | STALE | UNKNOWN
(memoria also wants AHEAD; the contract cannot carry it yet — below)The asker is a typed subject, and the type travels on the wire (suite §3): {kind: person_ref | address, value} — a person ref in suite shape, a lowercased address in lone shape. On the wire is the point, and this spec first got it backwards by saying the type is fixed at configuration because two instances that talk were provisioned together. The doctrine names that exact reasoning and rejects it: "provisioned together" is an operational habit, not an enforced property. With the kind on the call, a receiver that keys on the other one answers UNKNOWN for the whole call rather than guessing — which is a loud failure instead of a silent mismatch between an address and a person ref that happen to collide. Suite gate 2 tests it, and memoria's gate 6 gains the case.
The sibling answers from the grants join it already maintains — Sheets §4.5, "rows this team can see, plus rows granted to me individually, minus rows I have been removed from". memoria evaluates none of it.
AHEAD is adopted and not yet shipped. Settled 2026-09-06 as suite doctrine revision 10, on memoria's argument.
Draft 4 added AHEAD for the case where the sibling is behind — its log stops short of the revision memoria holds, or it has no row for the item at all — and the reasoning is good: STALE means memoria is behind and refetching fixes it, NOT_VISIBLE is the silent value and would delete history the sibling still has, and UNKNOWN "would report no answer when the sibling answered". Three wrong homes, so a fourth value.
Suite revision 7 had ruled the case UNKNOWN; revision 10 replaces that with AHEAD, in the shape memoria proposed and with one half declined — see below. The module carries it on an unmerged branch, so the version this repository pins (60b01a8) still has four constants.
So the wire shape above is four values today and five when the module ships, and every reference to AHEAD in this spec is written against the five-value design. That is a shipping lag, not a disagreement, and the distinction matters: nothing here needs redesigning, and nothing here can be built until the module lands. Suite CI is unconfigured, which is what holds it (§13).
How it got settled, because the route matters more than the outcome.
Record the doctrine's reasoning as the doctrine records it: suite §4.3 attributes the UNKNOWN ruling to its own GPT-6 review, finding 11 — never VISIBLE, because that authorises a revision nobody can vouch for; never NOT_VISIBLE, because that is the silent value — and its trigger was draft 3's open §13 item, not a question from this branch. It weighed two of draft 4's three wrong homes. What it did not weigh is the third: that UNKNOWN reports no answer when the app of record answered, precisely about its own log.
An earlier version of this paragraph said the suite "was asked by this branch, which had not read draft 4, and answered without memoria's argument in front of it". That is wrong twice — it credits this branch with the question and the doctrine with none of the reasoning — and it is the more embarrassing way to be wrong, because it makes another repository's careful work look like an accident of mine.
The argument that carried it was not the original one. The suite's objection — that AHEAD for an item with no row at all opens an id-existence oracle — was right and that half is withdrawn (§4.1's no-row rule above). What survives is the half that does not leak: a revision beyond the sibling's log, for an item it still admits.
And the reason that case cannot be handled by the feed instead: a whole-log restore rewinds the sibling's cursor, and S1a re-announces unasked. A partial restore — a content table restored without its event log — leaves the cursor perfectly valid, so nothing fires and nothing can, because from the log's point of view nothing happened. Only a per-item answer can see it. The two mechanisms are complementary, not overlapping.
Until the module ships, memoria maps the case to UNKNOWN and loses the distinction between "the sibling answered, and is behind" and "the sibling did not answer" — the collapse AHEAD was cut to prevent. §13 carries it as a shipping dependency rather than an open question.
The sibling intersects its own answer with custody, and memoria depends on that (suite §4.3, and the S2 row of suite §6):
> S2's answer is intersected with custody. The app of record runs its > grants join for the named asker, and then answers NOT_VISIBLE for any > item that is not team-visible, whatever the asker's individual grants > say.
Read Sheets §4.5 again — "plus rows granted to me individually" — and the reason is immediate. An item that leaves team-visibility appears in S1 as gone, which §4.2 requires to be indistinguishable from deletion. An unintersected S2 distinguishes them perfectly: VISIBLE for the one that went private, when a grantee is the asker, and NOT_VISIBLE for the one that was deleted. That is the leak the S1 visibility flag was removed to prevent and the leak S3's "404, never 403" was designed to prevent, arriving on the third call.
The cost is real and it is memoria's to disclose. A person who still holds an individual grant loses that passage here, and any answer built on it, while the document stays open to them in the app of record. That is the price of §4.2's indistinguishability and it cannot be avoided while that promise stands. memoria's honest move is §6.5's: name the app the answer went to, rather than quietly return a shorter one.
What memoria must do about it, since the mechanism is the sibling's: depend on it, and refuse to trust an S2 that has not demonstrated it. Before memoria consumes a sibling's S2, that sibling passes suite gate 3 — the same item id answers NOT_VISIBLE in each state — including the transition fixture: ingested while team, downgraded to granted with the asker's grant intact, asked before gone arrives.
An earlier version of this paragraph put the intersection on memoria's side, as "S2 says VISIBLE and the item was team-visible when acquired". Three things were wrong with it and a review found all three. It was vacuous — everything memoria holds arrived through S3, which serves only team-visible items, so the second half is true of every held item and false of none: a guard written to pass. It did not close the leak, because the downgrade-with-grant case passes both halves. And it argued against the doctrine's actual mechanism as a ruling-3 violation, when a sibling applying its own two rules to its own answer is not authorization cached or asked per person. The doctrine had already retracted exactly that reasoning — "a later visibility change is exactly what the defect turns on" — and this spec re-adopted it while citing the section that retracted it.
Five-valued in memoria's design, four on the wire today (see AHEAD above), and each value exists because collapsing it into another produces a plausible empty list — the failure shape sheets-core AGENTS.md names as the worst available.
| Result | Means | memoria does | |---|---|---| | VISIBLE | this person may read this revision | render it | | NOT_VISIBLE | this person may not read it | omit it silently — correct, and the only value that may be silent | | STALE | the item exists and is readable, but the sibling has moved past this revision | withhold loudly and refetch. Not a denial | | 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 | | UNKNOWN | the sibling did not answer usefully | withhold loudly |
STALE is here because draft 2 did not have it, and mapped a stale revision to NOT_VISIBLE — which is silently omitted. An ingestion delay therefore looked exactly like "you have no matching results", recreating the failure draft 2 claimed to have removed. A synchronisation condition is not an authorization decision and must not share its value.
AHEAD is here because draft 3 did not have it, and the draft-3 review found the hole. STALE is defined as the sibling having moved past the revision memoria asked about. Nothing covered the opposite: a sibling restored from backup holds revision 5 of an item memoria indexed at 7. It cannot authorize a revision it does not have, so the natural answer was NOT_VISIBLE — the one silent value — and the plausible empty list comes back through the restore door, on the day the suite is least healthy and least likely to be believed.
What memoria does with AHEAD is not what it does with STALE, and the difference is the whole reason for a fifth value. STALE means memoria is behind, and refetching fixes it. AHEAD means the sibling lost history, so refetching that revision 404s and refetching the item yields revision 5 — an older body which is now the sibling's truth. So memoria withholds the passage loudly, marks the source for the full re-announce §6.1's S1 row already defines for a rewound cursor, and deletes nothing: a sibling's restore must not purge memoria's corpus, and the re-announce is what reconciles the two. Wholesale AHEAD from one sibling is a restore signal and §8.1's third breaker names it as one, rather than as the mis-scoped predicate it is not.
"No row at all" is NOT_VISIBLE, and draft 4 had it as AHEAD. Corrected 2026-09-06 against suite §4.3, which governs. The argument for AHEAD was good and is kept below, because the cost of overruling it is real and should not be lost:
> A sibling that has never heard of an item memoria holds is in the same > position as one whose log stops short of it: memoria holds history the > sibling does not. An ordinary deletion does not arrive this way — it > arrives as an S4 tombstone on the feed, a positive statement that the > item is gone. Silence is not that statement. Mapping it to > NOT_VISIBLE puts a history gap on the one value that may be silent.
What it missed is an oracle. NOT_VISIBLE and AHEAD are distinguishable, so a consumer that can name an id learns which ids exist: AHEAD for one nobody holds, NOT_VISIBLE for one held privately. Suite gate 3 requires S2's answer to be byte-identical across private, granted, guest-only, link-shared-but-not-team and deleted, precisely so ids cannot be enumerated — and this rule would have opened, on the last call, the enumeration §4.2's indistinguishability and S3's "404, never 403" were both built to deny. A value cut to close a silent failure would have opened an existence leak instead.
And caller discipline does not save it. memoria only ever asks S2 about ids it received on S1, so memoria can name no id it was not told about — but the S2 row is a contract siblings implement, answerable to any consumer holding a token, and §3.2's leak list already contemplates a compromised one. A rule that is safe only while every caller behaves is not a property of the answer.
So the cost is carried rather than avoided, and it is stated here. A sibling whose restore lost a row entirely is now indistinguishable from one enforcing a denial, and memoria cannot tell them apart from S2 alone. What settles it is the S4 tombstone and the re-announce — reconciliation after the fact, not an answer at the point of the question. AHEAD survives for the shape that does not leak: a revision beyond the log, for an item the sibling still admits, which is the partial-restore case and the one no feed signal can see.
memoria never compares two revisions, and this is what makes STALE and AHEAD well-defined. A revision is an opaque token issued by the sibling, like S1's cursor. Nothing in this design requires revisions to be numeric, ordered or monotonic, and memoria tests them only for equality. STALE and AHEAD are the sibling's verdict on its own log — §6.1's S2 row already says the sibling must compare the asked-for revision against its log rather than against its current row — and a sibling whose revisions are content hashes or ULIDs answers them just as well as one that counts. Without this rule, a restored sibling holding "5" against memoria's "7" invites memoria to prefer the larger number, which is exactly the wrong answer.
Post-restore reconciliation, and the phantom chunks
Draft 4 stopped at "withhold and re-announce", and the review found what that leaves running. Between a sibling's restore and a completed re-announce, every chunk memoria holds from that sibling at a lost revision answers AHEAD. Each is withheld loudly — but each was ranked first, so it consumed a slot in top-k. A search matching fifty chunks, twenty of them from the restored sibling, rendered thirty results and a notice about twenty. In the bad case — the sibling that supplies most of the corpus — the page renders empty or nearly so, and the notice is the only thing standing between the reader and "no results". That is the plausible empty list, arriving through the door AHEAD was cut to close. Four rules close it.
- A source marked for re-announce leaves the ranking until the re-announce completes. Its chunks are not scored, not fused and not counted against top-k, so the other sources fill the page to its full depth. This costs nothing that was not already lost: every one of those chunks would have been withheld anyway. The page names the excluded source and says it is reconciling — one line, not one line per withheld passage.
- A re-announce is a complete statement, not a stream of hints. It opens with a marker, lists every item the sibling currently holds as team-visible, and closes with a marker carrying the item count. An announce that does not reach its closing marker, or whose count disagrees with what memoria received, has failed and authorises nothing — this is gate 7's rule about enumeration diffs, and it binds here for the same reason: a truncated announce is no information, not an assertion of absence.
- On a completed re-announce, an item memoria holds and the announce omits is retired. Retirement removes the chunks from the index and tombstones the rows, which makes it a deletion in memoria's own store and therefore subject to §7.4's durable tombstone log. It is not a purge on
AHEAD: nothing is retired on the S2 answer, and nothing is retired by an announce that failed. This is the only thing that ever removes a phantom chunk, which is why the announce has to be able to say "complete" and mean it. - The announce's revision replaces memoria's, in either direction. The sibling's log is the authority on its own items, so an item announced at revision 5 against memoria's 7 becomes revision 5: memoria refetches the body at 5 via S3 and replaces its chunks. There is no numeric comparison and no "keep the newer one" — see the opaque-token rule above. This is decision 8's provenance rule doing its ordinary job: the source moved to a different revision, so the derived rows go.
Gate 27 holds all four, including the case where the announce fails mid-way and nothing is retired.
UNKNOWN is synthesised by the caller, not only returned by the sibling. A sibling that never answers cannot return anything, so the client maps every non-answer to UNKNOWN: timeout, non-2xx, malformed body, an item missing from the response, a duplicate item, a short response to a 256-item request, and any result value the client does not recognise. The decoder is total — every requested pair gets exactly one result or the whole call is UNKNOWN. Without that rule a missing entry falls through to omission, which is the silent path again. Gate 6.
S2 subsumes per-item membership, and nothing else. Draft 3 said it "subsumes membership" without the qualifier and draft 4 put it back: there is no membership synchronisation and no membership table, because a non-member's items all come back NOT_VISIBLE from the app that knows, and §1.2's four unreconciled tables are simply not consulted.
But "is P a member of this team?" is a real and separate question, and since suite ruling 4 it is the home's roster that answers it, not S7 and not a sibling (§4.3). It is asked once at the door, about the team rather than about an item. The two must not be run together: authorization is per asker, per query, never cached at any scope; admission is a pulled copy refreshed by epoch. Collapsing them is what S7's per-session cache did, and separating them is the whole point of the roster split.
Revision matters. A document that stays team-visible but whose newer revision removed a sentence must not have its stale chunk spoken: a revision behind the sibling's current one answers STALE, and memoria refetches rather than omitting.
*A source can be behind the revision we hold, and that is not STALE. STALE means the sibling has moved past the revision memoria asked about — an ingestion delay, and memoria refetches. The opposite happens too: a sibling restored from backup can sit behind it, so the app of record does not recognise the revision at all. That answers AHEAD, and withholds loudly while the source re-announces.* Never VISIBLE, which would authorise a revision nobody can vouch for; never NOT_VISIBLE, the silent value and the mistake STALE was added to fix; and not UNKNOWN either, which reports that nobody answered when the app of record answered precisely about its own log. Three wrong homes, which is why the values are five and not four.
This paragraph said UNKNOWN until 2026-09-06, citing suite §4.3 and GPT-6's finding 11 — correctly, for revision 7. Revision 10 replaces that ruling with AHEAD, so the citation stood while the thing it cited had moved. Until the module ships the fifth constant the CODE maps this case to UNKNOWN; the spec no longer pretends that is the design.
Everything below AHEAD in this section is specified against the five-value design and cannot be built until the module ships it — gate 6, §8.1's third breaker, §5.2's step 2 and §7.4's post-restore reconciliation all name a value the pinned suite.Authorization does not carry. That is one dependency with one date, not five open questions.
Batch cap: 256 pairs per call, multiple calls permitted. Draft 1's 64 was tied to answer top-k and did not survive paginated search or derived-artefact expansion (§5.2). The number's reason: one page of search results (50) plus the provenance fan-out of the derived artefacts on it — measured before the constant is committed.
4.2 memoria-owned content: memoria is the app of record
Notion, Drive, forwarded mail and uploads have no sibling to ask. For this content memoria is the authority — not a copy, so there is nothing to drift from.
What that authority consists of, precisely, because draft 3 said "memoria's own members and grants" and memoria has neither. There is no members table (decision 11, §2) and there are no per-person grants: holding grants would be an authorization model, which §4.3 says memoria does not have. The test is two facts and nothing else:
- the asker is admitted — §4.3's answer, which since suite ruling 4 is the home's roster in suite shape and the owners list in lone shape. A statement about the team, not about this document; and
- the document is present in memoria and not deleted.
So memoria-owned content is team-wide by construction. Anything a connector or an upload puts in is offered to everyone the siblings call a member of this team, and the only revocation is deletion. That is a strong thing to say of a Drive folder, which is why §3.6's README says it in those words, why §3.5 makes least-privilege connector credentials a requirement rather than a nicety, and why §4.3's predicate has to mean member rather than guest: fact 1 is the whole of the door.
This is the honest resolution of gap 3, and it costs something that must be said plainly rather than implied: revoking a Notion page from the integration does not revoke it in memoria. The content was published into memoria and memoria now owns it; removing it is an act performed in memoria. §3.6's claim does not pretend otherwise.
Amended 2026-09-05 by the Notion ingest design (§3 of 2026-09-05-notion-ingest-design.md). This paragraph used to end "§5.1's enumeration diff is a convenience that eventually notices upstream deletions, not an authorization mechanism". It no longer does, because the first connector built under it does not delete on absence at all. The reason is that memoria is being adopted as a replacement for Notion, not a mirror of it: under a replacement, archiving a page upstream after migrating it is the intended workflow, and a diff-and-delete connector reads that as "forget it" — deleting from the app of record the document the migration existed to preserve. A convenience that eventually notices deletions is the wrong convenience when deletion upstream is the goal.
The cost is real and is carried rather than buried: a page deleted upstream because it should not have been shared stays indexed and readable by every admitted person, with no notice. Connectors therefore record last_seen_at per item on every enumeration, so "no longer in the source" is answerable later without a migration. Keeping the documents is a decision; being unable to list them is not licensed by it.
4.3 Admission comes from the roster
Optional Home sign-in for an existing local account (2026-09-10)
Home authentication can be enabled while admission still uses the existing MEMORIA_OWNERS list. This is an authentication adapter for lone shape, not roster activation. An opaque Home subject gains no membership or content access until the person independently proves their existing local account and confirms its connection to Home. Shared home/connect handles fresh proofs, browser binding and assertion replay protection.
Memoria has no numeric people table. The adapter therefore allocates a stable local identity ID only at the explicit linking entry, after a fresh verified magic-link or Keymail session. Its address is encrypted with a distinct, row-bound purpose; a keyed fingerprint provides the lookup. Ordinary assertion handling and account lookups never allocate identities. These bindings are per-person records in addition to the existing encrypted breaker tallies. They contain no content, membership or grants. Current admission is rechecked at each lookup and protected request; removing an address from configured owners also refuses existing Home sessions.
Home sessions carry the opaque Home subject and a distinct method. The adapter resolves that subject through the confirmed binding to the original verified local address. Existing owner checks and S2 SubjectAddress questions use that address; nothing rewrites documents, provenance, source coordinates or custody. Disabling Home refuses remaining Home sessions instead of interpreting their subjects as email addresses.
The optional configuration is MEMORIA_HOME_ISSUER, MEMORIA_HOME_CONSUMER_ID, MEMORIA_HOME_KEYS_BASE64 and MEMORIA_HOME_DEK_BASE64. Partial configuration refuses startup. The last value is a dedicated exchange key, separate from the content DEK.
The additional surface is GET /signin/local, GET /signed-out, GET /auth/start, POST /auth/assertion, GET /auth/finish, GET /auth/local/complete, and GET/POST pairs at /account/home and /account/home/confirm. Only the assertion callback accepts an exact configured cross-origin issuer; all other mutations retain CSRF protection. The normal /signin starts Home handoff when enabled. Local email verification remains available for first-time connection, including explicit force=1 fallback. Sign-out revokes the app session, then (home/connect 01166fc, shared chrome §9.5) hands the same POST on to {issuer}/signout with a 307, which ends the Oficina session too and returns the browser to /signed-out. HOME_DIRECTORY_ORIGIN (optional, validated with the rest) names the hub directory, where the app switcher's "All apps" goes.
Admission is the home's roster — suite §5.3, ruling 4. memoria holds no members table of its own and asks no sibling. In suite shape it pulls the roster (suite call 8, consumer token, roster epoch in every response) and admits from it. The epoch rides the home's own responses — calls 2, 5 and 8 and the assertion — and sibling calls carry none, deliberately (suite §5.3), so nothing in S1, S2 or S3 grows a membership field. In lone shape, where there is no home, admission is the owners list, exactly as slice 0 does today.
Draft 2 had memoria keep its own members table, and the draft-2 review showed why that fails: S2 protected sibling content, but admission and §4.2's memoria-owned content both used memoria's own list, so a person removed everywhere else could still sign in and read the team's Notion pages, Drive files, forwarded mail and uploads. That failure is real and the roster fixes it. What draft 3 got wrong was the replacement.
What this replaces, and why it was broken. Decision 11 admitted a person if at least one sibling answered MEMBER to a call S7. Two things were wrong with it. The small one: it cached membership for the life of a session, and ruling 3 now says authorization is never cached at any scope — the word "session" in the doctrine's first draft came from this rule, and folding admission into authorization is precisely the conflation the roster split exists to remove. Custody and admission are person-less or roster-shaped; authorization is per asker, per query, never cached. They are three different things and S7 blurred two of them.
The large one: MEMBER has no value for a guest. A guest is not on any roster, so "at least one sibling says MEMBER" is undefined for exactly the person the rule most needed to exclude — and memoria's own §13 carried that as an open question it could not answer from inside decision 11. Under ruling 4 it answers itself: guests are never on the roster. S7 is retired.
Three consequences worth keeping:
- No roster, no admission. Fail closed: an unreachable home means nobody new signs in, which is an outage, where the alternative is admitting someone nobody can vouch for. In lone shape the owners list is local and this cannot arise.
- A stale roster stops team standing for everybody, sessions included. Within the TTL the pulled copy is enforced as it stands. Past the TTL with no successful fetch it is stale, and team standing stops being established for anyone — including people already in a session — so S2 answers
UNKNOWNand the page withholds loudly. An unfamiliar roster generation holds the stale copy until an owner confirms it. Suite §5.3, gate 5. This spec said the opposite until 2026-09-05: "a session already minted survives an outage for its TTL". That is the doctrine's revision-3 position, which it retracted — on a 30-day sliding session it is an outage-length revocation tail, the very thing the paragraph claimed to reject, and it is the same tail §13 had been carrying as an open defect of S7. - memoria still holds no authorization model. It holds no answer of its own; it reads a roster and relays it, and §4.1 governs every passage after the door.
Draft 4's hardest finding survives the call it was about, and it is why the roster is the right answer rather than merely the doctrine's. It found that MEMBER had to mean member of this team and not known to this app, because Sheets §8.2 and Docs §8.2 both describe sharing as membership — so a person granted a single sheet is a member there, in those specs' own words. Under the old rule that person was admitted to memoria, and §4.2 then handed them every Notion page, Drive file, forwarded mail and upload the team holds. One shared sheet bought the whole corpus.
Asking a sibling could never cleanly separate the two questions, because the sibling only knows its own. A roster held by the home answers member of this team and nothing else, which is the property draft 4 was reaching for and could not get from S7.
Slice 0 has no home and no siblings, so admission is the owners list and nothing else, and the spec should say so rather than leave a reader to infer a table. Until a home exists, the only people admitted are the platform-configured owners the app refuses to start without. That list admits operators, it is not a roster, and it does not grow: when a home exists, everyone who is not an owner comes from the pulled roster (suite call 8). Extending the owners list one person at a time would be the members table decision 11 rejected, arriving under another name.
This paragraph said "slice 0 has no S7" and "everyone who is not an owner comes from S7" until 2026-09-06 — inside the section that retires S7, reintroduced by the merge from a branch written before ruling 4. §1.10's rule is to grep the retired thing by name; this is what it catches.
No guests, no anonymous link viewers — and after this section, no guest of a sibling either. Admission is not authorization: an admitted person still sees only what §4.1 and §4.2 allow.
The suite attribution page (GET /help/open-source, 2026-09-11) used this same session gate until the shared chrome (estilo spec/shared-chrome §8.4, 2026-09-30), which has the sign-in page link to it while signed out. It is now public: it serves embedded software notices and nothing about the team — no content, no passage counts, no document names. Signed in, it still shows the viewer's own bar and account menu; signed out, the bar's Sign in. It creates no session. Its stylesheet is a public static asset, like the other stylesheets.
---
5. Ingestion
Ingestion is an authorisation event, never a sweep.
From a sibling (§6): the change feed offers items the sibling marks team-visible. That mark is the custody gate of decision 3 — it bounds what memoria holds. It is not the authorization gate; §4.1 is.
What "team-visible" means is suite ruling 15's, and it is narrower than it sounds. An item sits on one of three rungs — private, granted to named people, or team — and only the team rung is team-visible. So custody keys on the rung and never on a read-only link: a link capability makes an item readable by whoever holds the URL, which is not the same as the team being able to find it. Treating a link as team-visible would turn a capability held by whoever was sent it into a row every team member reaches by search — which is the specific mistake ruling 15 rejects, and it would arrive here, at ingest, where memoria decides what to hold.
Two consequences memoria must not paper over. A granted item is outside custody even when the asker holds the grant, which is why §4.1's intersection sits at the sibling and why a person can lose a passage here while the document stays open to them at the source. And an item that moves from team to granted leaves custody: it arrives as gone in S1, indistinguishable from deletion by §4.2's design, and memoria deletes rather than downgrades.
From outside the suite: an admin configures a connector, and the sharing act at the far end is the publish.
5.1 What a connector must supply
A stable external id, a change feed (or an enumeration cheap enough to diff), a mapping onto memoria's custody rule, an extractor, and a delete signal.
| Source | Id | Feed | Custody maps | Extract | Delete | Verdict | |---|---|---|---|---|---|---| | Oficina siblings | ✅ | ✅ | ✅ team-visible mark | ✅ | ✅ tombstone | Full; §4.1 governs reads | | Google Drive | ✅ | ✅ changes API | ❌ per-file | ✅ | ✅ | Publish-gated; §4.2 governs | | Notion | ✅ | ✅ traversal from shared roots | ❌ | ✅ | ❌ none, and none used | Publish-gated; §4.2 | | Email | ⚠️ Message-ID | ✅ UID / history id | ❌ fundamentally | ✅ | ⚠️ Gmail only | Forward-only; §4.2 | | Files | content hash | ❌ | ❌ | ✅ | ❌ | Upload only; §4.2 |
Only the siblings supply all five, and only they get §4.1's live re-check. Everything else is memoria-owned by §4.2.
Email cannot be swept. A thread's audience is the three people on it; the team is twenty. Mail enters by forward-or-label only.
An enumeration diff must distinguish an authoritative empty result from a failure before deleting anything. A timed-out, rate-limited, partially-paginated or credential-expired enumeration returns no information, not no documents; treating the second as the first purges the corpus silently. Gate 7.
Amended 2026-09-05, two changes from building the Notion connector.
Notion's feed. This table said ⚠️ last_edited_time polling, which assumed /v1/search could enumerate a workspace. Notion documents that it cannot: search "is not guaranteed to return everything", and no field in the response says the result was partial. An enumerator built on it reports a complete import while missing pages — this design's worst failure shape, reachable with no error anywhere. Connectors enumerate by traversal from the roots the integration was explicitly shared with (search is documented as complete for those alone), which is cursor-verifiable and yields ancestry as a side effect.
Notion's delete. Now "none, and none used" — see the amendment in §4.2. Gate 7's rule above stands unchanged for connectors that do delete; Notion's connector satisfies it trivially by having no path that deletes, which is a stronger position than a completeness predicate that has to be right every time.
5.2 Deletion, and the hard case
Chunks and vectors are rows under memoria's DEK; they go on the tombstone or the next diff. §7.4 covers the snapshot ordering that makes a deletion survive a restore.
The hard case is derived content, because a fact outlives its source.
*Ruled (decision 8): every derived artefact carries provenance as (source_id, revision) pairs, and is deleted or regenerated when any of them stops resolving or moves to a new revision.*
And it records the model that produced it, beside that provenance (decision 4, widened 2026-09-05). Not for attribution's sake: an artefact is regenerated when a source moves, so the model that made the row a reader is looking at is often not the one that made it first. Without the producing model stored alongside, a regenerated artefact silently inherits the name of its predecessor — a naming requirement that reports the wrong name is worse than none, because it is believed. Draft 1's rule was id-level, which left "the Q3 target is X" standing after the source changed to Y — provenance that resolves and is wrong.
A derived artefact expands to its full provenance before §4.1 runs, and every source must pass, under whichever authority owns it. One failure withholds the whole artefact. Without this a summary is a laundering channel for a single downgraded input.
Draft 2 said this and specified it only for siblings, which the draft-2 review caught: S2 exists nowhere else, so an external source had no check at all and a mixed artefact had no rule for combining two authorities. The full algorithm:
- Partition the provenance by owner — sibling
(id, revision)pairs on one side, memoria-owned(id, revision)pairs on the other. There is no third class, and a derived artefact's id is not a provenance entry; that is what the flattening rule below is for.
Both sides carry a revision, and draft 4's did not. Draft 4 wrote "memoria-owned root ids", which reads as a small simplification and is decision 8 half-applied: the decision is "provenance or deletion, at (id, revision) granularity … or whose source changed", and an id alone cannot say that a source changed. A summary built from revision 1 of an uploaded contract was served unchanged after someone uploaded revision 2, citing the upload correctly and describing the wrong document — the id-level rule decision 8 was written to replace, surviving on the half of the corpus nobody had checked. So a memoria-owned document carries a revision, bumped on every re-ingest, re-upload and connector update, and its roots record it.
- Sibling pairs go to S2, batched, cap 256 per call, as many calls as the provenance needs.
VISIBLEfor every pair or the artefact is withheld.STALEon any pair means the artefact is out of date, not forbidden: regenerate it, do not speak it.AHEADon any pair means a source has been restored behind memoria (§4.1): withhold, re-announce, delete nothing. - memoria-owned pairs are checked against §4.2's two facts, plus the revision — the asker is admitted, the document is present and not deleted, and the recorded revision is the document's current one. A superseded revision is not a denial and is not a permissions failure: it is decision 8's "the source changed", so the artefact is regenerated, not spoken — the same treatment
STALEgets on the sibling side in step 2, for the same reason. The two facts are the whole authorization test, because for this content memoria is the authority rather than a copy and holds no grants of its own to consult; the revision check is a currency test sitting beside it, and collapsing the two is what let draft 4 drop it. Note what that makes load-bearing: the only door in front of every memoria-owned source is §4.3's admission, which is why §4.3's predicate has to mean member and not guest, and why its cache has a number. - A mixed artefact needs both to pass. There is no precedence rule and no fallback: authorities are ANDed, because an artefact is a claim about all its sources at once.
- Provenance that cannot be partitioned — a source whose owner is unrecorded — withholds the artefact and is logged as a defect. A derived artefact that cannot say where it came from is decision 8's unresolvable-provenance case and gets deleted, not served.
Provenance is flattened to roots at generation time, because it is not otherwise transitive. Draft 3's algorithm partitioned by owner into two classes, and a derived artefact is memoria-owned — so an artefact built from another artefact landed in step 3, whose entire test is presence in memoria. The first artefact's sibling sources never reached S2. Two hops launder exactly what one hop is written to stop: summarise a Sheets document, then summarise the summary, and the second summary is speakable to a person Sheets would refuse. Nothing in draft 3 forbade the second hop, and nothing would have noticed it.
So an artefact carries two lists, and only one of them authorizes:
roots— the(id, revision)pairs, sibling and memoria-owned alike, that the artefact ultimately rests on, resolved at generation time by substituting each contributing artefact's ownrootsfor its id. This is the list §4.1 runs against, and by construction it holds no artefact ids, so step 1's partition is total for the same reason §4.1's decoder is.built_from— the ids of the artefacts this one was generated from. It exists so that regenerating or deleting an intermediate propagates downstream, and it is never consulted for authorization. Keeping the two apart is the point: one list answers "may this person see it", the other answers "is it still current", and draft 3 had a single list trying to be both.
Three rules fall out, and each is an assertion rather than an intention:
- An artefact id appearing in
rootsis a defect, not a case to handle. It means flattening did not run, and it withholds the artefact under step 5. - An artefact whose sources cannot all be flattened is not generated. If a contributing artefact's own
rootsdo not fully resolve there is nothing to inherit, and generating anyway produces an artefact whose provenance is a guess wearing a list's clothes. - A cycle in
built_fromwithholds every artefact on it and is logged as a defect. Nothing in this design creates one, which is precisely why nothing would catch it.
Flattening is also what makes §4.1's 256-pair cap load-bearing rather than decorative: an artefact three hops deep fans out to every root of every ancestor, and that fan-out is the number §4.1 says to measure before committing the constant.
Where derived artefacts live: memoria's own store, sealed like any chunk, indexed and searchable — and therefore subject to §4.1 through their provenance, never on their own account. They exist only with AI on, and their regeneration is a model call whose cost §8.6 counts.
memoria never fine-tunes, because weights are the one copy that cannot be forgotten.
5.3 What "continuously evolving memory" honestly means
Re-index on change — a search index, honestly named. Derived notes regenerated when sources change — a materialised view with provenance. Updated weights — never.
The smallest honest version is the first two. It is a cache over sources. For sibling content it forgets when the sources do, because §4.1 asks them. For memoria-owned content it forgets when someone tells it to (§4.2); draft 1's claim that it "forgets exactly when the sources do" was false for that half.
---
6. The sibling contract
Decision 1 makes cross-app pull legal. Nothing yet makes it possible: every home contract call is instance→home or person→home, home §6 rejected a re-announce sweep because it "has no channel to reach instances", and aud binds every assertion to one host.
6.1 The calls
All are memoria → sibling, all pulls. Push is rejected on three grounds: a sibling outbox drains on wake and share mutation, not on a ticker (Sheets §9.1.1), adequate for a hint and useless for content; push would wake a hibernating memoria on every keystroke in three apps; and it inverts custody, since each sibling would hold a memoria token and memoria would grow an inbound surface it does not have.
memoria pulls on its own wake and on each question. It runs no timers.
| # | Call | Notes | |---|---|---| | 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. | | 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. | | 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). | | 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. | | ~~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. | | ~~S5~~ | Consumer expiry | no call, and no number. TTL expiry is the decommission verb. | | 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. | | ~~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. |
Draft 1's S1 row carried a visible-to-team field, and that was a leak. With it, a visible=false, gone=false row tells memoria that a guest-only item exists, at what revision, and when it was touched — exactly what S3's "404s, never 403s" was designed to hide, and unnamed in the leak list. Removed: departure from team-visibility is simply gone.
"A stale index costs a miss, never a leak" — narrowed twice. Draft 1 stated it unconditionally and it was false for retraction-by-edit. Draft 2 fixed that with a revision on S2 but mapped the failure to NOT_VISIBLE, which is silently omitted — so it cost a silent miss, which is the failure it was supposed to prevent. It holds now only because S2 carries the revision and answers STALE distinctly. All three clauses travel together or the sentence is false.
S3 takes no person, and the reasoning went wrong twice before landing here.
Draft 2's S3 took (id, revision) and claimed the sibling "re-runs its grants join at fetch time". The draft-2 review correctly called that impossible: no asker, no grants join.
Draft 3 fixed it by adding person_ref. That was the wrong fix, and a review of the built code found two consequences. There is no person on the ingest path — memoria pulls on its own wake, and the warm-up, the rebuild and derived-artefact regeneration have no asker either — so the call that fills the corpus could not be made. And a per-person S3 succeeds for individually-granted rows, because Sheets §4.5's join is "rows this team can see, plus rows granted to me individually" — which would pull into memoria's store exactly the content §6.4 forbids and §3.3 claims it cannot hold.
The error was collapsing two rules decision 3 keeps apart. Custody bounds what memoria may hold: team-visible only, no person involved, and that is S3's predicate. Authorization decides what a person may read: per asker, per query, and that is S2's, at render time. Draft 2's S3 had the right signature and a false comment; the fix was to delete the comment, not to add a person to the call.
So: S3 is custody-scoped and person-less. S2 is the only per-person gate, and nothing is spoken without it.
The definitions above are no longer memoria's to state. Suite ruling 7: one contract, one Go module, fixtures as the interface — every app imports amadan.net/oficina/suite, serves its half, and passes the conformance suite in CI. An app spec cites the doctrine and never restates it, because each spec restating the contract in its own words is exactly what drifted. So S1, S2, S3 and S6 are the module's, and what survives here is memoria's own reasoning about them: why S3 carries no person, why the four S2 values are four, what a slow sibling costs. The wire shapes are the module's and the fixtures are the interface.
S6 is cited as a specification, not as a fixture, and that is deliberate. The module on main is S1, S2 and S3; there is no S6 fixture to conform to yet. Suite §12 step 4 builds it — after memoria's slice 1 and before slice 2 — so §8.7's numeric path cites a call that is scheduled rather than one that exists, and picks up the fixture when it lands. Nothing else waits on it: slice 1 needs S1, S2, S3 and §4.1 only.
Suite revision 5 left S6 in the contract table with no step at all, which let a numbered call read as a finished one; revision 6 gave it a step and put it before slice 1, implying a dependency that does not exist; revision 7 placed it at step 4 and said so in the step. Recorded because "cite S6" meant three different things in one evening.
6.2 Authentication, and rotation
Per-consumer bearer tokens, minted by the operator drain, delivered as platform config at provisioning. Home §9 already works this way. The drain mints memoria's tokens and writes each sibling's consumers row. The home never sees a sibling token.
Three alternatives rejected: the home issues the token (breaks home §7's "the home holds no provisioning credential", and breaches home §2.2 the moment it decides which origin gets one); a team-scoped shared secret (cannot rotate per consumer); mutual TLS (TLS terminates at Carloku's router under a wildcard certificate per app zone, home §4, so the app never sees a client certificate).
Rotation is a three-phase handover, and draft 1 specified none. A single-token row cannot rotate without a window in which one side is wrong: update the sibling first and the old token breaks; update memoria first and it sends one the sibling will not accept. So a consumers row holds up to two hashed tokens, each with an epoch, and the drain:
- Expands acceptance — writes the new token alongside the old at every sibling. Both accepted.
- Switches the sender — delivers the new token to memoria as platform config.
- Contracts acceptance — retires the old token at every sibling, only after memoria is observed using the new one.
Each phase is idempotent and re-runnable, so a partial delivery across three siblings is recovered by repeating the phase. Emergency revocation skips to phase 3 and accepts the outage.
The mutable (cursor, TTL, epoch) state is created lazily by the sibling on first contact. Platform config delivers the token, not the row: home §6 is explicit that no channel exists to push one.
6.3 What stops team X reaching team Y
Team identity is the hostname label, per home ruling 16: <team>.<app>.oficina.works.
Two barriers, not the three draft 1 claimed. Both reviewers caught this and they were right:
- The sibling's
consumersrow — a credential and an origin allowlist, but one row read two ways. A bearer token is the origin claim; nothing verifies origin independently. One drain mis-write defeats both. Counting them separately overstated the defence. - memoria's client refuses any host whose
<team>label differs from its own — genuinely independent, test-enforced against a fixture of hostile hostnames, and it must refuse to follow redirects, which draft 1's fixture would not have exercised.
Barrier 2 is not a boundary against the §3.3 threat model. An attacker with code execution on memoria bypasses memoria's own client trivially. It is a real guard against accidental cross-team dialling and against a mis-provisioned host, and that is what it is claimed as.
Same-team membership is provenance, not a runtime query: the drain provisioned the hosts together. The home is not on this path at all, which is how home §2.2 survives decision 1.
6.4 What decision 1 does not license
Any hop across a <team> label. Any instance presenting an assertion. Any write except suite §7: a person's own hand-off, and S8 as memoria itself into its designated area. Any sibling item outside the team-visible custody rule. The home in the loop. Content leaving the four hosts, except §8.3's named provider, which is a disclosed exception and not a sibling.
The write exception is narrow and it is suite ruling 9's: a write names its real actor, and no sibling ever acts as a person. Two forms and no third — the hand-off (suite §7.1) and S8 (suite §7.2) — and the wire shapes are the doctrine's, cited here and not restated. An earlier version of this paragraph described the hand-off as "memoria renders a form that POSTs to the receiving app's create endpoint; memoria is not in the write at all, it only drew the button", which is the drifted restatement ruling 7 exists to prevent, and it drifted in the direction that made memoria's own obligations disappear.
memoria's own obligations, which are the part that belongs here:
- The destination is validated against memoria's own
<team>label before the form is rendered. memoria is in the write to that extent — it chooses where the button points. - A
form-actionCSP allowance for the receiving origin, in memoria's rastrillo config. Gate 4's log rule runs on memoria's side of this, and the receiving app's create endpoint commits on receipt only after its own confirmation step (suite §7.1: "a create endpoint that commits on receipt is a drive-by"), which is the receiver's obligation and not ours. - On-behalf-of writes are refused for good, because a sibling token that can write as a person is a write capability over every member's identity.
This spec previously forbade all writes, which was simpler and also forbade a person clicking "save this to Docs" with their own hands.
6.5 Availability: the deadline, and what a slow sibling costs
Draft 2 had none of this, and the draft-2 review was right that it is the finding most likely to change the product rather than the spec. Every search now calls every contributing sibling, any of which may be cold, and "withhold loudly" said what to do on failure without saying when failure is declared.
- Deadline: 800 ms per sibling, all siblings called concurrently. One page of results is one round of calls, so the page's floor is the slowest sibling, not their sum. The number is a starting assumption, not a measurement — §13 carries the obligation to measure it before slice 1 ships.
- A sibling past its deadline is
UNKNOWN, which withholds its passages loudly and renders the rest. Partial pages are the normal case, not an error path, and the page names what is missing and from which app. - A cold sibling is a wake, and a wake is slower than a deadline. The honest consequence: the first search after a quiet period reliably withholds. So memoria polls S1 at every configured sibling on its own wake, before anyone has searched, to spend that latency where nobody is waiting. In suite shape it pulls the roster (suite call 8) on the same wake, for the same reason and on the same grounds: call 8 carries a consumer token and no asker.
- Draft 3 said "fires S7 and a warm-up S2 on its own wake", and that call cannot be made. Both take a person ref and there is nobody there on a wake — the same error as draft 2's per-person S3 (§6.1), repeated three sections after the correction for it was written. S1 is the call that already has no asker, it is the call memoria makes on wake anyway, and warming a sibling is warming a sibling whichever endpoint does it. A warm-up naming a person is a warm-up no fixture could ever have received, which is how it survived a draft. The suite branch then wrote "pulls the roster and fires a warm-up S2", which is the same mistake with one of its two halves fixed — the roster pull is asker-less and fine, the S2 still is not. Corrected on merge, 2026-09-06.
- A reliably slow sibling produces permanent withholding, which is worse than it sounds: the product's primary path degrades to "Sheets did not answer" forever, and a reader learns to ignore the notice. There is no design that fixes this while failing closed. The stated position is that this is a suite health problem surfaced by memoria, not a memoria problem to route around — and if it happens, the answer is to fix or drop the source, never to cache its authorization. (§4.3's S7 cache is not an exception to that sentence and §4.3 argues why: this clause is about a sibling's answer on a passage, which is never cached and never will be.)
Each of these is a number a gate can assert, and draft 3 asserted none of them. Gate 23 holds the deadline and the concurrency together, because either alone passes on the other's failure: a sibling sleeping past the deadline must not add its latency to the page, and three siblings called at once must not cost three times one sibling's time. Gate 24 holds the warm-up: every configured sibling receives its S1 poll unprompted, within a stated few seconds of the app coming up, with no query issued at all.
This read "before the query handler serves its first request" until 2026-09-07, which is the ordering §10's own gate 24 works through and rejects as unwritable either way — asynchronous it is a race between goroutines, synchronous it puts three sibling wakes in front of the first reader and undoes the warm-up's entire purpose. §10 had the corrected form; this sentence did not, and the code cites §10. Found by a review of the branch that built it.
---
7. The index
The design Docs §15 item 2 recorded as missing: "An FTS5 index is plaintext in SQLite and would fail our own gate; decrypting the corpus per query is O(corpus)."
Hybrid BM25 + dense vectors, fused by reciprocal rank, both sealed at rest and resident in RAM while awake.
7.1 FTS5 is available cgo-free
Verified independently by both reviewers, each building a static binary against the suite's pinned modernc.org/sqlite v1.55.0: CGO_ENABLED=0 created fts5(body, tokenize='porter unicode61'), ran bm25(), reported SQLite 3.53.3, and pragma_compile_options contained ENABLE_FTS5.
Per §3.1 its postings are plaintext, so the FTS table lives on non-replicated scratch or in memory and is snapshotted to the bucket as a sealed blob. Whether CARLOS provides non-replicated scratch is unverified — §13.
7.2 The arithmetic, derived
One corpus assumption drives every number below. Draft 1 used 90k chunks for memory and 60M tokens for cost, which are inconsistent; both reviewers caught it.
> 90,000 chunks at the measured 202 words / 1325 bytes each — about > 24M tokens and 114 MB of text.
Stated in chunks, because documents vary too much to convert. Draft 3 wrote "30,000 documents × 3 chunks"; the benchmark's own corpus averages 11.8 chunks per document, so this ceiling is roughly 7,600 documents of the kind measured, not 30,000. Any figure quoted per-document is wrong by about 4×. The ceiling is a chunk count and only a chunk count.
The token figure follows from the measured chunk (202 words ≈ 270 tokens), not the 400-token target: 24M, not the 36M draft 3 carried into §8.6.
Vectors. 512-dim Matryoshka embeddings, stored int8-quantised.
512 is a client-side truncation, not a requested dimension. The parenthetical this replaced — "OpenAI's -3 models accept a dimensions parameter" — is true of OpenAI and false of the provider §8.3 selects. qwen/qwen3-embedding-8b emits 4096 fixed and fails the request if dimensions is sent. The pipeline therefore keeps the first 512 values and re-normalises to unit L2. That is valid because the model is trained Matryoshka.
Search computes a raw dot product over vectors stored unit-normalised, not a cosine that divides by norms at query time. §7.3's 2 × N × D flop figure already assumed this and the spec never said so. It matters here: cosine is scale-invariant, so against a true cosine a skipped renormalise changes no ranking at all — a gate written over cosine could not fail, which is the shape AGENTS.md says this codebase has already shipped three times. Over a raw dot product the same omission silently reorders results. So the earlier draft of this paragraph, which said "cosine similarity then degrades smoothly", was wrong twice: cosine does not degrade, and the thing that does degrade is the form the spec had not committed to.
Gate 28 is therefore deterministic and cannot be argued past — every stored vector has L2 norm 1 ± ε; mutation: skip the renormalise, expect red — and gate 29 carries the measurement it cannot do.
It costs ingest bandwidth, not storage. Truncation happens before storing, so the table below is unchanged — 90k chunks × 4096 f32 is ~1.4 GB decoded to store 46 MB, a ratio of about 32×, not the 8× you get by mis-reading the kept width as the f32@512 intermediate (184 MB is what truncation yields; int8 quantisation is what is stored).
On the wire it is several GB, and base64 is not the way out. Tested 2026-09-05: encoding_format: "base64" is accepted and silently ignored — the response returns JSON floats, matching the request that omits the parameter. Not an error, just dropped. So the ~2 GB base64 figure an earlier draft hedged with is unavailable. The replacement figure is derived, not measured, and the range is wide. At ~11 bytes a float it is ~4 GB across 90k chunks; servers of this family often emit 17-significant-digit reprs at ~20 bytes, which is ~7 GB. A probe response was in hand and nobody counted its bytes — measure bytes-per- vector once and multiply, rather than guessing at a serialiser's precision. And "no opt-out" overstates it: Content-Encoding: gzip collapses digit strings, Go's client asks for it by default, and whether TensorX honours it is one response header on the same probe (§13). Note the behaviour as well as the number: a provider that accepts a parameter and discards it will do so again, which is an argument for asserting on what comes back rather than on what was asked for.
This is ingest-time, not wake-time, so §7.3's ceiling is untouched. It is not free either: neither the first embed's wall-clock nor the batch size the endpoint accepts is written down anywhere, and §13 asks for the re-embed figure while the first embed's is equally absent.
| chunks | int8 @512 | f32 @512 | f32 @768 | |---|---|---|---| | 10k | 5 MB | 20 MB | 31 MB | | 90k | 46 MB | 184 MB | 276 MB | | 250k | 128 MB | 512 MB | 768 MB |
Draft 1's table mixed an int8 design with f32 columns without saying so.
Query latency, derived. A brute-force pass is 2 × N × D flops. On one core of this machine, measured at 3.2 GFLOP/s (pure Go, no SIMD):
| chunks × dims | flops | latency | |---|---|---| | 10k × 512 | 10.2 M | 3 ms | | 90k × 512 | 92 M | 29 ms | | 100k × 768 | 154 M | 48 ms — measured, matches | | 250k × 512 | 256 M | 80 ms | | 1M × 512 | 1.02 G | 320 ms |
FTS index size — remeasured, and draft 2 was wrong by half. Draft 2 reported 104 MB from a synthetic corpus of random letters. The draft-2 review showed that figure could not describe the stated schema, because a default FTS5 table keeps its own copy of the inserted text. Measured properly, on the real corpus, chunked by memoria's own chunker (docs/BENCHMARKS.md carries the machine and command):
| schema | index | bytes/chunk | build | query | |---|---|---|---|---| | fts5(body, …) — default | 200 MB | 2329 | 6.8 s | 1.09 ms | | fts5(body, content='', …) | 49 MB | 574 | 6.2 s | 1.09 ms |
A contentless table is 4× smaller and queries just as fast, but cannot return column values — so snippet() does not work and memoria must build its own highlight from the sealed chunk. That is the trade, recorded as a decision: slice 0 uses the default table because a snippet is free there and the corpus is tiny; the snapshot design uses contentless, because 49 MB against 200 MB decides the wake budget.
A chunk is smaller than draft 2 assumed. Measured mean: 202 words / 1325 bytes, not 400 words / 1.6 KB. The chunker targets 400 words but folds runts and never straddles a heading, and real documents have headings every few paragraphs. Every figure here follows from the measured size.
The chunker is line-based, and three bugs made it so (found reviewing the built code, not the spec). A heading is a heading whether or not a blank line follows it — ## Revenue immediately above its text is the commonest Markdown style, and a paragraph-based splitter filed the whole thing as body with an empty trail, so those documents indexed with no heading at all. A # inside a fenced code block is a shell comment, and was becoming a heading that captured everything after it. And a file with no blank lines — a CSV, a log, hard-wrapped prose — became one chunk of the entire file, whose only citation read "passage 0" for everything. So: fences are tracked, headings close a chunk without needing a blank line, and MaxWords (1000) is a hard ceiling on both a paragraph and a chunk. The measured mean above is from the fixed chunker.
Rebuild vs snapshot. Building the index took 6.8 s at 90k chunks. Loading a sealed snapshot does not. Snapshot, never rebuild — above slice 0, where a rebuild is milliseconds.
7.3 The wake budget, and the real ceiling
Snapshot at 90k chunks, measured:
| design | FTS | vectors | separate text | total | |---|---|---|---|---| | default FTS (text is in the index) | 200 MB | 44 MB | — | 244 MB | | contentless FTS + the text snippets need | 49 MB | 44 MB | 114 MB | 207 MB |
Stated assumption, which draft 1 omitted: bucket read at 50–100 MB/s, AES-GCM at ~1 GB/s. So 150 MB loads in 1.5–3.0 s plus 0.15 s decrypt.
A 5 s budget at the conservative bandwidth admits ~250 MB. Against the measured footprints that is about 90,000–110,000 chunks.
Both earlier drafts got this wrong, in opposite directions, and the correction is worth keeping. Draft 1 said ~100k and called it "the same ceiling the brute-force arithmetic gives, which is tidy" — a coincidence it had manufactured. Draft 2 called that out and derived ~150k instead, from an FTS figure that was wrong by half. So draft 2 moved the number the wrong way while claiming to have fixed the arithmetic, and draft 1's ~100k was closer by accident. Measurement, not rederivation, is what settled it.
Brute-force cosine remains nowhere near binding: §7.2 measures 250k chunks at 80 ms.
The honest conclusion draft 1 missed: the wake budget binds first, and ANN does not help it. An HNSW graph makes the snapshot larger. So the deferred item is not "ANN at 250k" but an on-disk sealed index, or partial load with background fill — §12.
7.4 Snapshots, deletion, and restore
A snapshot is not a cache; it is a durable copy that outlives the deletion it predates. GPT Sol's finding 6, which neither the other reviewer nor draft 1 caught.
- A deletion is not complete until a superseding snapshot is written and the prior snapshot is retired. Snapshots carry a monotonic generation; retirement deletes the blob.
- On wake, sibling content may serve immediately, because §4.1 asks the sibling live and a deleted item answers
NOT_VISIBLE. - memoria-owned content (§4.2) has no such backstop, so its deletions must be applied to the loaded index before it serves. A snapshot records the deletion cursor it is current through; a gap is replayed from the tombstone log at wake, before first query.
- A restore from backup takes the re-announce path and replays memoria-owned tombstones before serving.
- *A sibling's restore is the mirror case, and §4.1 specifies it. memoria then holds revisions the sibling does not, which answer
AHEAD. Reconciliation is: the source leaves the ranking, the re-announce runs, items the completed announce omits are retired*, and announced revisions replace memoria's whichever way the numbers point. Retirement is a memoria-side deletion, so it goes through this section's durable tombstone log like any other; an announce that fails to complete retires nothing. Gate 27. - The tombstone log is durable independently of the database, and this is what draft 2 was missing. Restoring a backup from before a deletion also restores a tombstone log from before it, so replaying that log replays nothing: the deletion is simply gone. So tombstones are written to their own append-only object in the bucket, keyed by a monotonic deletion sequence, retained beyond any restorable backup's age, and replayed from the sequence the restored database stops at rather than from the log it happens to carry.
- A snapshot is not retired while any restorable backup could point at it. Draft 2's retirement rule created the mirror-image bug: a restored database referencing a superseded generation finds the blob deleted and has no index at all. Retirement waits out the backup retention window, and a snapshot referenced by a missing generation triggers a rebuild rather than an empty index — an empty index would serve "nothing matched" over a full corpus.
---
8. Retrieval and the answer
8.1 One gate, both paths
Every path that renders a passage calls §4.1 first — search results, snippets, generated answers, derived artefacts, API responses. There is no passage-only path that skips it, which is the whole of gap 2's fix.
With AI off, the floor: a GET form, ?q=, ranked passages, each carrying app, title, locator, 👨/🤖 marker, date, and a deep link. Scriptless, paginated, rastrillo server shape. Lexical only, since embeddings need a provider — and per §3.2, nothing leaves the instance in this mode.
§8.8's fact box is part of this floor and not an addition above it. It calls no model in its deterministic layer (§8.8, decision 15 as amended), so that layer is present in exactly the mode this paragraph describes; gate 15 covers it with everything else here, and it renders inside the results region rather than beside it.
Loud withholding, naming the shape of the loss:
UNKNOWNfrom a sibling → "Sheets did not answer; 7 results withheld."- A failed S3 after a successful S2 → the passage is listed as unavailable, never dropped.
- A failed or stale S1 → the page states the corpus is current as of a time, rather than implying freshness.
- A circuit breaker, and draft 4's renamed its problem rather than removing it. Draft 3's fired on the routine case — a Docs-only member gets wholesale
NOT_VISIBLEfrom Sheets on every query they will ever run, and a notice that fires for an ordinary person is a notice readers learn to skip. Draft 4 wrote the right sentence, "the predicate has to be a property of the sibling, not of one asker", and then kept a predicate that was a property of whoever happened to query that day: zeroVISIBLEin a 24-hour window. Two ordinary events satisfy it.
A 24-hour Sheets outage answers UNKNOWN to everything, which is zero VISIBLE, which trips a breaker whose whole subject is refusal — an availability problem reported as a permissions anomaly, on the day the operator most needs to read the notice correctly. And on a team where one person uses Sheets and three do not, a single day when that person is away leaves three askers, four pages of queries, zero VISIBLE and a VISIBLE last week. The routine case with a day off in it.
So the predicate counts refusals, not the absence of grants, and the signal is a per-asker transition. Per sibling, over a rolling 24-hour window:
- the baseline set is every asker who received at least one VISIBLE from this sibling in the 7 days ending where the window begins — [start − 7d, start), disjoint from the window, so the window can never be its own baseline; - the breaker fires when at least 3 askers in the baseline set each asked this sibling at least one pair inside the window and received zero VISIBLE, and the window contains at least one NOT_VISIBLE from this sibling.
Each clause earns its place. Counting NOT_VISIBLE rather than "no VISIBLE" is what separates a sibling that is refusing from one that is down: an outage produces UNKNOWNs and no NOT_VISIBLE, so it does not trip this breaker at all — §6.5's partial-page notice is what reports it. The per-asker transition is what makes the predicate a property of the sibling: a Docs-only member is never in the baseline set, so no number of them can trip it, whoever else did or did not query today. And requiring each counted asker to have asked inside the window is what stops absence being read as refusal.
The 200-pair clause is dropped, and its removal is the point. One page of results is 50 pairs, so four queries met it; it did no work, and a floor that everything clears is a clause that reads as rigour. What replaces it is the requirement that three baseline askers each actually queried the sibling in the window, which is a floor written against the thing being measured rather than against the traffic.
Where the windows live, and what that costs. memoria hibernates (§3.5), so an in-memory window resets on every wake and a breaker that can never accumulate seven days of baseline is a breaker that is off — reading, on the owner's page, as "no anomaly". So the counters are durable: per (sibling, asker, UTC day), two integers — VISIBLE and NOT_VISIBLE — sealed under their own HKDF purpose, retained 8 days and no longer, which is the window plus the baseline plus one. That is a per-person, per-sibling record of how much each person was allowed to see, it is a per-person record, alongside optional Home identity bindings, and §3.2's leak list carries it rather than leaving it to be discovered. Two integers a day is the smallest thing that answers the question; a per-refusal log would answer it too and is not built, because the aggregate is sufficient and the log is not.
The window is a UTC day, and the NOT_VISIBLE clause is per counted asker. Corrected 2026-09-07.
It says the counters are keyed per (sibling, asker, UTC day) and that the window is rolling 24 hours. Two integers a day cannot answer a question about an interval beginning partway through a day: there is nothing in the row to split on, which is the whole economy of storing an aggregate rather than the per-refusal log declined above. The storage rule wins, because it is the one the leak list was written against — the window is the current UTC day, and the baseline the seven UTC days before it, still disjoint. The cost is precision at the boundary: a refusal spree beginning at 23:00 UTC is judged on an hour of evidence and re-judged on a full day. That is a delay in firing, never a false positive, which is the right direction for a notice whose purpose is not to cry wolf.
And the third clause binds each counted asker, not the window. It read "the window contains at least one NOT_VISIBLE", which §13 had already recorded as wrong: window-global, it is satisfied by one routine refusal of a Docs-only member, so a 24-hour outage plus that single query fires the refusal breaker — the exact case the prose above says is excluded. Per counted asker — VISIBLE = 0 and NOT_VISIBLE >= 1 — all three clauses collapse into one condition, and "asked in the window" stops needing its own storage: a refusal is the evidence that somebody asked.
An amendment made here on 2026-09-07 added a third counter, ASKED, arguing that the clause could not otherwise fail. The observation was right — an all-UNKNOWN day writes no row, so an outage's askers never enter the window and mutation (ii) stays green — and the remedy was wrong twice over. §13 already carried the correct fix, and this section was amended without opening it, which is the failure AGENTS.md calls characteristic, committed while repairing another one. And the third integer enlarged the per-person breaker record memoria keeps — a "how often did they query" count — without §3.2's leak list being opened either. Both reverted; two integers, and the clause moved instead.
A window that cannot be read says so. If the counters are missing, unreadable or short of a full baseline — a fresh instance, a restore, a seal failure — the sources page reports "cannot determine" for that sibling, naming which of the two it lacks. It never reports "no anomaly", because a count that fails is not a count of zero, and this is the one place in the design where that mistake would silently disable a guard rather than mis-render a page.
- The S3 half has no asker, so its predicate is the same shape with the asker clause removed: at least 20 fetches in the window, all 404, with at least one success in the disjoint preceding 7 days. Twenty rather than two hundred because an ingest pass fetches far fewer items than a search asks pairs, and the 200 here was the same borrowed number doing the same non-work.
- Neither breaker's counters are read at query time. They are written on the path that already made the call and read only when the owner's sources page is rendered, so a reader never waits on them.
- Wholesale
AHEADis a third breaker, and it reads differently: not a mis-scoped predicate but a restore (§4.1). Unbuildable until the contract module ships the fifth constant — with the wire mapping the case toUNKNOWN, this breaker cannot fire at all, and worse, it would be indistinguishable from the outage this section says must not trip one. §13 carries the dependency. It states a restore, marks the source for re-announce, takes that source out of the ranking, and purges nothing. It needs no baseline and no asker floor, because a singleAHEADis already a positive statement that the sibling lost history — there is no routine case to exclude. Gate 27.
- All three purge nothing, and none of them appears on a reader's results page — with one exception the
AHEADbreaker has to make. The refusal and S3 notices go to the team owner's sources page, because a message telling one reader how much they were not allowed to see is a disclosure with a number in it, and §4.1 is explicit thatNOT_VISIBLEis the one value that may be silent. The reconciling notice is different and does go on the results page: it says a source is temporarily out of the search, which is a statement about the suite's health and carries no fact about anyone's permissions. Leaving it off would be the silent short page gate 27 exists to catch. Gates 8 and 27.
8.2 Routing, decided after retrieval
Retrieval is free, so the router runs on results. Five classes, three calling no model:
| Class | Signal | Route | |---|---|---| | Navigational | "where is the Q3 budget" | No model. Result list. | | Numeric / aggregate | asks for a computed value | No model does the arithmetic. Resolve slots, then S6 — §8.7. | | Who knows about X | asks about people | No model. Authorship markers on passages that already passed §4.1, aggregated. | | Single-source factual | large top-1 BM25 margin, one document | Cheap model, extractive. | | Synthesis | top-K spread across ≥3 documents | Frontier model. |
Draft 1 wrote "filtered to what the asker can see" on the third row, which either restated §4.1 or smuggled a second per-person filter alongside it. The passages have already passed the gate.
§8.8's fact box is not a sixth row, and it was one in that section's first draft. A row here is a route, and a route is terminal: a query naming a fact kind would have stopped at the box, so "what's the office phone number?" with nothing validated in reach would get no answer where the single-source class would have given one. The box runs before this table and does not consume the query — Paul's constraint is deterministic first, not deterministic instead. It is a fourth way memoria answers without a model; it is not a fourth destination.
Two configuration names — model.cheap, model.frontier — with the route printed on the answer.
The pinning rule bit the first two ids this section chose, and the catalogue since settled how to satisfy it. The first pair were z-ai/glm-5.3-flash and moonshotai/kimi-k3, neither datestamped. The live catalogue proves the concern rather than merely suggesting it: it lists deepseek/deepseek-v4-pro and deepseek/deepseek-v4-pro-0813 side by side — the same model as a bare alias and as a dated snapshot, which is exactly the shape the rule is about, in this vendor's own inventory (GET /v1/models, 2026-09-05).
Only two families offer a dated id at all. DeepSeek (v4-pro-0813, v4-flash-0731, r1-0528) and one Qwen (qwen3-235b-a22b-2507). Every Kimi, GLM and MiniMax id is a bare name — kimi-k3 exists, but no kimi-k3-<date> does. So pinning Kimi would make gate 30's canary load-bearing, and pinning DeepSeek does not.
Both tiers are therefore DeepSeek: model.cheap is deepseek/deepseek-v4-flash-0731 and model.frontier is deepseek/deepseek-v4-pro-0813. Confirmed by Paul on 2026-09-05.
Be exact about how much of that the rule decided. It forced the move off two undated ids. It did not force DeepSeek twice: the survey above lists a dated Qwen (qwen3-235b-a22b-2507), so a two-lab pair satisfies the rule equally. Choosing DeepSeek for both is a judgement about price, recency and family coherence — defensible, and made without putting the Qwen option in front of Paul, which §13 records rather than quietly keeps.
The concentration gets a stated response, not just a note. Both tiers are now one lab as well as one host, so a DeepSeek withdrawal moves both at once. Name the fallback now — qwen/qwen3-235b-a22b-2507 for the frontier tier, z-ai/glm-5.3-flash for the cheap one with gate 30 carrying it — so a withdrawal is a config change through §Review on a normal day rather than a research task on a bad one.
The canary is not demoted, and an earlier draft of this section demoted it wrongly. At first configuration, embed a fixed set of public, non-content sentences — not team content, so §3.2 is untouched — seal the vectors, re-embed on wake, and state an anomaly when cosine drift exceeds a stated ε. Gate 24, covering all three pinned ids, and §11's sentence stands unamended: it is the only thing standing between a stable id and a silent swap.
A dated id at a reseller pins a label, not a behaviour. DeepSeek does ship dated release artefacts, so -0813 plausibly names lab weights — but TensorX did not train them, §13 records that id immutability at TensorX is unread, and everything short of the weights that moves a chat model's output is theirs to change under an unchanged id: quantisation, chat template, sampling defaults, context truncation. A dated id buys a name to print on an answer. It does not buy a detector.
openrouter/auto is disqualified, and so is anything shaped like it. It selects by trailing-7-day community spend, changes weekly, and cannot be attributed — failing AGENTS.md §Review's "record which model" and decision 4's naming requirement. State the rule generally, because a router is not the only thing that breaks it: a model id that can change under a team without that team changing it cannot be printed on an answer. A floating alias is disqualified on identical grounds, which is why the two config names hold ids and not tiers — pinned where the catalogue permits it, and canaried where it does not.
Noted against ourselves: OpenAI's dated snapshots satisfy this rule more cleanly than the catalogue §8.3 selects. That is a real cost of the provider choice, and §8.3 records it as one rather than talking past it.
8.3 Egress
Per decision 4: one provider, fixed by the team owner, named on every answer, disclosed in the README's first paragraph, opt-in per team, with §8.1's floor working when it is off.
The provider memoria supports is TensorX (api.tensorx.ai) — open-weight models served on EU infrastructure in Dublin, Helsinki and Paris, with advertised zero retention. Advertised: no account exists, no terms have been read, and §3.6 tells the reader we cannot verify a retention promise. That sentence keeps saying so.
The provider is not team-configurable, ruled by Paul on 2026-09-04. Supporting one is a code change; a team owner's choice is on or off. This is narrower than decision 4's "fixed by the team owner", and deliberately: a configurable base URL on an OpenAI-compatible client would let an owner point memoria at a proxy while the answer still printed "one named provider", which is the failure this whole section is about. Decision 13 records it.
Swapping later is cheap on one side and not the other. Generation is one client, one base URL and two model ids, through code and §Review, where the two-parties question gets asked again. Embeddings are a third id and the expensive one: a new host that serves the same open qwen3-embedding-8b weights yields compatible vectors and costs a migration, and any other host costs a re-embed of the whole corpus — 24M tokens at an unmeasured wall-clock (§13). Saying "swapping stays cheap" without that split would contradict §13 two pages later.
Why this one, honestly stated. Not because it beats OpenRouter and OpenAI on decision 4's own words — on one half of them it loses:
- Fewer parties, not one party. OpenRouter is a proxy, so the proxy and the upstream that runs the weights both see content, and "one named provider" would be naming one of two. TensorX presents as running the weights it serves — no proxy is interposed that we can see, and the one probe that could have shown otherwise returned 403 rather than an embedding. That is weaker than it reads. A 403 is a refusal, not a not-found, it was one key's refusal, and the probe had no control: nobody asked what a nonsense model id returns. If a made-up name 404s while
text-embedding-3-small403s, then that name is in their routing table and the probe found exactly the thing it was built to find, merely gated. §13 carries the two control calls that separate the readings; until they run this is a presentation, not a fact. Beyond it, one contractual counterparty is not one organisation seeing the corpus: a vendor this young on infrastructure in three cities is plausibly renting it, and a subprocessor sees content in exactly the sense §3.2 means. That is a DPA question and the DPA is unread (§13). The claim is narrowed, not closed, and §13's item says so. - Named weights — partially. §8.2's requirement, met by a namespaced catalogue, but only
deepseek/deepseek-v4-pro-0813and its like are datestamped. On id-pinning OpenAI's dated snapshots are strictly better, and §8.2 records that against us. - Open weights, which decision 4 never asked for. Any host serving the same
qwen3-embedding-8bproduces compatible vectors, so the vendor failing is a re-host, not a re-embed of the corpus — see §13. Self- hosting stays reachable (§12). This is the strongest argument here and it is not a decision-4 argument at all. - EU jurisdiction, likewise not a decision-4 criterion.
So the honest record is: TensorX was chosen for sovereignty and open weights — criteria Paul added on 2026-09-04, not criteria decision 4 stated — at a known cost in id-pinning and in frontier answer quality (§8.2, §13). Going direct to OpenAI satisfies decision 4's letter at least as well: one counterparty, pinnable dated ids, zero-retention obtainable on request, and the subprocessor point cuts both ways since it runs on Azure. It loses on jurisdiction, on open weights, and on cost. Written that way the choice stands; written as "TensorX wins on decision 4" it would be false.
Three things send content to it, and only the third exists today. Embeddings at ingest — every chunk, once (§7.2, not built). Answers — the passages behind a generated answer (§8.2, not built). And §8.9's metadata extraction — up to eight ruled passages of a search request that asks for a kind with no validator, a postal address, whether or not an answer would follow and including live searches while typing. The third was built first, on 2026-09-27, and the README discloses it alone because it is the only one the binary performs.
What it does not change. This is still whole-corpus egress at ingest, and §3.2's leak list is unaltered: a provider that sees the corpus sees the corpus, whatever its jurisdiction. EU jurisdiction and zero retention constrain what may lawfully be done with the content after it arrives — the first thing on this page that constrains anything — but they do not reduce what is sent, and a retention term is a promise we cannot verify (§3.6 says so to the reader and should keep saying it). Decision 5's AI-off floor remains the only configuration in which nothing leaves.
The rule is suite §8's, not memoria's, and this spec cites it rather than qualifying another app's:
> Egress that is a property of the product is forbidden. Egress that is > per-team opt-in, to one named recipient, disclosed in the README's first > paragraph and on every artefact it produced, with a working floor when it > is off, is admissible.
That one sentence replaces what stood here — a qualification of Docs §11.2 written from memoria's side — and Docs cites the same sentence rather than its own wording (A.2, merged 2026-09-05 as 6797bbf). This is how oficina/docs discussion 33 closed: it asked for the boundary of Docs' rejection to be recorded rather than the rejection reversed, and a shared sentence records it once instead of twice in two repos that then drift.
What memoria still has to say for itself, because the doctrine states the rule and not our position under it: per §3.2, memoria's embedding ingest is whole-corpus egress, not merely answer-time top-k. Draft 1 compared Docs' whole documents against memoria's retrieved passages, which flattered memoria. Under suite §8 the difference is opt-in, naming, and the AI-off floor. It is not a difference in what the provider sees, nor in how much.
A "model call ledger" is content. Any runtime record of prompts holds retrieved passages, so it falls under §3.2's log rule and §5.2's provenance rule. It is not the development prompt ledger of docs AGENTS.md, which records prompts to refs/amadan/ledger/<branch>; draft 1's "the prompt ledger" ambiguously named both. If a runtime ledger is built it is sealed, provenance-stamped, and expires with its sources.
8.4 Citations with an optional answer, never the reverse
A citation list is always served without an answer. An answer is never served without its list. With AI on, a 🤖 paragraph appears above the list; every sentence carries a passage id from it, and a sentence citing nothing is dropped before render. If every sentence is dropped, say so — an empty paragraph is indistinguishable from AI being off.
The invariant, which is the part that binds
> Every passage in a response, and every passage in a model prompt, > passed §4.1 in the request that produced it. Nothing memoria holds, and > nothing it accepts from the client, carries a passage across requests.
This is decision 3 applied to rendering, and §8.5 already states its other half — "authorization runs before the model exists". It is a rule about what may enter a response or a prompt per request. It says nothing about interface shape, and it is enforceable: gates 17 and 26.
What it forbids is re-speaking. A surface that puts previously retrieved passages back into a model prompt, re-renders them server-side, or stores them in a transcript row is memoria speaking those passages again, at a moment when nobody has asked whether this person may still see them. Carrying query text forward is not re-speaking and is not forbidden: retrieval and S2 then run from scratch, which is the whole point of retrieval being free (§8.2).
An earlier draft of this section argued the case badly, and the argument is worth recording as wrong. It said a chat transcript is a cache of authorized passages because turn 1's snippets sit on screen while turn 4 is asked. That proves too much: it is equally true of a search results page left open in a tab, and of /documents/{id}, which renders a whole document's passages and stays open as long as the reader likes. A rendered page is a copy in a browser and memoria's revocation never reaches it — true of every page here, chat or not, and now recorded as such in §3.2 rather than argued from here. The draft also claimed re-authorizing a transcript would breach §6.5's deadline. It would not: §4.1 batches 256 pairs per call and §6.5 makes a page one round of concurrent calls, so a four-turn transcript of eight passages is 32 pairs — one batch, one round, the same floor as a fresh query. A correct chat is affordable under this spec. The invariant, not the latency, is what does the work.
The shape, which is a product ruling and not a consequence
memoria is a search page: one GET search form, ?q=, one query in and one page out, with /documents/{id} as the only other passage surface. That follows from decision 5 — the search floor is the product — and not from decision 3, which a conversational surface could satisfy. Recorded as decision 14 — the spec author's ruling, in answer to a question Paul asked on 2026-09-04 and confirmed by him on 2026-09-05. A conversational surface that keeps the invariant above is not forbidden by this section; it is deferred by §12, where it can be argued on its merits rather than smuggled in as a corollary. Parking it there rather than ruling it out was the half that needed confirming, and it was confirmed explicitly.
Two things this settles for now:
- A refined search is a new query. Edit
?q=and ask again; nothing carries over, and nothing needs to. - The 🤖 paragraph is disposable — today. One paragraph above one list, regenerated per query, stored nowhere; it is not a §5.2 derived artefact and no runtime prompt record exists to fall under §8.3's ledger rule. Both halves expire if §12's cached-answers or model-call-ledger rows trigger, and this paragraph changes with them.
What slice 0 actually shipped is a subset of §8.1's floor, not the floor: index.html renders source, title, ordinal, snippet and deep link, but no 👨/🤖 marker, no date, and no pagination — Search(ctx, q, 25) is a hard cap and nothing renders a second page. §9.1 also specifies GET /query?q= where the app serves GET /?q=. Three gaps and a path, listed rather than glossed.
Hybrid, then, in the sense that matters: the search page is the product and the answer is a garnish on it (decision 5).
8.5 Prompt injection
- No tools. memoria retrieves and answers. It never writes to a sibling at answer time and never fetches a URL at answer time. Suite §7's writes are not tools and are not on this path: the hand-off is a person clicking a button after an answer is rendered, and S8 is memoria filing its own material. Neither is reachable from a prompt, which is what this bullet is about.
- No remote loads in rendered answers.
is exfiltration without a tool. Strip remote images; refuse links to non-suite hosts. - Authorization runs before the model exists. §4.1 has already filtered the passages, so injection cannot cross a grant line — there is nothing on the other side to retrieve.
- Every claim carries an openable citation. Poisoning one document to lie about another survives all of the above; a checkable snippet is the only mitigation, and a partial one.
8.6 Cost
On §7.2's measured corpus — 90k chunks, 24M tokens:
Ingest: 24M × $0.02/M (qwen/qwen3-embedding-8b) = $0.48 once. Re-embed only changed chunks. The figure survives the move off text-embedding-3-small unchanged because the two list prices happen to match — a coincidence, not a reason to stop rechecking it.
This has been wrong twice, in opposite directions. Draft 1 priced 60M tokens against a 90k-chunk corpus; draft 3 corrected to 36M by keeping the intended 400-token chunk; the measured chunk is 202 words. It follows from §7.2's measurement, and if that measurement moves so does this.
Query: retrieval is free. The generative layer sends ~8 passages plus instructions — ~5k tokens in, ~500 out. Against the ids §8.2 pins: deepseek/deepseek-v4-flash-0731 at $0.25/$0.30 per M is ~$0.0014 a query, and deepseek/deepseek-v4-pro-0813 at $2.00/$4.00 is ~$0.012. Five people × 20 queries/day × 22 days = 2,200 queries: ~$3/month if most route cheap, ~$26 if most route frontier.
Read that against one baseline, because an earlier draft used two. Draft 3 carried ~$5 cheap and ~$100 frontier against a generic provider. So the frontier month fell about 4× and the cheap month about 1.6× — both moved, and the earlier claim that "the ceilings fell, the estimates did not" was true only of the intermediate glm/kimi pair and is now retired. Per query the cheap figure still sits inside the $0.001–0.003 range this section always carried; the frontier one, at $0.012, has fallen below its $0.02–0.10 range rather than sitting in it.
Most of that came from §8.2 pinning dated ids from one family, not from the provider switch, and none of it is a reason to route more queries to the frontier tier — §8.2's three model-free classes remain the design, and this section's closing sentence still holds.
Derived-artefact regeneration (§5.2) adds a synthesis-class call per invalidated artefact and is unbudgeted until the feature exists. So does §8.9, at one cheap-tier call per query that names a valueless kind and whose recognisers found nothing — a small fraction of a small fraction. Cheap tier by choice, not by necessity: Paul's ruling says "best value models", and extraction from eight passages is the cheap tier's work under §8.2. A better model would extract better — more complete addresses, cleaner boundaries, fewer omissions — so this is a cost-quality trade to be re-measured, not a consequence of the verbatim check.
The money is recurring query tokens by an order of magnitude — the economic argument for §8.2's router and its three model-free classes.
8.7 Numeric and aggregate questions
Paul's motivating example, 2026-09-03: "what's average revenue in June the last 3 years". Ruled independently by Fable and GPT Sol, which converged; where they differ, the stricter reading is taken.
The tension this resolves. §2 says memoria never recomputes a number, because a second calculator eventually disagrees with the first. But no cell contains "average June revenue over three years", so the question either gets forbidden or the rule gets read properly. What the rule protects is not arithmetic — averaging three doubles is deterministic — it is selection, units and formatting: actual versus budget, one year in thousands, 1234567.833 versus €1,234,568. Sheets §5 already names a second formatter as the same bug class one layer down.
So: Sheets computes, memoria never does (decision 10). S6 takes an enumerated function over cell references — SUM, AVERAGE, MIN, MAX, COUNT — and never expression text, so memoria is not a formula author and cannot grow into one. Rejected alternatives: memoria computing and showing its working (a second calculator, arrived at one mean at a time); writing a formula into a scratch sheet — and note this rejection now stands on decision 10's own ground rather than on a write ban. Suite §7's S8 permits memoria to write into a designated area, and a scratch tab is exactly that shape, so "forbidden by §6.4" is no longer the reason. The reason is decision 10 itself: a formula memoria authored is a calculation memoria owns, and a citation that cites itself.
Chunking a spreadsheet, which §13 carried as unsolved. Two chunk kinds per tab:
- One structure chunk per tab — sheet title, tab name, dimensions, the header band, the leftmost text column, number formats named in words, and every date-like header emitted twice, as displayed and canonicalised (
Jun 2024and2024-06). This is the chunk that carries the vocabulary. - Region row chunks, one per row of a header-plus-contiguous-block region, rendered as facts rather than positions: "FY2024 › P&L › Revenue: Jan 2024 = €1,200,000; … Jun 2024 = €1,350,000". Row label is the nearest text run to the left, column label the header band above, concatenated top-down. Sheet and tab prepend to every chunk. Values are the display string from S3, never a double memoria formatted.
That is what makes revenue, June and 2024 literal BM25 terms for a cell whose content is the bare number 1350000. Header detection is deterministic and conservative: where competing header bands make the meaning ambiguous, index the alternatives as structure and do not manufacture a fact.
Retrieval, because top-k cannot prove completeness. Decompose into named slots — (Revenue, June, 2024), (…, 2025), (…, 2026) — and resolve each by exact metadata lookup against the structure catalogue, using top-k only to find candidate regions. Recall becomes a per-slot count:
- exactly one current, authorized operand → filled;
- zero → a named gap;
- two or more → ambiguity, never extra evidence, and never collapsed.
S6 is called only when every slot is filled and every operand carries a certified unit. Any gap, ambiguity, STALE, AHEAD or UNKNOWN yields the slot table with citations and no number: "Found June 2024 and June 2023; nothing matched June 2025. No average shown." A two-of-three average is the plausible-wrong answer that §8.1's loud withholding exists to prevent, and it is worse than an empty result because it is actionable.
"Compatible units" was unfalsifiable in draft 3, and this section's own example is the case it cannot catch. Three cells formatted General, one of them typed in thousands, carry no signal that distinguishes them. MIXED_UNITS therefore never fires, the three numbers average cleanly, and memoria certifies a figure wrong by a factor of a thousand while citing all three cells correctly. A check that cannot fire is a check written to pass, and this one guards the number the whole section exists to protect.
So the test is inverted: a unit is certified, not contradicted. Every operand must carry a unit, and it may come from exactly two places:
- the cell's
format_code, which S6 already returns — a currency symbol, a percentage, or a scaling comma (#,##0,is thousands in both Sheets' and Excel's grammar); or - an explicit unit word in the structure chunk's header band or row label, both of which §8.7's chunking already indexes:
€k,thousands,$m,%.
General with no unit word is not a unit. It is the absence of one, and that absence has to be a verdict rather than a silence.
Who certifies: memoria, and not S6. Draft 4 said both — "S6 is called only when units are certified" and "S6 gains UNIT_UNKNOWN" — which cannot both be true, because a value S6 returns is a value that arrives after S6 was called. The review was right to force the choice, and it falls to memoria for a reason that is not about which is tidier: neither party holds both signals, and memoria is the one that holds neither-half-only. The format_code comes from Sheets, but memoria already receives it (S6 returns it, and §8.7's structure chunk indexes it at ingest). The header word comes from memoria's own chunking, and Sheets has no idea what memoria's structure chunks say. An S6 that decided units would be deciding on half the evidence.
So the certification rule is memoria's, and it is a fixed table rather than a judgement:
| Signal on an operand | Unit token | |---|---| | 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, % | | the structure chunk's header band or row label carries an explicit unit word — €k, thousands, $m, % | that word, canonicalised the same way | | both, and they canonicalise to the same token | that token | | both, and they canonicalise to different tokens | none: the operand is uncertified | | neither — General, no unit word | none: the operand is uncertified |
And the two refusals are memoria's verdicts, delivered before S6 is called:
| memoria's verdict | Means | memoria does | |---|---|---| | MIXED_UNITS | every operand is certified, and two of their tokens differ | no S6 call; no number; name the operands and their tokens | | 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 |
€k and € are different units, and memoria will not reconcile them. This is the case the review asked to be ruled, and the ruling is the strict one: recognising €k and €#,##0 as the same unit means multiplying one operand by a thousand, which is memoria doing arithmetic on a cell value — the exact thing decision 10 forbids, arriving as a helpful conversion. So they do not aggregate, MIXED_UNITS fires, and the refusal names both formats so the reader can see it is a formatting difference and fix it in Sheets. memoria performs no unit algebra of any kind: no scaling, no currency conversion, no percent-to-fraction. Tokens are compared for equality or they are not compatible.
A General cell under a €k header aggregates, and here is exactly what is displayed. The header word certifies the operand as €k; if the other operands certify to €k too, S6 is called and returns a value, a display string and a format_code. memoria displays S6's display string unchanged and names the unit beside it, from the header — "Average June revenue, 2024–2026: 1,350 (€k, from the column header "Jun 2024 €k")." It does not fold the unit into the number, does not render €1,350,000, and does not re-format anything: folding the unit in is formatting, and §8.7 exists because a second formatter is the same bug class as a second calculator. Where the unit came from a header word rather than a format code, the answer says so, because that is a weaker provenance and the reader should be able to check it.
Re-certification on the way back, because the indexed format can be stale. memoria certifies from the format code it indexed, then compares it against the format_code S6 returns for the same cell. A disagreement means the sheet was reformatted since ingest: no number, the slot table, and the source marked for re-ingest. This is STALE's logic applied to formatting rather than to content, and without it the certification runs on a snapshot of the thing it is certifying.
All three outcomes can fail, which is the point. Three cells formatted €#,##0 aggregate. One €#,##0 against one k€#,##0 fires MIXED_UNITS. Three General cells under a header that says nothing fire UNIT_UNKNOWN and show no number. Gate 22 runs all three plus the €k-header case, and the General one is what draft 3 would have answered wrongly and confidently.
The cost is real and it is not hidden. Plenty of real spreadsheets are General throughout, and on those memoria refuses every aggregate until someone formats the cells or writes the unit into the header. That is a worse product than one which answers, and a better one than one which answers wrongly: the refusal names the cells and says what would fix them, in Sheets, where the fix belongs. The spec's position is that there is no override switch, because an override is the General case with a checkbox in front of it, and a checkbox nobody reads is how the wrong number gets certified with the reader's own consent.
That position is not mine to settle, and §13 marks it as Paul's. It trades a product cost nobody has measured against a correctness guarantee, and the measurement — how many of a real team's sheets are General throughout — does not exist. If it turns out to refuse nearly everything, the answer this spec would argue for is better header indexing rather than an override; but "we refuse most numeric questions" is a product decision, not a spec detail.
One limit, stated plainly: a cell whose value was typed in thousands under a General format is undetectable to memoria, to S6 and to Sheets. Nothing in the suite knows. The header word is the only remaining signal, and where there is none, UNIT_UNKNOWN is the correct answer rather than a cautious one.
The answer names its basis, including which years "last three" meant, because that is ambiguous on any given date.
What must not be built. A retrieval-augmented calculator: retrieve top-k, let a model pick numeric strings, average them, present it as grounded. Both reviewers named this unprompted. It stacks three silent errors — incomplete recall, wrong operand selection, arithmetic and formatting outside Sheets — and every one of them cites correctly while being wrong. Also forbidden: memoria parsing a display string back into a number ("€1.2M"), which is a second parser and therefore a second calculator; and caching an S6 result, which outlives its cells.
Gate 14 extends: every digit in an answer sentence traces to a cited passage or an S6 result.
8.8 The fact box: deterministic extraction above the list
Paul's motivating example, 2026-09-14: a search for phone number should put the answer at the top of the page — "Tito office number: +353 (0)1 547 7854" — and the same for an eircode, a VAT number. And his constraint, given in the same breath, which is the whole of this section's shape: do as much deterministic ordering of the data as possible before any inference runs on it.
Called the fact box, and not the lookup box, which is what draft 1 called it. query.IsQuestion's complement is already a lookup in this codebase — TestALookupPrimesTheFirstResult is about Enter, not about this — and a second meaning for the word in the same package is the name collision this repo's reviews keep finding.
This section is the deterministic layer, and it calls no model. The kinds it serves are the kinds that have a validator. Decision 15 originally said the whole fact box calls no model, ever, and excluded any kind without a validator; Paul amended it on 2026-09-19 and §8.9 is the model half he ruled in. Nothing in this section changes because of that: a validator-backed kind never reaches a model, and every guarantee below is a guarantee about this layer. Where a sentence here says "no model", it means this layer and not the request — §8.9 may call one afterwards, for a different kind, and §8.2's router may call one after that.
Its deterministic layer is a fourth way memoria answers without a model, and it is not a class. Drafts 1 and 2 both called it "§8.2's fourth model-free class" while also calling it a layer, which cannot both be true: a row in §8.2's table is a route, and a route is terminal. A query naming a kind would have stopped at the box, so "what's the office phone number?" with nothing validated in reach would get no answer where the single-source class would have given one. The box is attempted, and then §8.2 routes the query exactly as it would have. Paul's constraint is deterministic first, not deterministic instead.
"Model-free" in §8.2 and §8.6 is a claim about routing, not about the request. §8.9 now sits between this box and the router and may call the cheap tier for a postal address. A navigational query that names address can therefore cause a model call that no router class did — the router's three model-free classes are still model-free; the request carrying them may not be.
This section's own path touches nothing in §3.2. No egress, no provider, no embedding — the deterministic box works on an instance with AI switched off, which is decision 5's floor and therefore the whole product today. It is part of §8.1's floor and gate 15 covers it. Nothing on this path is logged, gate 4's rule applying unchanged: a fact, its label and its kind are all content, and the query that named the kind is content too.
That claim is now load-bearing in a way it was not, because decision 15 has been amended and §8.9 adds a model. What survives the amendment without qualification is everything in this section: recogniser, validator, label, cap, collision, citation. What §8.9 adds sits after all of it and is switched off with AI. The sentence to keep straight is "the deterministic box has no egress", not "the fact box has no egress" — and §8.9 exists as its own section rather than a paragraph here precisely so that the two cannot be confused by someone reading quickly.
One extraction, over the ruled results, and why drafts 1 and 2 had two
Candidates are the validated facts carried by the passages this search already returned and §4.1 already allowed. There is no second index, no second query, no ingest-time half and nothing stored. Store.Search produces Page.Results; the box is extracted from those and from nothing else.
That makes §8.4's invariant structural rather than careful: every passage in a response passed §4.1 in the request that produced it, and a candidate that is not in Page.Results does not exist. There is no second retrieval path, so there is no second authorization path to get wrong.
Both earlier drafts built a retrieval mechanism for reachability, and the cap is what killed it. Draft 1 folded kind words into the main index; draft 2, after the IDF objection below, gave facts their own in-memory table queried by kind. Writing the retrieval step out concretely is what found the contradiction:
- this section caps candidates at three;
- a facts table is queried
WHERE kind = 'phone', which is corpus-wide; - a real corpus holds dozens of phone numbers —
index/rank_test.go's twelve-chunk fixture already holds two, plus four account numbers and a VAT number.
So the cap fires on the first query anyone types, on every kind, permanently, and the box never appears. The only way to bound a corpus-wide fact query is to rank the candidates and keep the best three, which is exactly what "never by picking" forbids — and a LIMIT before the ruling is worse still, because a withheld document then eats one of the slots and "count after the ruling" becomes unimplementable. The gate review reached this independently and from the other side.
The ruled top-25 is already a query-scoped candidate set. Once candidates come from it, the facts table has no query-time job left, and its only other job was the ingest-time ranking lift.
Why the deferred fix is not "index the kind words", measured rather than asserted. FTS5 computes a term's IDF across the table, so a term present in most rows scores near zero. On modernc.org/sqlite, 20 rows, best bm25() for "phone": -2.7169 with the term in one row, -0.0000 with it injected into all twenty. A collapse, not a degradation.
Be exact about what that measures, because draft 2 overclaimed it. Draft 2 said the injection would degrade "exactly the query the NEAR comment works through", and on that comment's own fixture it would not: the chunk holding the office number already contains the word phone, so injecting it there moves that chunk's term frequency and length, not the corpus IDF. The real exposure is a corpus where many chunks carry numbers and few say phone — which is the shape a large real corpus has and the fixture does not. The number stands; the sentence it was attached to did not.
What this costs, and it is the thing to measure next
The reachability limit is back, and draft 2 claimed to have removed it. A fact in a document that does not reach the ruled top-25 is a fact the box does not have: an eircode written D02 XY45 in a document that never says "eircode" is found only if something else about it ranks. Draft 1 named this and deferred the fix; draft 2's table did not actually remove it, since that table ran only when a kind was named and was inert corpus-wide under the cap. §12 carries it.
The box's presence depends on the retrieval limit, which is a real edge and is stated rather than discovered. Search(ctx, q, 25) is a hard cap, so a fourth distinct value sitting at rank 26 leaves a box of three, and the same value at rank 25 removes the box entirely. That is not wrong — the box describes what this search found — but it means the box is a function of the page, not of the corpus, and nobody should read it as the latter.
Whether the cap made the box inert on the corpus that motivated it was unmeasured until Paul hit it, 2026-09-29. He searched "what is the office phone number?" on the live instance and got 25 results and no box. The Office page alone carries the office number and a list of unused ones, so the ruled top-25 held four or more distinct valid numbers and the cap fired — on the very query this section was written for. This section used to say the remedy was "a narrower kind lexicon or a per-kind cap, not a rule that picks". Neither of those would have helped: the lexicon was already right (the query asked for a phone number), and any cap large enough to pass would show a list of unused numbers as the answer. Paul ruled the remedy instead: narrow by the reader's own words — see "Narrowing by the reader's own words" below.
§4.1 is unaffected, in the direction that matters. A reader for whom passages are withheld draws candidates from a smaller set, never a larger one — rule drops withheld results and §12's over-fetch-and-refill is deliberately not built, so the slot stays lost. Fewer candidates cannot be a leak.
A chunk row that is gone by the time the box reads it is skipped silently, exactly as Store.Search's livePassage check drops a stale hit, and for the reason recorded there. It does not fail the search: the box is a garnish and trading the page for it is the wrong direction.
And the box needs plaintext the search path does not open. Store.Search opens titles, paths and item ids under their own purposes; chunk bodies are opened only by indexDocument and Passages. FTS5 snippets are 24 tokens with <mark> in them — too short and too marked-up to take a span or a label from. So the box re-reads the sealed chunk rows for the ruled results, after the ruling, under seal.PurposeChunk: at most 25 rows, one IN query, 25 AEAD opens.
Why nothing is stored
The rejected design is a sealed, durable facts table, written at ingest, read at query time. Two grounds, and draft 1's third is withdrawn:
- It is derived state, so it inherits AGENTS.md's ordering rule in both directions and §5.2's invalidation — a new way for the index and the database to disagree, bought for nothing that query-time extraction does not already give.
- It freezes the recognisers at the version that ran at ingest, so improving one leaves every existing document answering with the old one until somebody re-ingests it. This is not the
Passagesproblem and draft 2 cited it as though it were. That comment records the opposite lesson — re-running the chunker at view time misaligned citations, and freezing at ingest is the fix — and it does not transfer, because a recogniser change moves no ordinal and breaks no citation. The staleness argument stands on its own or not at all.
Draft 1's third ground was that a plaintext kind column would be an inventory of which kinds of sensitive data the team holds — true, and irrelevant, because the proposal was for a sealed table. The honest residual for a sealed one is the per-chunk fact-row count, which is real, much smaller, and not on its own a reason.
Nothing new exists at rest, and nothing new exists in the index either. That is the strongest property this section has and it came from deleting things.
The recognisers, and what is not one
A kind is in the box when a query word names it and a candidate validates.
| Kind | Primed by | Validated by | |---|---|---| | 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 | | iban | iban, bank details | country prefix, length by country, mod-97 | | vat | vat, vat number | modern Irish format and its check letter | | phone | phone, telephone, tel, mobile, phone number | structural, and see below | | email | email, email address, e-mail | local@domain, domain carrying a dot, no whitespace — weak, and see below | | address | address, postal address, office address | none — this layer never produces one. A §8.9 model kind; see there |
Three of these are checksums and two are not, and the section says so rather than implying five. An eircode's routing key is a closed set, an IBAN has a mod-97 check, an Irish VAT number has a check letter derived from its digits. These can be wrong, and a wrong one is refused.
phone has no checksum, so it gets a structural rule that is stated here rather than left to the code: a bare run of digits is never a phone number. The span must carry a +, or separators grouping it. This is not a hack bolted on to a weak validator — it is how documents write phone numbers, and without it rank_test.go's own corpus yields false positives: DUNS/D-U-N-S number is 985632364 is nine digits and a plausible Irish mobile. The cost is stated too: a document writing 0871234567 with no separators is not boxed. That is the safe direction, and it is the direction a refusal should fail in.
Three structural rules came out of building it, and each one is here because ordinary document text was boxed without it. The implementation review found all three live, not as mutations:
- A phone number uses one separator style throughout. Mixed separators walk straight past the 9-or-10 bound above: "The deadline is 01-02-2024 10:30" matched
01-02-2024 10, ten digits, valid, and boxed a date. Three styles, never mixed. - Four digit groups, or five with a country code — refused by count, never truncated. "Numbers drawn: 01 05 12 23 34" canonicalises to ten digits and passes every other test, so something has to refuse it. The count is the validator's job and not the pattern's, and putting it in the pattern was a worse bug than the one it fixed: a pattern capped at three groups and five digits a group does not refuse a long number, it shortens it. "Mobile: +353 87 1234567" — the commonest international spelling of an Irish mobile — was boxed as
+353 87 12345, and "+49 30 12 34 56 78" as+49 30 12 34 56. Both are well-formed numbers that pass every other check and are not the number in the document: §8.7's "cites correctly while being wrong", manufactured by the guard meant to prevent it.
So the recogniser is generous, it must end on a word boundary or an extension, and the validator refuses by group count. Germany and France genuinely write more groups than this and their numbers are refused. That is the stated cost, and it is the right way round: a refusal shows nothing, a truncation shows a wrong number.
- The international floor is ten digits, not E.164's eight. Eight admits
+10 000 000out of "Growth of +10 000 000 users". A country code plus a national number reaches ten everywhere this corpus dials, and refusing a genuinely short international number is the safe direction.
One residual is stated rather than left to be found: an ALL-CAPS heading. SEE FORM P45 THEN P60 still yields P45 THEN, because case-sensitivity cannot separate an eircode from uppercase English, and uploads do carry all-caps headings. No regex fixes this; only a label or a dictionary would, and both are bigger than the problem. Recorded so the next person finds the limit rather than the surprise.
And eircodes are matched case-sensitively, which is not fussiness. The identifier alphabet is most of the consonants, so case-insensitively it matches lowercase English after a road number or a form code: "Take the N11 then exit at Bray" boxed N11 then, and "Form P45 then P60" boxed P45 then. A cited fact that is really an English word reads exactly like a correct answer — §8.7's "cites correctly while being wrong", arriving from a character class. Eircodes are written uppercase; a lowercase one going unrecognised is the cost and it is the right way round.
email is kept and its weakness is stated rather than hidden. local@domain is closer to pattern-spotting than to validation. On a corpus of uploads and Notion pages it is useful — one support address, cited. On a corpus carrying forwarded mail (§4.2's source list) every signature qualifies, the cap is passed, and the box is inert for that kind. That is the cap working, and the failure direction is silence rather than a wrong address.
url was in draft 1 and is dropped. scheme://host is pattern-spotting with nothing behind it, so every link in every document qualifies and the box is inert immediately. It also collides with §8.5's "refuse links to non-suite hosts" for no gain.
Nothing in the box is ever an anchor. The value is text; the only link the box carries is its citation, to /documents/{id}#p{n}. No mailto:, no href built from a document's bytes.
address primes the postal-address kind, and email address primes email — longest phrase first, so the two cannot collide. Postal address has no checksum, no fixed grammar and no length, so this layer has nothing to validate it with and produces nothing for it. It is §8.9's first model kind, which is what decision 15's amendment changed: before it, "no validator" meant "not a kind"; now it means "not this layer's kind".
number on its own primes nothing either. The lexicon maps distinctive words and phrases; generic words count only in combination.
This is query.openers' trade made in the opposite direction, and draft 1 claimed it was the same one. openers is deliberately stingy — "the safe direction is not priming" — because a wrong prime navigates away from a page somebody was reading. The fact lexicon can afford to be generous, and the reason is the validator, not a shared instinct: "mobile app" primes phone, finds no phone number, and renders nothing.
It complements Enter-priming rather than competing with it. IsQuestion declines to prime for anything ending in ? or opening with an interrogative, so "what's the office phone number?" navigates nowhere today, and those are disproportionately the queries a fact box answers. Where both fire — "office phone number", no question mark — Enter still goes to the first result, not to the box's citation. The box is a thing to read, not a destination; changing what Enter means depending on whether a regular expression matched is the unreliable-Enter failure PrimesFirstResult exists to avoid.
The span, the canonical form, and this spec's two arithmetic rules
The span is what the document wrote, byte for byte, including punctuation, the (0), and any extension written after it. It is what the box displays and the only thing the box displays. Separators inside a span include U+00A0, because Word and Notion paste non-breaking spaces and the chunker does not normalise interior ones — a recogniser written with Go's \s, which does not match U+00A0, silently finds nothing in pasted text.
The canonical form is match-only. It is what candidate identity is keyed on, and what the validator runs on. It is never rendered and never an operand. An Irish national number canonicalises to E.164 so that 01 547 7854 and +353 (0)1 547 7854 are recognised as one number rather than two candidates.
Two of this spec's own rules have to be read against that, and both are ruled here rather than left to be found:
- *§8.7 forbids "memoria parsing a display string back into a number … a second parser".* What it forbids is a parse that feeds something memoria then shows or computes with — the
"€1.2M"case. A canonical form that is never displayed and is never an operand is not that. - Decision 10 says memoria never does arithmetic. A mod-97 check digit is arithmetic. It is not a number memoria reports: it decides whether a string is an IBAN at all. Decision 10 governs numbers in answers; a checksum is a predicate. That is a narrowing of Paul's decision made by the spec author, and it is filed in §13 beside decision 15's, rather than one being marked and the other slipped through.
Draft 1's own example failed draft 1's own validator, which is worth keeping visible: +353 (0)1 547 7854 is not E.164 — E.164 has no spaces and no (0) — so "validated by E.164" was a rule the motivating case did not pass. The table says canonicalises to, which is what was meant.
The label, and the four ways it goes wrong
The label is taken from the document and is never composed. Given the span's position in the chunk text:
- Take the run back to the start of the enclosing sentence or line. If that run contains another recognised span, start after it — in
Phone: 01 547 7854 - 01 598 1234the second number's run would otherwise bePhone: 01 547 7854 -, labelling the second number with the first one's value.
The spans it skips are of every kind, not only the kinds the query named, and that is a rule rather than an implementation note: a label that depends on what was asked for is not the document's label. Asked for phone alone, "Email paul@tito.io or phone: 01 547 7854" came back labelled Email paul@tito.io or phone — one fact's value inside another's label, which is the exact failure this rule exists to stop, reappearing because the rule could not see the span.
- If what remains ends in a separator —
:,—,–,-,=, tab — and is non-empty after removing it and trimming, and is 60 characters or fewer, that is the label. - Empty after the separator → fall through. A markdown bullet is the case: the chunker keeps lines verbatim, so
- +353 1 547 7854gives a run of-, which "ends in a separator" and yields nothing. Two such bullets give two empty labels, collide, and would have suppressed the box for a plain contact list. - Longer than 60 → no box for that kind. Not the trail instead. The document labelled this value and the label will not fit, so substituting a vaguer one is memoria replacing the document's words with its own: "The number you must never give to customers because it rings the CEO directly is: 01 598 1234" becomes
Contacts, sitting besideReception — 01 547 7854. Dropping the candidate instead is worse, because that turns a two-candidate ambiguity into a confident single answer. - Otherwise the chunk's heading trail, and if there is none — an upload with no headings, which is the common pasted-file shape — the document title, never the empty string.
A leading list marker is markup, not part of the label (2026-09-29). Rule 1 anchors the run at the start of the line, and a list item's line starts with its marker. So - Office: 01 547 7854 was labelled - Office, and a to-do item - [x] Office: … was labelled - [x] Office. For a Notion page, memoria's own renderer wrote that marker (notion/markdown.go renders bulleted items and toggles as - , to-do items as - [ ] /- [x] , quotes as > ), so the label carried memoria's syntax as though the document had written it. (A numbered item's 3. was already dropped, because the sentence split treats a full stop after a digit as a sentence end. An earlier draft of this paragraph said otherwise.)
The rule: before rule 2 runs, the run — from where rules 1 and 1's earlier-span clause start it, up to the value — loses its leading indentation and one leading marker: -, *, + or •, each followed by a space; N. or N) followed by a space; either of those followed by [ ] or [x]; or > followed by a space. A bare bullet (- +353 1 547 7854) is untouched: its run is trimmed on the right to -, which has no space after the marker and so is not one, and rule 3 still sends it to the trail, exactly as before. The cost is stated beside narrowing's other limitations: a literal prefix shaped like a marker cannot be told apart from markup.
Draft 1 required the value to start its own line and capped the label at 40, and the repo's own fixture is what breaks that. index/rank_test.go:56 — built from the live instance where Paul hit this — holds one chunk reading:
> "Turn the knob. The office phone number is: +353 (0)1 547 7854. We > also have the following unused phone numbers: +353 (0)1 598 1234."
Under draft 1 both numbers are mid-paragraph, both fall to the heading trail, and both come back labelled Office Address: two identical labels, one of which the document explicitly calls unused. The box would have shown them as equals with the disambiguating word one clause away. That is §8.7's "cites correctly while being wrong" in a lookup coat. Sentence anchoring fixes it by doing less: the labels become "The office phone number is" and "We also have the following unused phone numbers", and the reader can see which is which because the document said so.
The sentence split is naive, and draft 2's claim that it fails safely is false. A boundary placed too late yields a shorter run that still passes every test above: Dr. Smith's direct line: 01 … yields Smith's direct line, and St. Stephen's Green office: … yields Stephen's Green office. That is a label the document did not write, produced by the rule whose entire justification is that it never composes. A short fixed exception list — Dr. Mr. Mrs. Ms. St. No. ext. Ltd. Co. — covers the common cases and is stated as incomplete. It must not grow into a sentence tokeniser; where it is wrong it is wrong in the composing direction, and saying so is the difference between a known limit and a discovered one.
60 characters is a guess. What is not a guess is that it must clear the motivating fixture, whose longer label is 47.
Candidates, ambiguity, the cap, and one box per kind
A candidate is one canonical value. The same number in five forwarded signatures is one candidate, cited to the best-ranked passage carrying it. Two different numbers in one chunk are two — the fixture above is exactly that shape, and treating a chunk as the unit would hide the second.
Every rule below is applied per kind, and a multi-kind query renders one box per kind. A cap counted across kinds would let two phone numbers and two email addresses suppress each other, and a collision check across kinds would suppress a phone and an email that happened to share a label.
§8.7's slot rule is "two or more → ambiguity, never extra evidence, and never collapsed", and it resolves that by showing no number. The fact box shows all of them instead, and the difference is not inconsistency. An average built from two of three operands is a wrong number: the question had one answer and memoria produced a different one. Two phone numbers are two right answers to an underspecified question. §8.7's ambiguity is incompleteness, which can only be refused; this one is underspecification, honestly served by showing both, labelled and cited.
Never by picking. Not highest BM25, not first, not longest. Rank order sorts a result list and carries no claim about which number is the office one. The one narrowing allowed is a match between the reader's words and the document's own label, and only once the cap has fired (Paul, 2026-09-29, below). That is not picking in this paragraph's sense: no score and no order are involved, every candidate is treated the same, and when two labels match, nothing renders.
A box row carries the document title and the heading trail as well as the label, exactly as a result row carries both and for the reason recorded there: two pages called "Contact" under different documents are the same row to a reader who cannot see which is which.
Two candidates whose whole citation line collides — same title, same trail, same label — render no box. Keyed on the line rather than the label alone, because a supplier page and the office page both falling to the trail Contact are perfectly disambiguated by their titles, and suppressing that box would be losing a real answer to a rule aimed at a different problem. Where the whole line does collide, the page would show one identical row twice against two different values — not an enumeration but a contradiction with no way for the reader to resolve it, and the results list is the honest surface for that.
Capped at three inclusive: three renders, four renders nothing.
Narrowing by the reader's own words (Paul, 2026-09-29)
The ruling. Paul asked why "what is the office phone number?" showed no box, on a page whose second result says "The office phone number is: +353 (0)1 547 7854". The proposal he approved ("Sure."), in the words he was shown:
- "When there are too many candidates, use the other words in the question ("office") to narrow them down. If exactly one candidate's label contains them, show that one."
- "Treat " - " as a break when working out labels."
- "Keep the current rule as the fallback: if narrowing doesn't leave exactly one candidate, still show nothing rather than guess."
This section implements 1 and 3 as he approved them, and 2 differently, for a reason he has not yet been told. Part 2's premise was wrong. The - he saw was the search snippet flattening Notion's list lines. The stored text has real line breaks, which the label rule already treats as a boundary. The actual defect is the list marker those lines start with, and the list-marker rule above fixes that. An earlier draft of this section went further than the ruling: it narrowed below the cap, and it showed every match rather than exactly one. The first review caught both, and this text is back to the ruling.
When it applies: only when the cap has fired. If a kind has more than three candidates after the §4.1 ruling, it would render nothing, and narrowing is attempted. Otherwise nothing changes: a kind with three or fewer candidates renders or is suppressed by collision or rule 4 exactly as before, and the reader's words play no part.
The qualifiers are the query's tokens (fact.tokens: lowercased, split on anything that is not a letter or digit) with these removed:
- every token of every lexicon phrase, for every kind, so
phone,mobile,email,address,vatand the rest are never qualifiers; - the generic
number,numbers,noandnr; - a fixed list of question and function words: interrogatives, articles, pronouns, auxiliaries, common prepositions, and request verbs such as
tell,give,findandshow. The list is in the code, it is stated as incomplete, and like the abbreviation list it must not grow into a parser; - single-character tokens, which disposes of the
sinwhat'sandTito's.
Pinned examples: "what is the office phone number?" → {office}; "office phone" → {office}; "office mobile" → {office}, because mobile is how Phone is asked for; "what's Tito's phone number" → {tito}; "phone number for the Dublin office" → {dublin, office}; "support email" → {support}; "phone number" → none.
A candidate matches when its label is the document's own and contains every qualifier as a whole token.
- "The document's own" means rule 2, the sentence-anchored label ending in a separator. A rule 5 label (the heading trail, or the title) never matches. This is what makes narrowing work on the motivating page. The unused numbers there are listed under the heading
Office Address, so a rule that matched fallback labels would match every number on the Office page, unused ones included, and could never leave exactly one. Provenance is recorded when the label is made (fact.Found.Own), never inferred by comparing the label's text with the heading. A document can write a label identical to its own heading. - Whole tokens, compared on
fact.tokens, soofficedoes not matchofficesorbackoffice. There is no stemming. - Every qualifier, not any.
Dublin officedoes not match a label sayingOffice phone.
The outcome, for a kind over the cap:
- Exactly one candidate matches: that candidate renders, alone. If its label is over-long (rule 4), the kind renders nothing: the document's own words are the answer, and they cannot be shown. Rule 4 is kept for matching only.
fact.Scanreturns the over-long text withNoBoxset, and it is never rendered. - None, or two or more: nothing renders, as before. Narrowing never picks between two matches.
The box says it narrowed. Under the row, one line:
> Matched “office”. 3 other phone numbers are in these results.
It names the matched qualifiers and the number of candidates left out, which is always at least three because the cap had fired. The count is taken after the §4.1 ruling, like every other count in this section, so a withheld document neither adds to it nor is revealed by it. It carries no values and no links: the results list below is where the others are.
Why this line is required: narrowing weakens this section's injection defence, and the line is what keeps that visible. "Injection" below says the ambiguity rule fails safe against poisoning: a planted second number makes the box show both, or nothing. Narrowing changes that in one case. On a page already over the cap, a planted number whose label alone contains the reader's word becomes the only row: a single, confident, cited answer. The count line is the mitigation. It tells the reader that other numbers exist and that the box chose among them on the reader's own word. It is not a fix, and this spec does not claim one.
Stated limitations, so that they are known rather than discovered:
- Only the value right after the separator gets the label. In
Office: 01 547 7854, 01 598 1234, only the first number is labelledOffice. The second follows another recognised span, not a separator, so it falls to the trail and can never match. The same applies toOffice:followed by bare bullets. - Identity comes before matching. A value's first occurrence is its candidate. If that occurrence fell to the trail, a later occurrence of the same value with a matching label does not rescue it.
- Multi-kind queries share one set of qualifiers. "office phone and support email" applies
{office, support}to both kinds. Usually no label has both, and neither kind is narrowed. - The lexicon limits which kinds are recognised at all. "main office landline" primes no kind (
landlineis not in the lexicon), so no box is attempted. - A literal prefix shaped like a list marker is indistinguishable from markup. A document whose label genuinely begins
2.loses it.
Model kinds are not narrowed. §8.9's candidates come from a model reading passages, so there is no document-authored separator label to match. They keep §8.9's own rules.
Nothing here is logged, as in the rest of this section: the qualifiers are words from the query, and the query is content.
Where it sits on the page
Inside the results template definition, not above it. results.html is rendered two ways from one definition — inside the page on a scriptless submit, alone on GET /search for search.js — and a box outside it would sit above a fragment that swapped underneath it, leaving query N−1's answer over query N's results on every keystroke.
Below §6.5's notices and above the list. results.html already puts the withholding callout above the results because "a reader who scrolls past the last result has already decided what the page means". A confident single answer above the line saying the page is partial is the same mistake reversed.
Search page only. Decision 14 makes the search page and /documents/{id} the only two passage surfaces, and this is a feature of one of them.
Zero candidates renders nothing at all — not "no phone number found". The corpus is not the world, and a confident absence is this codebase's own worst failure shape.
Gate 14's first clause extends to the box — every character of it traces to a cited passage. Its second does not. "An all-dropped answer says so rather than rendering empty" exists because an empty 🤖 paragraph is indistinguishable from AI being off; a box that does not appear is indistinguishable from nothing, which is what it means.
Injection, and the one place ambiguity pays for itself
§8.5's mitigation for a poisoned document is that every claim carries an openable citation, and that it is a partial one. The box sharpens the problem: it promotes a single document's assertion to the top of the page with a validated-looking format around it. Office phone: +353 1 000 0000 in an uploaded file is a fact box, and no model was involved in believing it.
Two things bound it and neither is a fix:
- The value is a cited span of a passage that passed §4.1, so the reader can open it, and a poisoned document is one somebody in this team put in this corpus.
- The ambiguity rule fails safe against poisoning. A planted second number does not replace the real one and does not outrank it — it makes the box show both, or show nothing if the whole citation line collides, or vanish past the cap. The attack that works against a single-answer box degrades into a visibly contradictory page, which is a property worth keeping rather than an accident to optimise away. Narrowing (above, 2026-09-29) weakens this in one case. On a page already over the cap, a planted label that alone carries the reader's word becomes the only row. The "Matched … N other …" line is what keeps the other candidates visible. That section states this rather than leaving it to be found.
Sequencing
Slice 0, entirely. Recognisers, the lexicon, the label rule, the box and gates 35–37 need no sibling and no model. Gate 34 needs a custody fake that rules per item, which fixedCustody is not — it returns one disposition for every candidate from a single field — so the gate lands with a fake extended to answer per item id, in internal/memoria at store level, which is where its "assert the struct, not the page" demand is natural anyway. Drafts 1 and 2 both said this gate could run against "memoriatest's sibling stub, which exists"; there is no such stub, and that is the renamed-test failure §10 opens with, committed twice in the section that cites it.
8.9 The model half: metadata deterministic analysis cannot reach
Paul's ruling, 2026-09-19, amending decision 15: "do as much as possible with deterministic analysis, and then attempt to build metadata with the best value models via TensorX that make sense."
The question put to him was narrow — should a postal address, which has no checksum and therefore no validator, be extractable by a model? The answer he gave is general: the deterministic layer is the floor and not the ceiling, and where it cannot reach, a best-value TensorX model may. Everything below that sentence is the spec author's proposal for how, and decision 15's row says which parts are his and which are not.
Draft 1 of this section overclaimed, and a review by Codex GPT-6-Astra (2026-09-27) is why it no longer does. It said "the model proposes, a string comparison disposes" and that a hallucinated address "cannot reach the page". The check it described proves where a value's bytes came from. It does not prove the value is an address, that it is the whole address, that it is the relevant one, or that the model returned every address it was shown. Those are four model judgements, and this section now says so instead of implying the check makes them.
Which kinds, decided by the kind and never by the outcome
Eligibility is a property of the kind. A fixed allowlist of model kinds — today exactly one, address — may be sent to a model. A validator-backed kind never is, whatever the deterministic layer did with it: produced candidates, produced none, or produced some that the cap, a collision or an over-long label then suppressed (§8.8). Draft 1 said the model runs "where the deterministic layer produced nothing", which invites treating an empty box as nothing; the box is empty for five different reasons in §8.8, and none of them is licence to ask a model for a phone number.
Kinds are independent. "Phone number and postal address" is two requests: the phone goes to §8.8, the address to this section, and a validated phone does not suppress the address request or the reverse.
"No model is called" is a claim about this metadata path, per kind. §8.2's router runs after the box and may call a model for an answer regardless; §8.8 says so and this does not change it.
With AI off, nothing in this section happens and §8.8 is unchanged. Decision 5's floor is the search page and the deterministic box.
Built ahead of the rest of slice 2, on feat/model-fallback (2026-09-27): the provider client, the MEMORIA_AI switch, this section's extraction path and gate 39 — and nothing of the embedding half. §11 sequences provider calls into slice 2; this is the first of them to land. Draft 1 said gate 31 enforced the sequencing; it did not — gate 31 forbids logging a provider's response body and says nothing about when a provider may be called. What keeps content in the instance today is the switch: with MEMORIA_AI unset no extractor is wired and gate 39 asserts, with its presence half, that no request is made.
What is sent
Of the top eight ruled results, those still present, by rank — the passages §4.1 allowed in this request, bounded at eight to match §8.6's answer-time budget. Bounded by rank first and then thinned, never refilled: a chunk deleted between the ruling and the read makes the payload smaller; it does not let result nine in. (The first build counted survivors instead, and a review deleted result one and watched 2-9 go out.) Not the twenty-five §8.8 scans: an address in result eleven is a recall limit this section accepts in exchange for a bounded payload and a response inside the deadline. Each passage is sent with an index, and nothing else about it — no title, no path, no id.
Per request, and that includes live search. GET /search renders the results region on each pause in typing and runs this path like the full page does, so a query that asks for an address sends passages on every debounced keystroke that reaches the server. The request context bounds it — search.js aborts a superseded fetch and the call is cancelled with it — but it is still a request per pause, and the README says so.
Egress is the existing category with a new trigger, and §3.2 now says both halves: the same recipient and the same kind of transmission as the answer path, but sent when a query names a model kind whether or not a generated answer would follow. The README names it now, because unlike the answer path it exists: it says what is sent, to whom, when, and that live searches count. §3.6 is the full assistant's paragraph and records why it is not yet the README's; §8.3 names this trigger beside embeddings and answers.
If decision 16 lands — TensorX as zero-data-retention, treated as equivalent to local inference, proposed on design/zdr-treat-as-local — the retention wording here and in §3.2 aligns with it. The transmission disclosure does not go: decision 16 changes what is retained, not what is sent, and it says so itself.
What comes back, and what memoria checks
The response contract asks for every postal address in the sent passages, as a list of {passage: <index>, text: <exact bytes>}, and nothing else. A response the provider marks truncated is rejected whole, not partially used; a malformed one likewise.
Each proposal becomes a coordinate or is dropped. For every returned {passage, text}:
passagemust name a passage that was sent. A reference to a passage that was not sent — including an authorised result ranked ninth, which was never in the request — is dropped.textmust be non-empty, at least 12 bytes, and contain a digit, a comma or a line break — structural, not a validator, and there to stopDublinor1passing because they occur somewhere.textis located in that passage by exact byte search — no normalisation of case, whitespace, quotes or Unicode form. NBSP versus space, curly versus straight quotes, a line break flattened to a space: each is a refusal, deliberately. A span crossing two passages is a refusal. These are real recall limits and they are chosen over any mapping back to source bytes, which would be a second parser (§8.7).- The first occurrence in that passage, by byte offset, is the one — deterministic, and never the provider's return order or a map's.
- The occurrence must begin and end on a word boundary and must not lie inside a span §8.8's recognisers found — a street name inside an email address is not an address.
- The candidate is now the immutable coordinate (passage, start byte, end byte). The span displayed is sliced from the document at it; the label is derived by §8.8's five rules at
start; the citation is that passage. The model's own string is discarded — its only role was to be found.
Identity is the byte-exact span. An address has no canonical form, so two spellings are two candidates; the same bytes in two passages are one, cited to the better-ranked passage, as §8.8 does.
Cap, collision and over-long labels then apply to the accepted proposals, per §8.8, with the same results: four accepted addresses render nothing, a colliding citation line renders nothing, an over-long label silences the kind.
What does not survive, stated rather than implied
Draft 1 said every §8.8 guarantee survives. Three do not, and a reader of a model row has to be told:
- "Never picks" does not hold. §8.8 scans every recognised candidate in its result set; this section sees only what the model chose to return. If the passages hold four addresses and the model returns one, every check passes and the box shows one address as though it were the only one. That omission is undetectable from inside memoria. The exhaustive contract and the truncation refusal reduce it; nothing removes it.
- The cap and collision rules cannot fire on what was never returned. An omitted fourth address means a box of three where §8.8 would have shown none.
- §8.8's poisoning argument weakens. A planted second phone number makes a deterministic box show both or vanish — except on a page over the cap where the planted label alone carries the reader's word, which §8.8's narrowing (2026-09-29) turns into a single row with a line counting the others. A planted instruction in a passage can make the model omit the real address instead — the attack §8.8 says degrades into a visible contradiction degrades here into a silent omission.
So a model row never inherits the deterministic layer's claim. It is marked 🤖 and names both the provider and the model — "TensorX · deepseek/deepseek-v4-flash-0731" — and is visually distinct from a row a checksum vouched for. Both, because decision 4 names the provider: a row naming only the model names DeepSeek's weights and never the company that received the passages. (Draft 2 said "names the model" while citing the requirement to name the provider; a review caught the substitution.) A reader must be able to tell "validated" from "a model pointed here, and the text really is in the document" at a glance.
What the checks do guarantee, narrowly: every accepted value is a byte-exact span of a passage that passed §4.1 in this request and was sent; its label is the document's; its citation opens on it. Its classification, completeness and relevance are the model's judgement.
Model and tier
model.cheap (§8.2, §8.3 — deepseek/deepseek-v4-flash-0731 at the time of writing) is the chosen initial tier, not a consequence. Paul asked for "best value models"; extraction from eight passages is the cheap tier's work under §8.2. A better model would extract better, and draft 1's claim that a string-checked task "does not improve with a better model" was wrong. Re-measure against labelled cases before treating the tier as settled.
Deadline: 800 ms, §6.5's sibling deadline reused. The call rides the request's context, so an abandoned keystroke on the fragment route cancels it. Past the deadline, or on any provider error, the box renders without model rows and the page is otherwise unchanged. Nothing from the request or the response is logged — status and a fixed string only (gates 4, 31).
Four surfaces the first build missed
Found by the final pre-merge review (Codex GPT-6-Astra, 2026-09-27), each reproduced before it was fixed, and each a place content could leave or a claim could be false outside the path the section above describes:
- Go's own transport logger. With keep-alives on, bytes a provider sends on an idle pooled connection are reported by
net/httpwith the standard library'slog.Printf, quoting them — past every sanitised error path. The client disables keep-alives; a fresh handshake per call is the cost, inside the deadline. - The footer on every page. It said "no document ever leaves this instance" while a search had just sent passages. It now states the switch: with AI on it names TensorX and the trigger; with AI off it makes the strong claim, which is then true. A gate walks every declared GET route and checks each footer at whatever status it renders; a route that renders no footer must be on a list with its reason, or the walk fails, and each listed reason was verified rather than assumed. The pages that exist only when Home is configured are checked by name through the Home fixture — eight of them, including four
/auth/failure pages that render the footer at 400. That is the coverage; it is not "every page in every configuration", and a new Home page has to be added there by hand. (The first two versions of this gate missed, in turn, every non-200 page and then the four/auth/failure pages — each found by review.) - Processing time. The deadline covered the call and not the work on its response; a legal-sized response of repeated proposals took 6.8 s to validate. One deadline now covers both, identical proposals are validated once, and the recognisers run once per passage.
- Native search.
POST /api/native/searchbuilt the box — so called the model — and its response has no field for facts: egress for an answer the client never received. It now searches without the box, and moves back when the native client can show attributed facts.
Injection
§8.5 is unchanged and its mitigation is the same partial one, with the omission case above added to it. The checks defend against invented bytes; they defend against neither the corpus lying nor the model being steered into silence.
What "that make sense" leaves open, and it is Paul's
Which metadata is worth a query-time model call. Postal address is ruled in. Opening hours, a person's role, an effective date, a registration number in a format memoria does not know are plausible and unruled. The proposed test is "is the answer a verbatim span somebody would want cited" — anything needing summary is an answer, not metadata, and §8.2 owns answers. §13 records it.
And the deterministic half of Address is not built yet, which is the next step Paul's rule asks for. A validated eircode certifies the address block it ends; extracting that block deterministically would serve most Irish addresses with no model at all. §12 carries it.
Gate
39. The model half sends only what it may, accepts only what it can locate, and never touches a validator-backed kind (§8.9). Against a stub provider that records every request body and returns scripted responses. Every case asserts on the recorded requests as well as on the page, because draft 1's version could not see an egress that had already happened by the time the box rendered nothing.
- Presence. A scripted span that is in a sent passage renders, as the full tuple
(span, title, trail, label, DocumentID, Ordinal), marked 🤖 with the model named — and the recorded request contains exactly the top eight ruled passages. - A span in no passage → no row; the stub confirms the response was delivered, so "no row" is a refusal and not a failed call.
- A span in an authorised result ranked ninth, which was never sent → no row. A check run against all authorised results rather than the sent set passes every other fixture.
- A withheld document, with a visible control in the same search: the recorded request contains the visible passage and not the withheld one, and a span present only in the withheld one renders nothing.
- The same text in a visible and a withheld passage → one row, cited to the visible passage, every tuple field checked.
- Two occurrences in one passage with different labels → the first by byte offset, both label and citation.
- Normalisation refusals: NBSP, curly quotes, a flattened line break — each returned in the "wrong" form → no row.
- Short and embedded spans:
Dublin, the empty string, and a street-name fragment inside a recognised email → no row. - A truncated response → no model rows at all.
- Validator-backed kinds never reach the provider, counted at the stub by purpose: zero valid candidates, candidates suppressed by the cap, by a collision, by an over-long label, and a mixed
phone number and postal addressquery — which must send exactly one request, for the address. - AI off → zero requests for every fixture above; with a presence half, the same query with AI on does send one.
- A provider error and a timeout → the page renders without model rows, and nothing from either is in the log.
- Four addresses in the passages, one returned → one row renders. This is the undetectable omission, asserted as the documented behaviour so that it is a known limit and not a surprise; the day something can detect it, this case goes red on purpose.
Draft 1 listed "render the model's string instead of the source slice" as a mutation this gate catches. It cannot, and it does not matter: for an exact match the bytes are identical. What matters is the coordinate — which occurrence, which passage, which citation — and the tuple assertions above are what see a wrong one.
---
9. Surfaces
9.1 What memoria exposes
Draft 1's §9 specified POST /ingest, DELETE /ingest/{doc} and GET /feed?since= with per-consumer bearer tokens — the inbound push surface decision 7 forbids and §6.1 argues against. Both reviewers caught the contradiction; it was carried over from an earlier pass that predated the pull-only ruling.
memoria exposes exactly two surfaces:
- A person-facing web surface, authenticated by home assertion, scriptless-first.
GET /query?q=renders passages;&answer=1adds the model layer.qcapped at 1 KiB and never logged. - A connector ingest surface for memoria-owned content only (§4.2) — external connectors and manual upload. Per-connector bearer token stored hashed, origin allowlisted, body cap 1 MiB with the reason measured before the constant is committed. This is not a sibling path, and a sibling presenting a connector token is refused: the two credential sets are disjoint and separately scoped.
What the app actually serves. Gate 17 is implemented, as of 2026-09-05 — TestNoUndeclaredRoutes walks the router's registrations and fails any this list does not name. main's copy of this paragraph said it was specified and not implemented, which was true of that branch and stopped being true on merge. The list stays written down anyway, because a gate read from a list that matches the app is worth more than one read from memory:
GET /, GET /search, GET /documents/{id}, POST /documents, GET and POST /documents/{id}/delete (the GET asks before the POST deletes), the connector routes under /connectors/…, public GET /help/open-source, POST /appearance (the shared chrome's Appearance cookie: inside CSRF, outside the session gate), public static GET /static/credits.css, and the auth routes (including GET /auth/landed, where a verified link lands on the way back to the page the person asked for).
Two of those need saying out loud. GET /query?q= above is not served and never has been — the search page is GET /?q=, and the paragraph above has specified a path the app does not have since draft 1. GET /search is new on 2026-09-05: it renders the results region alone, from the same template GET /?q= renders it inside a page, so that live search can replace that region on a keystroke. It is a narrower surface than the page, under the same session gate, and it adds no passage the page did not already speak. It is not a second query surface and not a step toward one; delete the script and nothing calls it.
A hand-off whose sources include memoria-owned material is refused, and that is a real product cost. Suite §7 lets a person hand an artefact to another app through their own session — "save this answer as a Doc" — but it requires the receiver to check every source with its app of record. For a passage from Docs or Sheets that works. For memoria's own uploads, Notion pages and forwarded mail (§4.2), memoria is the app of record and serves nobody to ask, because decision 7 gives it no sibling-facing surface. So the receiver cannot check, and the hand-off cannot complete.
Refused until a decision opens that surface, rather than opened here by implication. Opening it means a new decision with its own §3.2 leak entry and its own gate, exactly as §12 requires of any consumer of memoria — and decision 7 is settled, so widening it is Paul's and not a corollary. The cost is concrete and belongs in front of him: an answer built on the team's own uploads cannot be saved into Docs, while one built purely on sibling passages can. That asymmetry will look like a bug to a reader long before it looks like a ruling. (Raised by GPT-6's review of the doctrine, finding 3, via A.4.)
No feed. Nothing consumes memoria. If something ever does, that is a new decision with its own leak-list entry, not an endpoint left lying around for it.
MCP is deferred as a thin adapter over the query surface. Trigger: the first consumer that speaks it.
9.2 The home links; it never renders
Home §2.2 forbids the home rendering content from an instance, and a generated answer is content; it also forbids it holding an authorization model, which choosing whose answer to show would be. memoria is per-team, so "the home knows everything about all the apps" across teams is federation.
The home gets one directory row (app = memoria) and a search box that is a GET form redirecting to the team's memoria, call-6 style. memoria renders on its own origin. The home never sees q.
---
10. Testing gates
Two rules govern this list, and §1.4 is why. Three of the first five gates built could not fail for the thing they named.
- After writing a gate, break the thing it guards, watch it go red, revert — per path, not per file, and the test comment records what was mutated and when. Every gate below states its mutation.
- A gate asserting an absence must first assert the presence of the thing whose absence it checks. Gate 4 is the worked example: "no sign-in link in the logs" was true because the test app never sent one. An assertion that could not have been produced is not an assertion that passed, exactly as a count that fails is not a count of zero.
The gates:
- Plaintext grep over raw SQLite bytes and bucket objects, from the first commit.
plaintextExemptstarts empty. - Inversion probe — the gate the byte grep cannot see, with the threshold draft 3 promised and never gave.
First, the presence assertion, which draft 4 omitted and without which this gate is green on slice 0 for exactly the reason gate 4 was green. Slice 0 has no embeddings (§11), so an absence-only version of this gate passes against a store containing no vectors at all, and would keep passing on the day vectors arrive unsealed if the seeding silently failed. So: seed a 200-chunk corpus of known text, then read the sealed vector blob back through seal.Open and assert it decrypts to 200 vectors of the configured dimension, with the sentinel chunk's vector matching the one the stub embedder returned. Only then is the absence check below an assertion rather than a description of an empty world.
Then the absence check, in two forms. Take the bytes at rest — the SQLite file, its WAL, and every bucket object — and:
- parse them as contiguous matrices at each plausible dimension (256/512/768/1024/1536) under each plausible dtype (int8, f32, f64, both endiannesses). For every parse yielding a well-formed matrix, compute its pairwise cosine matrix and correlate that against the true corpus's. Fail if any parse correlates above r = 0.20 — far below what a real embedding scores against itself, far above what noise reaches over 200 chunks; - grep for the sentinel vector, the way TestPlaintextNeverReachesTheDatabase greps for a canary string. One seeded chunk gets a fixed, distinctive vector from the stub embedder; the gate searches the at-rest bytes for its leading components in each dtype and endianness and for their decimal text renderings. This is what catches the encodings the matrix parse cannot see — a JSON array, a length-prefixed or row-framed layout, one vector per row with a header between them — because none of those presents as a contiguous matrix, and all of them are shapes a store might plausibly grow.
Mutation: write the vectors unsealed and both halves go red — the correlation at r ≈ 1.0, the grep on the sentinel. Break the seeding and the gate fails on the presence assertion rather than passing.
What it still does not guard, stated rather than implied: an encoding that stores neither a contiguous matrix nor any sentinel component recognisably — a quantisation or transform applied before writing, say — passes both halves. The claim is therefore "nothing at rest is a vector in a form anyone would write one in", not "nothing at rest is derivable from a vector". The stronger probe, running an actual inversion model against the at-rest bytes, needs weights this binary cannot carry and is deferred with that reason in §13.
- Feed rows carry no content and no visibility flag — grep extended over S1 rows, asserting the absence of the field draft 1 had.
- No content in logs, covering query text, answer text, cited snippets and any model-call ledger — and its credential half must first prove a credential was sent.
TestNoContentInLogsasserts that no sign-in link reaches the logs while the test app wires no auth and therefore never sends one, so that half passes on an empty world. It must instead build the app with a mail sink, drive the begin route with a real address, assert (a) the sink captured a link, and only then assert (b) that link's token appears in no log line. (a) is what gives (b) the ability to fail. Mutation: log the link and (b) goes red; disable the mailer and the gate fails on (a) rather than passing.
The routes, read out of the code rather than remembered, because a gate naming a route the app does not serve is the renamed-test failure §10 opens with. internal/memoria/app.go registers GET /signin (the form), POST /auth/begin (the submit, a.auth.Begin) and GET /auth/verify (the landing, a.auth.Verify); internal/memoria/auth.go sets SigninPath: "/signin". So the gate posts to /auth/begin, and the token it then hunts for is the one in the /auth/verify?… link the sink captured. The existing TestNoContentInLogs greps for the literals "sign-in link" and "auth/verify?", which is the right target and the wrong method: it asserts nothing was logged without ever proving one was sent, and greps for a route spelling rather than for the captured token, so a mailer that logged the token alone would slip through. Both halves move to the captured link.
- Two people get different answers. End-to-end, two assertions, one sibling holding a document granted to one of them: the passage renders for one person and not the other, and the sibling's own grants join is what decides. Draft 2's version asserted only that an authorization call happened, which the draft-2 review correctly called weaker than the claim — it stays green with a constant
VISIBLE, with the wrong person ref, or with authorization performed after disclosure. - Every S2 result is handled, and the decoder is total. All five values get their specified behaviour,
AHEADincluded and named because draft 4 added the value and left it out of the gate that proves the decoder covers everything:VISIBLErenders,NOT_VISIBLEomits silently,STALEwithholds loudly and refetches,AHEADwithholds loudly, marks the source for re-announce and deletes nothing,UNKNOWNwithholds loudly. And the total-decoder half: a timeout, non-2xx, malformed body, missing item, duplicate item, short response, or any result value the client does not recognise maps toUNKNOWNrather than falling through to omission. Mutation: add a sixth valuePERHAPSto the fixture's responses and the gate must go red on the unrecognised-value case, not green by ignoring it; dropAHEAD's arm and theAHEADcase falls to the default, which must also be red. - An enumeration diff distinguishes empty from failed, and deletes nothing on a timeout, credential error or partial page.
- Circuit breaker, at §8.1's predicate — five named cases, and three of them are cases where it must stay silent.
- Fires. Three people each had VISIBLE from Sheets in the baseline week; in the window each queries Sheets and receives only NOT_VISIBLE. It fires, states the anomaly, appears on the owner's sources page and not on any reader's results page, and purges nothing. - Silent: three Docs-only members. A team where one person uses Sheets and three do not. The three query 50 times each — four pages, 200 pairs, comfortably over every threshold draft 4 had — Sheets answers wholesale NOT_VISIBLE, and the Sheets user does not query that day. The breaker stays silent, because none of the three is in the baseline set. This is the case draft 4's predicate got wrong, and it is the mutation target below. - Silent: a 24-hour outage. Sheets answers UNKNOWN to everything for a full window, including to baseline askers. Zero VISIBLE, no NOT_VISIBLE. The refusal breaker does not fire; §6.5's partial-page notice reports the outage instead. - Silent: below the asker floor. Two baseline askers, refused all window. Nothing fires. - Cannot determine. Delete the durable counters, or start the app with fewer than 7 days of history, and the sources page must say it cannot determine the breaker's state for that sibling, naming which of window and baseline it lacks. Assert the string. It must not say "no anomaly", and it must not say nothing.
Survives a restart: write counters, stop the app, start it, and the breaker still sees the baseline — which is the assertion that they are durable rather than in-memory, and memoria hibernates.
The S3 breaker gets the same five-case treatment at its own numbers (20 fetches, all 404, one prior success), and the AHEAD breaker is covered by gate 27.
Mutations, and each is per path. (i) Replace the per-asker transition with draft 4's "zero VISIBLE in the window" and both the Docs-only case and the outage case go red. (ii) Count VISIBLE absence instead of NOT_VISIBLE presence and the outage case goes red alone. (iii) Let the baseline overlap the window and the fires-case goes red, because every firing asker is then their own baseline. (iv) Return "no anomaly" when the counters are missing and the cannot-determine case goes red.
- Derived artefacts expand to full provenance, and one
NOT_VISIBLEsource withholds the artefact — including at two hops. An artefact built from an artefact built from a sibling document must send that document's(id, revision)to S2, and must be withheld when the sibling refuses it. Assert also that norootsentry is an artefact id. Mutation: stop flattening at generation time and the two-hop case renders, which is the laundering §5.2 forbids.
And the memoria-owned revision case (§5.2 steps 1 and 3), which is where draft 4's id-only roots failed: generate an artefact from revision 1 of an uploaded document, re-upload the document as revision 2, and assert the artefact is not served — it is regenerated, and until it is, the reader gets the slot table rather than a stale summary. Assert too that its roots entry recorded a revision at all. Mutation: record memoria-owned roots as bare ids and this case goes red while every sibling case stays green, which is exactly how it survived draft 4.
- Deletion survives a restore — a tombstone applied, a snapshot superseded and retired, then a restore from the prior generation must not serve the deleted passage. This is memoria's restore; the mirror case, a sibling restored behind memoria, is gate 27.
- Cross-team dial refusal, including redirects.
- Token rotation — all three phases, each interrupted and resumed, across a partial multi-sibling delivery.
- Two-consumer isolation on the sibling endpoints.
- Answer sentences cite listed passages; an all-dropped answer says so rather than rendering empty.
- AI-off floor — every affordance in §8.1 works scriptless with the provider unreachable, and no egress occurs, asserted by a network guard rather than by inspection.
- A sibling credential is refused at the connector routes, and a connector credential at nothing else. §9.1 asserts the two sets are disjoint; nothing tested it.
- No sibling-facing endpoint exists. Enumerate memoria's routes and fail any inbound route not on the person-facing or connector list — the regression gate for the push surface draft 1 shipped by accident. The list is literal, not descriptive:
GET /,GET /documents/{id},POST /documents,GETandPOST /documents/{id}/delete, publicGET /help/open-source,POST /appearance, public staticGET /static/credits.css, plus the auth routes. Written that way so that adding a thread or chat route goes red on the way in (decision 14) rather than being argued about afterwards. Built 2026-09-04 asTestNoUndeclaredRoutes; rewritten 2026-09-05 after a review mutation-tested it and found the first version blessing five idioms. It keyed on path alone, sor.Route("/chat", …)passed on the declared"/"inside it,r.With(mw).Postwas invisible because the receiver is a call rather than an identifier,r.Deletepassed on a path declared only for GET,r.Handle("/")bound every method at once, and — worst —mux.HandleFunc("/documents", …)served a declared path while bypassingrequireSession. It now keys on method and path together, which is what this list already declared, and keeps the chi andServeMuxnamespaces apart. Eight mutations, each verified red and reverted, are named in the test's own comment. It reads the AST, not a grep. What it still does not catch: a route registered through a helper outsideinternal/memoriaandcmd, or on a receiver renamed away fromr/mux. A note on how the rewrite nearly went wrong. Following an arbitrary selector chain to find the receiver mader.URL.Query().Get("err")resolve tor, and the gate reported two query parameters as undeclared GET routes.ris the conventional name for a request as well as a router. The chain now follows only chi's own chaining constructors — caught by running it, not by reading it. The declared list gained the five connector routes on 2026-09-06, when this gate caught them arriving from main — which is the gate working: five routes shipped and none could reach the app until somebody wrote them down. - External-source authorization and disclosure, against §4.2's two facts. Draft 4 said "governed by memoria's grants", which named a table §4.2 says does not exist; a gate written from that sentence would go looking for grant rows and find none. The two facts are the whole test, so the gate asserts both, each able to fail on its own:
- admission is the only door. Two admitted people both see a memoria-owned document — there is no per-person filter to distinguish them, and asserting that is asserting §4.2's team-wide-by-construction claim rather than trusting it. Then flip one person's only vouching sibling to NOT_MEMBER and, past the cache, they see nothing memoria-owned at all. - presence is the other. Delete the document in memoria and it is gone for everyone; revoke it upstream instead — disconnect the Notion integration, remove memoria's Drive access — and it is still served, which is the §3.6 README's promise stated as a test rather than as copy.
Mutation: add any per-person condition to the memoria-owned path and the first case goes red; make an upstream revocation remove the document and the second goes red and §3.6 becomes false, which is the pairing worth having.
- Mixed-provenance authorization — a derived artefact with one sibling source and one memoria-owned source requires both, spans multiple S2 batches, and withholds when either fails.
- The wake budget and the corpus ceiling, run against a real corpus rather than asserted, failing when the measured footprint at the stated chunk count exceeds the budget. §7's numbers were wrong twice with no gate to notice.
- Admission is read, not held. A person absent from the roster is refused, and an unreadable roster refuses rather than admits. Lone half built 2026-09-05 as
TestAdmissionRefusesANonOwnerAtVerification; suite half owed at slice 1, when admission becomes the home's roster (suite call 8) rather than the owners list. The built half asserts at the moment that decides, which is verification, not submission:rastrillo/auth'sadmitrunsConfig.Authorizeagainst the address it has just verified, after the magic link is followed. So the gate sends a link while the address is an owner and follows it through an app built with a different owners list — an owner removed between asking for a link and clicking it — and requires a 403 and no session cookie. Two-sided: the admitted case must reach a session, or "no session" is satisfied by a broken link, a mailer that never fired, or pending state that did not survive. This entry claimed until 2026-09-05 that "the fixtures change with the prose". There were no fixtures. Nothing ininternal/memoriatesthad ever asserted admission, so retiring S7 replaced nothing and the claim described work that had not happened. Falsified once, by the author, to prove it is not vacuous — makingAdmitsreturn true for everyone turns it red on the session-cookie assertion. No author-chosen mutation set is recorded, per §1.9: the adversarial pass belongs to someone who did not write it, and is owed. - Unit certification, and memoria is what certifies (§8.7). Five cases, and the first assertion in each is that S6 was or was not called, because §8.7's ruling is that the refusals happen before the call:
- three cells formatted €#,##0 → S6 called once, a number shown, and the number is S6's display string byte for byte; - €#,##0 against k€#,##0 → S6 is never called, MIXED_UNITS, no number, and the notice names both format codes; - three cells formatted General under a header carrying no unit word → S6 is never called, UNIT_UNKNOWN, no number, and the notice names the cells — the case draft 3 would have averaged; - a General cell under a header reading €k, with its co-operands certifying to €k too → S6 called, a number shown, the unit named beside it and attributed to the header rather than folded into the figure; - the re-certification arm: certify from the indexed format code, then have the S6 fixture return a different format_code for the same cell → no number, and the source marked for re-ingest.
Mutations: treat General as a unit and case 3 goes red; rescale k€ to € and case 2 goes red, which is the assertion that memoria does no unit algebra; re-format S6's value instead of passing its display through and cases 1 and 4 go red; move certification after the S6 call and every "S6 is never called" assertion goes red.
- The deadline and the concurrency (§6.5), on two fixtures, because one cannot hold both. Draft 4 asserted the overlap against the deadline fixture — one sibling at 5 s, two at 10 ms, an 800 ms deadline — and that assertion is unwritable. If the recorded latency is the client's, the slow sibling is cut off at 800 ms and 90% of the sum is 738 ms, which a concurrent client (≈800 ms) exceeds: always red. If it is the fixture's, the sum is 5.02 s, 90% of it is 4.5 s, and a strictly sequential client still finishes in ≈820 ms because it abandons the slow one at the deadline: never red. Same test, opposite vacuity, depending on which clock — which is why it needs its own fixture of comparable sub-deadline latencies.
- The deadline half, unchanged: three fixtures, one sleeping 5 s and two answering in 10 ms. The page renders in under 1.2 s, names the slow sibling, and shows the other two's passages. - The concurrency half, its own fixture: three siblings each answering in 300 ms, all comfortably inside the 800 ms deadline so none is cut off. Concurrent is ≈300 ms, sequential ≈900 ms, the sum is 900 ms and 90% of it is 810 ms. Fail if total wall time exceeds 810 ms. That bound is 2.7× the concurrent time and 0.9× the sequential one, so it separates the two by a wide margin at both ends rather than sitting on top of either.
Mutation: make the client sequential and the concurrency half goes red at ≈900 ms; remove the deadline and the deadline half goes red at ≈5 s. Each mutation must be checked against both fixtures, since the point of the split is that neither fixture can see both faults.
- The wake warm-up (§6.5). Bring the app up against three sibling fixtures, issue no query at all, and assert each fixture received an S1 poll within N ms of the app coming up — N a stated constant in the low seconds, not a race against anything.
Draft 4 ordered the poll against the first query, and that cannot be written either way. If the warm-up is asynchronous, "before the handler served a request" is a race between two goroutines and the gate is flaky. If it is synchronous, satisfying the assertion means the first query waits for three sibling wakes — which puts the latency back on the reader, and §6.5's entire reason for the warm-up is to spend it where nobody is waiting. Asserting unprompted presence avoids both: the claim is that the poll happens on its own, not that it wins a race.
Mutation: delete the warm-up and every fixture goes unpolled, red. Make the warm-up fire lazily on the first query and the gate goes red too, because no query is ever issued — which is the assertion draft 4's ordering could not make. This gate is also why the warm-up is S1 rather than draft 3's S7: an S7 warm-up carries a person ref that does not exist on a wake, so no fixture could ever have received it and no gate could have caught its absence.
- S3 carries no person, asserted end to end rather than by spelling. A fixture sibling holds an item granted individually to the asker and not team-visible. It must 404, that item must never appear in memoria's store, and the fixture must record that no S3 request it received carried any field it did not expect — the fixture decodes S3 into its own struct with unknown fields rejected, which is a total check where a name list is not.
The meaningful mutation, and it takes two edits: add a person ref to memoria's S3 call and make the fixture honour it by serving the individually-granted item. Then the 404 assertion and the store assertion both go red. Either edit alone proves nothing — a person ref the fixture ignores changes no behaviour, which is why a field-name check on its own is not a guard.
Draft 4's field-name regex — person|user|asker|actor|sub — is demoted to a lint, not a gate. It is the shape §10 opens with: principal, who, ref, identity, on_behalf_of and viewer all sail straight through it, and sub false-positives on subject and submitted_at. It stays as a cheap advisory because a matching field name is worth a second look; nothing rests on it.
external_idis keyed, and reverting it is caught. The same body ingested twice yields the same id; the same body under a second DEK yields a different id; andsha256(body), in hex and in base64, appears nowhere in the row or the bucket object. Mutation: revert to a bare SHA-256 and the second and third assertions go red. Draft 3's fix shipped with no gate at all, so reverting it left every test green.
The key is the DEK, and draft 4 said "instance key". Checked against the code: internal/memoria/store.go fingerprints via s.sealer.Fingerprint(seal.PurposeExternal, …), and the Sealer holds the DEK — seal.New(dek), HKDF over s.dek per purpose. The instance key is a different value with a different job: internal/memoria/auth.go uses InstanceKey to seal auth's pending blob, and it never reaches seal. So a gate written from draft 4's wording would rotate MEMORIA_INSTANCE_KEY, see the id unchanged — because it correctly is — and go red on correct code, which is worse than the missing gate it replaced. Rotate MEMORIA_DEK.
- A sibling's restore reconciles, and the page does not go quietly short (§4.1). Gate 10 covers memoria's own restore; this is the mirror case, and draft 4 shipped
AHEADwith nothing holding what happens after it.
Fixture: memoria holds 60 chunks from Sheets and 40 from Docs. Sheets is restored behind memoria and now (a) holds revision 5 of an item memoria holds at 7, (b) holds no row at all for a second item, (c) answers normally for the rest.
- AHEAD for (a); NOT_VISIBLE for (b). The no-row case is NOT_VISIBLE and must be byte-identical to a private item's answer — suite gate 3 — or S2 becomes an id-existence oracle. Draft 4 had (b) as AHEAD and §4.1 records what overruling it costs. Assert the indistinguishability, not just the value: ask about (b) and about a known-private item in the same call and require the two results to be equal, because asserting NOT_VISIBLE alone passes on an implementation that answers it for different reasons. - Nothing is deleted, and the source is marked for re-announce. - The empty-page assertion, which is the point of the gate. Run a query matching all 100 chunks with a page size of 50. The page renders 50 results, all from Docs, plus a line naming Sheets as reconciling. Assert the count, exactly. Draft 4's behaviour — rank the Sheets chunks, then withhold them one at a time — fills the page with AHEAD chunks and renders fewer than 50 plus a pile of notices; run the same case with the Docs fixture removed and it