oficina / memoria Public

docs(spec): draft 3 — answer the draft-2 review, and Paul's numeric ask

GPT Sol (via Codex on Paul's subscription) reviewed draft 2: five fixes
confirmed answered, one blocker, six live findings. Draft 3 answers them.

The blocker is not mine to close, and §1.3 says so plainly. memoria pulls
plaintext bodies from siblings, and home §2.2, Sheets PRODUCT.md principle
3 and Docs §8.2 each forbid content crossing an instance boundary. Paul's
one-trust-domain ruling stands, but memoria cannot enact another app's
settled decision in its own table. Slice 1 is blocked on agreement, not
on code.

"Nothing left to reconcile" was still false: S2 fixed sibling content
while admission and memoria-owned content both used memoria's own members
table, so a person removed everywhere else could still read the team's
Notion, Drive, mail and uploads. Admission is now relayed from the
siblings (decision 11) — memoria holds no membership answer of its own.

A stale revision is not a denial. Draft 2 mapped it to NOT_VISIBLE, which
is silently omitted, recreating the exact failure it claimed to remove. S2
is now four-valued with STALE, and its decoder is total: every non-answer
becomes UNKNOWN rather than falling through to omission.

S3 promised a per-person grants join while taking no person. It carries
the person ref now.

Derived artefacts have an algorithm for external and mixed provenance;
authorities are ANDed. Restore has a durable tombstone log independent of
the database, and snapshots are not retired while a restorable backup
could reference them. §6.5 gives live authorization a deadline, a
concurrency policy and a partial-result contract — and states plainly that
a reliably slow sibling degrades the primary search path, with no design
that fixes it while failing closed.

§7's numbers are measured now, not asserted, and measuring found draft 2
wrong: the FTS figure was out by half, so the ceiling is ~90-110k chunks
rather than 150k. Draft 2 moved that number the wrong way while claiming
to have fixed the arithmetic.

§8.7 answers Paul's "average revenue in June the last 3 years". Fable and
GPT Sol converged: Sheets computes via an enumerated-function S6 call,
never expression text, so memoria is never a formula author; retrieval
resolves named slots and refuses to show a number unless every slot has
exactly one authorized operand. Both independently forbade the same
anti-design — a RAG calculator letting a model average numeric strings it
retrieved, which cites correctly while being wrong.

Gates rewritten: gate 5 now requires two people to get different answers
from the sibling's real grants join, because "an authorization call
happened" stays green with a constant VISIBLE.
Reviewed-by: gpt-5 (self-reported; Codex labelled it gpt-5.6-sol)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiWVaR69Vb2Lzc9wgfit1v
Paul Campbell pushed by paul@keymail.dev b256d88063bdf89cb8f30e43b187d2282f3dc2bc parent 6f6ecea
1 file changed, +410 −67
  • docs/superpowers/specs/2026-09-03-memoria-design.md +410 −67
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index fa611d6..8b5c6f4 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3,18 +3,23 @@
The Oficina suite's memory. One instance per team, at
`<team>.memoria.oficina.works`.
-**Status:** draft 2, 2026-09-03.
+**Status:** draft 3, 2026-09-03.
-Draft 1 was reviewed independently by **GPT Sol** (via commandcode) and
-by **Fable**, both against the same adversarial brief, and both returned
-**REQUEST CHANGES** — 12 and 21 findings, three structural gaps found by
-both. This draft answers all of them. §1.2 records what changed and why,
-because two corrections reverse a settled decision and a future reader is
-owed the reason.
+Review history, per AGENTS.md §Review. None of these models wrote the
+draft they reviewed; all drafts were written by Opus.
-Per AGENTS.md §Review, the reviewing models are recorded above; neither
-wrote draft 1, which was written by Opus. **This draft has not itself
-been reviewed** — that pass is owed before approval.
+| 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 | none yet | — |
+
+§1.2 records what the draft-1 reviews changed; §1.3 what the draft-2
+review changed. **Draft 3 has not been reviewed.**
+
+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.
---
@@ -59,7 +64,9 @@ embedding is a plaintext cell in a costume — §3.1.
| 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 |
-| 9 | **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 |
+| 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, not from a members table memoria maintains.** A person may enter memoria if at least one sibling says they are a member of this team. | Draft 2's own members table, which left a person removed everywhere else still able to read memoria-owned content | after the draft-2 review, 2026-09-03 |
+| 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 |
### 1.1 The proposal review, and what it killed
@@ -136,6 +143,62 @@ same contradiction between §9 and decision 7, and the same over-claim in
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:** S3 carries the person ref too
+(§6.1).
+
+**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.
+
---
## 2. What memoria is
@@ -332,6 +395,13 @@ draft-1 version was false about egress volume, which PRODUCT.md principle
> With the assistant switched off, nothing leaves this instance —
> memoria searches your content where it sits.
>
+> **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
@@ -352,18 +422,38 @@ Every path that would speak a passage originating in a sibling app calls
```
S2(person_ref, [(item_id, revision), …])
- → per item: VISIBLE | NOT_VISIBLE | UNKNOWN
+ → per item: VISIBLE | NOT_VISIBLE | STALE | UNKNOWN
```
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.
-**Three-valued, deliberately.** `UNKNOWN` is what a hibernating or broken
-sibling returns, and it is *not* `NOT_VISIBLE`. Collapsing them is how a
-suite produces "a plausible empty list, not an error" — the failure shape
-sheets-core AGENTS.md names as the worst available. `UNKNOWN` triggers
-loud withholding (§8.1); `NOT_VISIBLE` silently omits, correctly.
+**Four-valued, 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 |
+| `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.
+
+**`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. **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.
**This subsumes membership.** There is no separate "is P a member?"
question and no membership synchronisation, because a non-member's items
@@ -372,8 +462,8 @@ unreconciled tables of §1.2 are simply not consulted.
**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 `NOT_VISIBLE`, and
-memoria re-fetches on the next pull.
+revision behind the sibling's current one answers **`STALE`**, and memoria
+refetches rather than omitting.
**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
@@ -395,12 +485,39 @@ memoria*. §5.1's enumeration diff is a convenience that eventually
notices upstream deletions, not an authorization mechanism, and §3.6's
claim does not pretend otherwise.
-### 4.3 Using memoria at all
+### 4.3 Admission is derived from the siblings
A person reaches memoria with a home assertion whose `aud` is memoria's
-host, and memoria decides admission from its own grants (home §5.5).
-Admission is not authorization: an admitted person still sees only what
-§4.1 and §4.2 allow. No guests, no anonymous link viewers.
+host. **Whether they are admitted is asked of the siblings, not answered
+from a table memoria maintains** (decision 11).
+
+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 from the team in Sheets and Docs, and not removed in memoria,
+could still sign in and read the team's Notion pages, Drive files,
+forwarded mail and uploads. The membership drift decision 3 was written to
+eliminate survived in exactly the half nobody was looking at.
+
+**The rule: a person is admitted if at least one sibling answers that they
+are a member of this team.** That is one more call, `S7(person_ref) →
+MEMBER | NOT_MEMBER | UNKNOWN`, and it is cached for the life of a session
+rather than per request, because it changes on the timescale of employment
+rather than of editing.
+
+Three consequences worth stating:
+
+- **`UNKNOWN` from every sibling refuses the session.** Fail closed: an
+ unreachable suite means nobody new signs in, which is an outage, where
+ the alternative is admitting someone nobody can vouch for.
+- **A session already minted survives a sibling outage** for its TTL,
+ exactly as home §2.1 accepts for the suite as a whole.
+- **memoria still holds no authorization model.** It holds no answer of
+ its own — it relays one, and §4.1 still governs every passage after the
+ door.
+
+No guests, no anonymous link viewers. Admission is not authorization: an
+admitted person still sees only what §4.1 and §4.2 allow.
---
@@ -455,13 +572,33 @@ them stops resolving *or moves to a new revision*.** 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.**
-Speaking a summary means asking S2 about *every* source behind it, and
-**every one must return `VISIBLE`**. One `NOT_VISIBLE` withholds the
-artefact; one `UNKNOWN` withholds it loudly. Provenance larger than the
-batch cap spans several calls; all must pass. Without this a summary is a
+**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 pairs on one side,
+ memoria-owned ids on the other.
+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.
+3. **memoria-owned ids are checked against memoria's own grants** — which
+ for that content is the authority, not a copy (§4.2). Existence plus
+ team-visibility in memoria is the whole test.
+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.
+
**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,
@@ -505,10 +642,12 @@ 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 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. |
-| S2 | Authorization re-check | `(person_ref, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / UNKNOWN`. §4.1 specifies it. Cap 256 pairs. |
-| S3 | Content fetch | `(id, revision)` → plaintext body over TLS into memoria's RAM only. The sibling re-runs its grants join at fetch time and **404s, never 403s**. |
+| S2 | Authorization re-check | `(person_ref, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / STALE / UNKNOWN` per item. §4.1 specifies it, including the total decoder. Cap 256 pairs. |
+| S3 | Content fetch | **`(person_ref, id, revision)`** → plaintext body over TLS into memoria's RAM only. The sibling re-runs its grants join and **404s, never 403s**. |
| S4 | Tombstone | *a feed row.* `gone` covers deletion and departure from team-visibility, indistinguishably. |
| S5 | Consumer expiry | *no call.* TTL expiry is the decommission verb. |
+| S6 | Aggregate | `(person_ref, fn, [(sheet, tab, row, col, revision)…]) → {value, display, format_code}` or `STALE(cell)` / `NOT_VISIBLE` / `UNKNOWN` / `NON_NUMERIC(cell)` / `MIXED_UNITS`. §8.7. Cap 64 cells. Never cached. |
+| S7 | Membership | `(person_ref) → MEMBER / NOT_MEMBER / UNKNOWN`. §4.3's admission relay. Cached per session, not per request. |
**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
@@ -516,10 +655,22 @@ 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.** Draft 1 stated
-it unconditionally and it was false for retraction-by-edit. It holds now
-only because S2 carries the revision (§4.1): a stale chunk fails its
-re-check. The two clauses must travel together.
+**"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 carries the person, and draft 2's did not.** Draft 2's S3 took
+`(id, revision)` and promised the sibling "re-runs its grants join at
+fetch time" — with no asker in the call, which the draft-2 review named as
+objectively impossible. The alternative considered was an S2-minted
+capability that S3 consumes; rejected as a second credential type to
+issue, bind and expire, where passing the person ref costs nothing and
+keeps one authority. **Every sibling call that touches content carries the
+person it is for.**
### 6.2 Authentication, and rotation
@@ -589,6 +740,36 @@ Any write. 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.
+### 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 **fires S7 and a warm-up S2 on its own wake**,
+ before anyone has searched, to spend that latency where nobody is
+ waiting.
+- **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.
+
---
## 7. The index
@@ -644,32 +825,61 @@ one core of this machine, measured at **3.2 GFLOP/s** (pure Go, no SIMD):
| 250k × 512 | 256 M | 80 ms |
| 1M × 512 | 1.02 G | 320 ms |
-**FTS index size.** Measured at **104 MB** for 90k chunks — but on a
-synthetic corpus of random 4–9 letter words, whose vocabulary inflates a
-postings list relative to English. Real English will be smaller. **Taken
-as 104 MB because it is the only measurement in hand, and flagged as
-conservative rather than accurate.** Draft 1 asserted 60 MB with no
-derivation.
-
-**Rebuild vs snapshot.** Building that index took **19 s**. Loading a
-sealed snapshot does not. **Snapshot, never rebuild.**
+**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.
+
+**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: 46 MB vectors + 104 MB FTS = **150 MB**, ≈1.67 KB
-per chunk.
+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** therefore admits 250–500 MB, i.e. **150k–300k chunks**.
-Taking the conservative bandwidth: **~150k chunks**.
+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.
-**Draft 1 claimed ~100k and called it "the same ceiling the brute-force
-arithmetic gives, which is tidy". Both reviewers showed that was
-manufactured**, and they were right twice: 250 MB ÷ 1.67 KB is ~150k, not
-100k; and brute-force cosine is nowhere near binding at that size — §7.2
-shows 250k chunks answering in 80 ms.
+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
@@ -693,6 +903,21 @@ reviewer nor draft 1 caught.
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.
+- **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.
---
@@ -730,7 +955,7 @@ 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.** Cite the cell or range; Sheets is the app of record. |
+| 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. |
@@ -813,6 +1038,82 @@ invalidated artefact and is unbudgeted until the feature exists.
The money is recurring query tokens by an order of magnitude — the
economic argument for §8.2's router and its 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 (a write, forbidden by §6.4,
+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 with compatible units.** Any
+gap, ambiguity, `STALE` 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.
+
+**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.**
+
---
## 9. Surfaces
@@ -869,11 +1170,18 @@ renders on its own origin. The home never sees `q`.
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.
-5. **Every render path calls §4.1.** Enumerate the passage-rendering
- entry points and assert each one's authorization call — the gate that
- would have caught gap 2. A new render path with no S2 call fails.
-6. **`UNKNOWN` is never treated as `NOT_VISIBLE`.** A sibling returning
- `UNKNOWN` produces a stated withholding, never a shorter list.
+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.** `UNKNOWN`,
+ `STALE` and `NOT_VISIBLE` each produce their specified behaviour;
+ `STALE` withholds loudly rather than omitting; and a timeout, non-2xx,
+ malformed body, missing item, duplicate item or short response all map
+ to `UNKNOWN` rather than falling through to omission.
7. **An enumeration diff distinguishes empty from failed**, and deletes
nothing on a timeout, credential error or partial page.
8. **Circuit breaker** — a wholesale `NOT_VISIBLE` or 404 response states
@@ -892,6 +1200,26 @@ renders on its own origin. The home never sees `q`.
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.
+18. **External-source authorization and disclosure.** A memoria-owned
+ document is governed by memoria's grants, an upstream revocation does
+ *not* remove it, and the copy the README promises is deletable is
+ deletable.
+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 relayed, not held.** A person no sibling calls a member
+ is refused, and `UNKNOWN` from every sibling refuses rather than
+ admits.
---
@@ -944,13 +1272,28 @@ rows.
## 13. Open
-- **§Review pass on this draft.** Draft 1 was reviewed by GPT Sol and
- Fable; draft 2 answers them and has been reviewed by nobody.
+- **§Review pass on draft 3.** Drafts 1 and 2 were reviewed; this one has
+ not been.
+- **BLOCKER, and not mine to close: the source apps must adopt decision
+ 1.** home §2.2, Sheets PRODUCT.md principle 3 and Docs §8.2 each
+ currently forbid content crossing an instance boundary. Paul ruled the
+ suite is one trust domain, and memoria cannot enact that in its own
+ table. `oficina/docs` discussion 33 is open; Sheets and home need the
+ same. **Slice 1 is blocked on agreement, not on code.**
+- **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 size is measured on a synthetic corpus** whose random
- vocabulary inflates it. Re-measure on real English before §7.3's
- ceiling is trusted as anything but conservative.
+- **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.
- **OpenRouter's embeddings endpoint** is reported OpenAI-compatible at
`/api/v1/embeddings`, but the documentation page 404'd when checked. If
embeddings and chat both route through OpenRouter, **two parties see
@@ -961,10 +1304,10 @@ rows.
- **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).
-- **Chunking is unspecified**, and §8.2's numeric route depends on it: a
- spreadsheet is not prose, and a 400-token chunk of a grid may be
- meaningless. Sheets likely needs a per-tab or per-region strategy Docs
- does not.
+- **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