Disclosure before egress: the README says exactly what §8.9 sends
The README said there was no model call anywhere in the binary. This branch makes that false, and §11 says the model layer does not land before its disclosure copy does — suite §8 wants it in the first paragraph. It could not simply become §3.6's designed paragraph. That text describes the full assistant — embeddings, every document sent once at indexing — which is not built, and PRODUCT.md principle 1 makes overstating egress as much a defect as understating it. So the README now states exactly what this binary does with MEMORIA_AI=on: for a search that asks for a postal address its own checks cannot find, the top eight passages that search returned, already allowed for the reader, go to TensorX; only text found byte-for-byte is shown, marked 🤖 and named; nothing else is sent, and nothing at all for the five kinds memoria checks itself. With AI off, the original sentence stands unchanged. §3.6 now says it replaces the README paragraph the day embeddings land and not before, and how decision 16 would change the retention wording in both while leaving the transmission disclosure alone. tools/drive gets a stand-in model, because a 🤖 row is a visual state nobody sees unless something makes one. It behaves as a compliant model should, so drive shows the intended rendering; gate 39 is where the non-compliant ones are. Driven and read: "office address" renders the document's label, its exact bytes, 🤖 and the model id, citing "Head office › Visiting"; the eircode inside that same address renders as a validated row, without the marker. The two claims are distinguishable at a glance, which is the point of the marker. The seam is UseFakeModel, and it joins the production guard's list: it switches the model layer on without MEMORIA_AI, so in main it would bypass the owner's opt-in and, handed a real client, send content nobody agreed to send.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+84
−11
README.md+27 −11docs/superpowers/specs/2026-09-03-memoria-design.md+13 −0internal/memoria/model.go+10 −0internal/memoriatest/gates_test.go+4 −0tools/drive/main.go+30 −0
CI passed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 1.002s ok amadan.net/oficina/memoria/internal/memoria/chunk 0.005s ok amadan.net/oficina/memoria/internal/memoria/fact 0.363s ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index 0.034s ok amadan.net/oficina/memoria/internal/memoria/notion 0.021s ok amadan.net/oficina/memoria/internal/memoria/provider 0.864s ok amadan.net/oficina/memoria/internal/memoria/query 0.004s ok amadan.net/oficina/memoria/internal/memoria/seal 0.004s ok amadan.net/oficina/memoria/internal/memoria/sibling 3.021s ok amadan.net/oficina/memoria/internal/memoriatest 1.543s ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/README.md b/README.md| index e933a85..9eb5974 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -5,27 +5,43 @@ The Oficina suite's memory. One instance per team. |
| Ask a question, get an answer grounded in your team's own documents — |
| with a citation you can open, always. |
| -**Slice 0 is what exists today: a search engine, not an assistant.** It |
| -holds text you give it, seals it, and searches it. There is no model call |
| -anywhere in this binary, which means the honest version of the trust |
| -claim is unusually strong right now: |
| +**What exists today is a search engine, not an assistant**, and it has |
| +exactly one model call in it, **switched off unless you switch it on**. |
| +With it off — the default — the trust claim is as strong as it gets: |
| > memoria's passages and its index are sealed at rest under this team's |
| > own key, and **no document ever leaves this instance** — not at query |
| > time, and not at indexing time, because nothing is sent anywhere to be |
| > indexed. |
| -An earlier draft of that sentence said "nothing leaves this instance", |
| -which was false and a code review caught it. Signing in fetches |
| +**With `MEMORIA_AI=on`, one thing changes, and this is all of it.** When |
| +a search asks for a postal address — "office address", "postal address" — |
| +memoria first looks for one with its own checks. Addresses have no |
| +checksum, so those checks cannot find one. It then sends **the top eight |
| +passages that search returned** — passages you were already allowed to |
| +read, in this search — to **TensorX**, the one named provider, which |
| +points at any addresses in them. memoria shows only text it finds |
| +byte-for-byte in those passages, marks each such row 🤖 with the model's |
| +name, and never shows the model's own words. Nothing else is sent: not |
| +the rest of your documents, not your query's other results, and nothing |
| +at all for phone numbers, eircodes, IBANs, VAT numbers or email |
| +addresses, which memoria checks itself. TensorX can read what it |
| +receives; we rely on its retention terms and cannot verify them. |
| + |
| +An earlier draft of the first sentence said "nothing leaves this |
| +instance", which was false and a code review caught it. Signing in fetches |
| `https://<your address's domain>/.well-known/keymail` **before** anyone is |
| admitted, so an anonymous visitor can make the instance dial any domain |
| they type. No document leaves; a DNS name does. |
| -That changes the day the assistant arrives, and the design spec is |
| -explicit about how: embeddings come from a hosted API, so switching the |
| -assistant on sends **every document in the corpus** to a named provider |
| -once, at indexing time — not just the passages behind an answer. See |
| -§3.2 and §3.6 of the design. |
| +**The full assistant changes this again, by much more**, and the design |
| +spec is explicit about how: embeddings come from a hosted API, so |
| +switching it on will send **every document in the corpus** to the named |
| +provider once, at indexing time — not just the passages behind an answer. |
| +That is not built. When it is, this paragraph is replaced by §3.6 of the |
| +design, and not before: §3.6 describes egress this binary does not yet |
| +perform, and saying it early would overstate what leaves as surely as |
| +the old sentence here understated it. |
| ## Live |
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index fb90f18..fa93655 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -959,6 +959,19 @@ 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 |
diff --git a/internal/memoria/model.go b/internal/memoria/model.go| index 9109e52..44022de 100644 |
| --- a/internal/memoria/model.go |
| +++ b/internal/memoria/model.go |
| @@ -37,3 +37,13 @@ func WireModel(a *app, cfg Config) error { |
| a.logger.Info("model layer on", "provider", provider.Name, "model", provider.Model) |
| return nil |
| } |
| + |
| +// UseFakeModel wires a stand-in model, for tools/drive and tests. |
| +// |
| +// Named for what it is, and production never calls it — |
| +// TestProductionDoesNotUseTheTestConstructor fails if cmd/ does. It |
| +// matters more here than for the other seams: this one switches the |
| +// model layer on WITHOUT MEMORIA_AI, so in main it would bypass the |
| +// owner's opt-in (decision 4) — and, handed a real client, send content |
| +// the owner never agreed to send. |
| +func (a *app) UseFakeModel(e Extractor) { a.store.extractor = e } |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index ee7341f..69cd07b 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -476,6 +476,10 @@ func TestProductionDoesNotUseTheTestConstructor(t *testing.T) { |
| // removed — the guard that runs before the transport dials — |
| // and it would compile and serve perfectly well. |
| "NewForTest", |
| + // UseFakeModel switches the model layer on without MEMORIA_AI: |
| + // in main it bypasses the owner's opt-in, and handed a real |
| + // client it sends content the owner never agreed to send. |
| + "UseFakeModel", |
| } { |
| if strings.Contains(string(raw), seam) { |
| t.Errorf("%s calls the test seam %s; production must not reach it", p, seam) |
diff --git a/tools/drive/main.go b/tools/drive/main.go| index 4e999f5..974e107 100644 |
| --- a/tools/drive/main.go |
| +++ b/tools/drive/main.go |
| @@ -23,6 +23,7 @@ import ( |
| "net/http" |
| "os" |
| "path/filepath" |
| + "strings" |
| "time" |
| @@ -30,6 +31,7 @@ import ( |
| memoria "amadan.net/oficina/memoria/internal/memoria" |
| "amadan.net/oficina/memoria/internal/memoria/notion" |
| + "amadan.net/oficina/memoria/internal/memoria/provider" |
| "amadan.net/oficina/memoria/internal/memoria/seal" |
| "amadan.net/oficina/memoria/internal/memoria/sibling" |
| ) |
| @@ -76,6 +78,12 @@ func main() { |
| panic(err) |
| } |
| + // §8.9's model layer, with a stand-in for TensorX — drive has no key, |
| + // and a 🤖 row is a visual state nobody sees unless something makes |
| + // one. The stand-in is a COMPLIANT model, so what drive shows is the |
| + // intended rendering; gate 39 is where non-compliant ones are tested. |
| + app.UseFakeModel(driveModel{}) |
| + |
| // Seed enough to make every surface non-empty: results, a heading |
| // trail, a highlight, a document list with more than one row. |
| seedViaHTTP(mux, *addr) |
| @@ -114,6 +122,10 @@ func seedViaHTTP(h http.Handler, addr string) { |
| // This paragraph is index/rank_test.go's, quoted rather than |
| // invented — it came off the live instance. |
| {"Reception notes", "## Office Address\n\nTurn 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."}, |
| + // §8.9: an address, which no recogniser can validate, so only the |
| + // model layer can box it — rendered 🤖 and named, beside the |
| + // validated eircode inside it. Search "office address". |
| + {"Head office", "## Visiting\n\nOffice address: Unit 4, 12 Main Street, Dublin 2, D02 X285\n\nRing the bell marked Tito."}, |
| // A bulleted pair: the COLLISION case, where both labels fall to |
| // the same trail and the box must be ABSENT. An earlier draft of |
| // the plan called the paragraph above the collision case; it is |
| @@ -240,3 +252,21 @@ func seedSiblings(app interface { |
| fmt.Fprintln(os.Stderr, "seed siblings:", err) |
| } |
| } |
| + |
| +// driveModel stands in for TensorX so a 🤖 row can be looked at. It does |
| +// what a compliant model is asked to: every text after "address: " to the |
| +// end of its line, copied byte for byte, pointing at the passage it came |
| +// from. Nothing here dials anything. |
| +type driveModel struct{} |
| + |
| +func (driveModel) ExtractAddresses(_ context.Context, passages []string) ([]provider.Pointer, error) { |
| + var out []provider.Pointer |
| + for i, p := range passages { |
| + for _, line := range strings.Split(p, "\n") { |
| + if j := strings.Index(strings.ToLower(line), "address: "); j >= 0 { |
| + out = append(out, provider.Pointer{Passage: i, Text: strings.TrimSpace(line[j+len("address: "):])}) |
| + } |
| + } |
| + } |
| + return out, nil |
| +} |