oficina / memoria Public

🤖 test: build gate 21's lone half, at the moment that actually decides

The A.4 review found gate 21 claiming its fixtures had been replaced when
the gate did not exist and there were never any fixtures — retiring S7
replaced nothing, so the entry described work that had not happened.

Built now for lone shape, and the placement is the point. rastrillo/auth
runs Config.Authorize inside `admit`, at VERIFICATION — after the magic
link is followed — not at submission. A gate on the submit path would miss
the only case that matters: someone sent a link while they were an owner,
and removed from the list before they clicked it. So the test sends under
one owners list and follows the captured link through an app built with
another, over the same database and instance key, and requires both a 403
and no session cookie.

Two-sided, per §1.7. The admitted case must actually reach a session, or
"no session was minted" is satisfied by a broken link, a mailer that never
fired, or pending state that did not survive the second construction —
three ways to be green while measuring nothing.

Falsified once to prove it is not vacuous: making Admits return true for
everyone turns it red, and it is the session-cookie assertion that catches
it rather than the status, which is why both are there.

No author-chosen mutation set is recorded. AGENTS.md now says the
adversarial pass belongs to someone who did not write the gate, and
decorating this with three mutations I already had in mind would be the
thing §1.7 was written to stop. It is owed, and §10 says so.

The suite half — admission from the home's roster via call 8 — waits for
slice 1, and §10 says that too rather than letting this read as covering
both shapes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H3VKtXnq6fSmM2F11Bjcsy
Paul Campbell pushed by paul@keymail.dev 234b25e4d260c395b68a0104a7facf5457582f13 parent 9cdc4d0
2 files changed, +203 −12
  • docs/superpowers/specs/2026-09-03-memoria-design.md +20 −12
  • internal/memoriatest/admission_test.go +183 −0
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 2ae876d..228717c 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -2059,18 +2059,26 @@ renders on its own origin. The home never sees `q`.
with no gate to notice.
21. **Admission is read, not held.** A person absent from the roster is
refused, and an unreadable roster refuses rather than admits.
- **NOT BUILT, and the honest version of that matters here.** This gate
- cited `S7` until 2026-09-05; retiring a call leaves a gate asserting
- against something that no longer exists, which is green forever for
- the worst reason. But nothing in `internal/memoriatest` ever asserted
- admission at all — there were no S7 fixtures to replace, and there are
- no roster fixtures now, so "the fixtures change with the prose" (which
- this entry said for one commit) described work that had not happened.
- **Owed, in two halves:** the *lone* half is buildable today against
- `Admits` — an address absent from `MEMORIA_OWNERS` is refused — and
- the *suite* half waits for the roster at slice 1. No mutation is named
- here, because per §1.7 an author-chosen mutation on a test that does
- not exist yet is twice removed from evidence.
+ **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.7: the
+ adversarial pass belongs to someone who did not write it, and is owed.
22. **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
diff --git a/internal/memoriatest/admission_test.go b/internal/memoriatest/admission_test.go
new file mode 100644
index 0000000..304100c
--- /dev/null
+++ b/internal/memoriatest/admission_test.go
@@ -0,0 +1,183 @@
+package memoriatest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "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.
+type capturingMailer struct {
+ mu sync.Mutex
+ body string
+}
+
+func (m *capturingMailer) Send(ctx context.Context, to, subject, body string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.body = body
+ return nil
+}
+
+var linkRe = regexp.MustCompile(`https?://[^\s<>"]*/auth/verify\?[^\s<>"]+`)
+
+func (m *capturingMailer) link() string {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return linkRe.FindString(m.body)
+}
+
+// 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, memoria.Admits(owners))
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ a.UseAuth(au, memoria.Admits(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.body)
+ }
+
+ 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.body)
+ }
+ 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")
+ }
+}