oficina / memoria Public

Final review: four surfaces outside the path, all found by review

Reviewed by Codex GPT-6-Astra (dispatched: codex/gpt-6-astra; self-
reported "GPT-6 (Codex)"). REQUEST CHANGES, three blocking. Every one is
a place content could leave, or a claim could be false, OUTSIDE the code
path §8.9 describes — which is why the reviews aimed at that path missed
them, and why a whole-branch review was worth running.

**Go's own logger quoted provider bytes.** With keep-alives on, net/http
reads idle pooled connections and reports unsolicited bytes with the
standard library's log.Printf — "Unsolicited response received on idle
HTTP channel starting with %q" — straight past every sanitised error in
the provider and the store. Reproduced by the reviewer over real TLS.
Keep-alives are off; the test captures the STANDARD logger, since that
is the channel, and goes red with them back on. A fresh handshake per
call is the cost.

**The footer said no document leaves, on every page, while one had.**
I updated the README and never opened the footer, and an existing gate
required the now-false wording. It now states the switch: with AI on it
names TensorX, the trigger and live search; with AI off the strong claim
stands and is true. My first draft of the AI-on sentence ended "Nothing
else leaves this instance" — the exact overclaim the footer's gate
documents (sign-in dials the address's domain), dodging its substring
check only by wording. Caught before commit; it says "No other document"
now. The test walks every declared GET route, the routes gate's own
list lifted to package scope, so a page cannot skip the claim — and it
found one I would have listed wrongly: /help/open-source renders an
empty footer on purpose, which makes no claim and is fine. Mutations
that drop ModelOn from one page each fail naming that page.

**Processing a response was unbounded.** The deadline covered the call
and not the work on its answer: 1,000 identical proposals in a response
under the cap took 6.8 s, after the deadline and uncancellable. One
deadline now covers both, checked per proposal; identical proposals are
validated once; the recognisers run once per passage. My first fixture
for this was split small by the chunker and passed against the OLD code;
it is now one 60 KB chunk, and the old behaviour fails it at 823 ms,
deadline-bounded. An early return past the cap was removed rather than
kept: with caching, its absence could not be made slow enough to see.

**Native search called the model for nothing.** Its response has no
field for facts, so it paid the latency and caused the egress without
receiving the address. SearchWithoutFacts: same ruling, no box, no call;
tested through the real native route with the web search as the
presence half.

Also closed: TLS is verified, with both halves — an untrusted
certificate reaches nothing, a trusted one works, through a roots seam
tests alone can reach; the provider deadline must be DeadlineExceeded,
which the store logs, not Canceled, which it silences; every passage
sent is checked at the stub, not only the second; and after thinning, a
pointer's index is into the thinned list — the mutation that looked it
up in the unthinned one cited a deleted document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a8287b55326b314b2e2b4a53dd7937ed22d03d8c parent 1b01bed
17 files changed, +649 −88
  • docs/superpowers/specs/2026-09-03-memoria-design.md +25 −0
  • internal/memoria/credits.go +1 −1
  • internal/memoria/fact/fact.go +10 −1
  • internal/memoria/handlers.go +19 −13
  • internal/memoria/handlers_connector.go +2 −1
  • internal/memoria/home_render.go +1 −1
  • internal/memoria/model.go +5 −0
  • internal/memoria/modelfacts_test.go +94 −0
  • internal/memoria/native.go +4 −1
  • internal/memoria/native_test.go +40 −0
  • internal/memoria/provider/provider.go +23 −0
  • internal/memoria/provider/provider_test.go +112 −4
  • internal/memoria/store.go +66 −7
  • internal/memoria/templates/layout.html +12 −2
  • internal/memoriatest/footer_model_test.go +163 −0
  • internal/memoriatest/gates_test.go +6 −0
  • internal/memoriatest/routes_test.go +66 −57

CI failed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
--- FAIL: TestCreditsMatchProductionDependencies (0.04s)
    credits_inventory_test.go:36: production dependency graph unavailable exit status 1
FAIL
FAIL	amadan.net/oficina/memoria/internal/memoria	1.474s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.760s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.135s
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.186s
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
FAIL
make: *** [Makefile:62: test] Error 1

amadan: step test failed: exit status 2

amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 8580bd6..b57e8b5 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3644,6 +3644,31 @@ it. Past the deadline, or on any provider error, the box renders without
model rows and the page is otherwise unchanged. **Nothing from the request
or the response is logged** — status and a fixed string only (gates 4, 31).
+#### Four surfaces the first build missed
+
+Found by the final pre-merge review (Codex GPT-6-Astra, 2026-09-27), each
+reproduced before it was fixed, and each a place content could leave or a
+claim could be false **outside** the path the section above describes:
+
+- **Go's own transport logger.** With keep-alives on, bytes a provider
+ sends on an idle pooled connection are reported by `net/http` with the
+ standard library's `log.Printf`, quoting them — past every sanitised
+ error path. The client disables keep-alives; a fresh handshake per call
+ is the cost, inside the deadline.
+- **The footer on every page.** It said "no document ever leaves this
+ instance" while a search had just sent passages. It now states the
+ switch: with AI on it names TensorX and the trigger; with AI off it
+ makes the strong claim, which is then true. A gate walks every declared
+ route, so a page cannot skip it.
+- **Processing time.** The deadline covered the call and not the work on
+ its response; a legal-sized response of repeated proposals took 6.8 s to
+ validate. One deadline now covers both, identical proposals are
+ validated once, and the recognisers run once per passage.
+- **Native search.** `POST /api/native/search` built the box — so called
+ the model — and its response has no field for facts: egress for an
+ answer the client never received. It now searches without the box, and
+ moves back when the native client can show attributed facts.
+
#### Injection
§8.5 is unchanged and its mitigation is the same partial one, with the
diff --git a/internal/memoria/credits.go b/internal/memoria/credits.go
index 9a7ba3e..2c899af 100644
--- a/internal/memoria/credits.go
+++ b/internal/memoria/credits.go
@@ -28,7 +28,7 @@ func (a *app) credits(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
- render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}})
+ render(w, "credits", map[string]any{"Signed": true, "HomeEnabled": a.showHomeConnection(r), "ModelOn": a.modelOn(), "Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}})
}
func serveCreditsCSS(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/memoria/fact/fact.go b/internal/memoria/fact/fact.go
index bddf462..40c276a 100644
--- a/internal/memoria/fact/fact.go
+++ b/internal/memoria/fact/fact.go
@@ -899,7 +899,16 @@ func RecognisedSpans(text string) [][2]int {
// It returns the label, and whether the kind must render no box because
// the document labelled the value too long to show.
func LabelAt(text string, start int, heading, title string) (string, bool) {
- return labelFor(text, start, RecognisedSpans(text), heading, title)
+ return LabelAtSpans(text, start, RecognisedSpans(text), heading, title)
+}
+
+// LabelAtSpans is LabelAt with the passage's recognised spans computed
+// once by the caller. RecognisedSpans runs every recogniser over the
+// whole passage, and a caller labelling many values in one passage must
+// not pay that per value — a review measured 6.8 seconds for a legal-
+// sized model response doing exactly that.
+func LabelAtSpans(text string, start int, spans [][2]int, heading, title string) (string, bool) {
+ return labelFor(text, start, spans, heading, title)
}
// WordBounded reports whether text[start:end] begins and ends on a word
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index f041861..3b475c1 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -137,15 +137,20 @@ func (a *app) UseAuth(x *auth.Auth) { a.auth = x }
type page struct {
Signed bool
HomeEnabled bool
- Chunks int
- Query string
- Searched bool
- Flash string
- FlashKind string
- ForceEmail bool
- Results []Result
- Documents []Summary
- Drop dropZone
+ // ModelOn switches the footer's egress sentence (layout.html). Set on
+ // EVERY page, because the footer is on every page — the first build
+ // updated the README and left every page promising "no document ever
+ // leaves this instance" while a search had just sent passages.
+ ModelOn bool
+ Chunks int
+ Query string
+ Searched bool
+ Flash string
+ FlashKind string
+ ForceEmail bool
+ Results []Result
+ Documents []Summary
+ Drop dropZone
// Notices is §6.5's loud withholding: what this page could not show
// and which app it came from. **Never a count of what the reader may
@@ -555,7 +560,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
drop := newDropZone()
p := page{
- Signed: true, HomeEnabled: a.showHomeConnection(r),
+ Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(),
Chunks: a.chunkCount(),
Query: q,
Searched: q != "",
@@ -598,7 +603,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
// "/" and still gets these results, inside a page.
func (a *app) searchResults(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
- p := page{Signed: true, HomeEnabled: a.showHomeConnection(r), Query: q, Searched: q != ""}
+ p := page{Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Query: q, Searched: q != ""}
a.runSearch(r, &p)
// The passage count is only spoken by the no-match copy, and this
// route runs once per keystroke. Counting only when the count will
@@ -1024,6 +1029,7 @@ type passage struct {
type documentPage struct {
Signed bool
HomeEnabled bool
+ ModelOn bool
Chunks int
Title string
Source string
@@ -1072,7 +1078,7 @@ func (a *app) showDocument(w http.ResponseWriter, r *http.Request) {
}
_ = body
render(w, "document", documentPage{
- Signed: true, HomeEnabled: a.showHomeConnection(r),
+ Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(),
Chunks: a.chunkCount(),
Title: title,
Source: source,
@@ -1108,7 +1114,7 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
}
func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
- p := page{Chunks: a.chunkCount()}
+ p := page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}
p.ForceEmail = a.home != nil && r.URL.Query().Get("force") == "1"
p.Flash, p.FlashKind = signinOutcome(r.URL.Query())
render(w, "signin", p)
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go
index e5e4475..679991a 100644
--- a/internal/memoria/handlers_connector.go
+++ b/internal/memoria/handlers_connector.go
@@ -14,6 +14,7 @@ import (
type connectorPage struct {
Signed bool
HomeEnabled bool
+ ModelOn bool
Chunks int
Flash string
FlashKind string
@@ -100,7 +101,7 @@ func failMessage(code string) string {
}
func (a *app) connectorBase(r *http.Request) connectorPage {
- return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), Chunks: a.chunkCount()}
+ return connectorPage{Signed: true, HomeEnabled: a.showHomeConnection(r), ModelOn: a.modelOn(), Chunks: a.chunkCount()}
}
func (a *app) connectors(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/memoria/home_render.go b/internal/memoria/home_render.go
index a822c7c..f57f506 100644
--- a/internal/memoria/home_render.go
+++ b/internal/memoria/home_render.go
@@ -12,7 +12,7 @@ type homePage struct {
}
func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
- p := homePage{page: page{Chunks: a.chunkCount()}, Home: connect.Page(state, label)}
+ p := homePage{page: page{Chunks: a.chunkCount(), ModelOn: a.modelOn()}, Home: connect.Page(state, label)}
var body bytes.Buffer
if err := pages["home"].ExecuteTemplate(&body, "layout", p); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
diff --git a/internal/memoria/model.go b/internal/memoria/model.go
index 44022de..0a17ee2 100644
--- a/internal/memoria/model.go
+++ b/internal/memoria/model.go
@@ -47,3 +47,8 @@ func WireModel(a *app, cfg Config) error {
// owner's opt-in (decision 4) — and, handed a real client, send content
// the owner never agreed to send.
func (a *app) UseFakeModel(e Extractor) { a.store.extractor = e }
+
+// modelOn reports whether this instance may send passages to the model.
+// Every page's footer states the egress claim, and the claim depends on
+// this; one helper so no page can compute it differently.
+func (a *app) modelOn() bool { return a.store != nil && a.store.extractor != nil }
diff --git a/internal/memoria/modelfacts_test.go b/internal/memoria/modelfacts_test.go
index 98bb810..6b51bd9 100644
--- a/internal/memoria/modelfacts_test.go
+++ b/internal/memoria/modelfacts_test.go
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
+ "time"
"amadan.net/oficina/suite"
@@ -785,6 +786,21 @@ func TestGate39WhatTheFirstDraftCouldNotSee(t *testing.T) {
if sentContains(rec.calls, ninth) {
t.Errorf("result nine (%q) was sent after result one went away", ninth)
}
+
+ // **After thinning, a pointer's index is into the THINNED list.**
+ // Passage 0 is now result two, and the citation must say so. A
+ // review looked the result up as results[c.passage] instead of
+ // sent[c.passage].result — citing the deleted result one — and
+ // every case stayed green, because none returned a pointer here.
+ second := ruled.Results[1]
+ rec2 := &recorder{reply: point(0, addrOf[second.DocumentID])}
+ s.extractor = rec2
+ page2 := Page{Results: ruled.Results}
+ s.addFacts(context.Background(), &page2, "office address")
+ rows := modelRows(page2)
+ if len(rows) != 1 || rows[0].DocumentID != second.DocumentID || rows[0].Title != second.Title || rows[0].Ordinal != second.Ordinal {
+ t.Errorf("rows = %+v; want one, cited to result two (doc %d)", rows, second.DocumentID)
+ }
})
}
@@ -822,3 +838,81 @@ func TestAModelRowNamesTheProviderAndTheModel(t *testing.T) {
t.Errorf("%d rows marked as model output, want 1", n)
}
}
+
+// **A model response is bounded in bytes, and its processing must be
+// bounded in time too.** A review sent 1,000 identical proposals — a
+// valid 54 KB response, under the cap — against a 64 KB passage, and
+// validation took 6.8 seconds, after the provider's deadline had already
+// expired and with nothing checking for cancellation. The recognisers ran
+// once per proposal; they now run once per passage, identical proposals
+// are validated once, and the cap stops the work as soon as it is certain.
+func TestAModelResponseIsCheapToProcess(t *testing.T) {
+ // ONE large chunk, dense with recognisable facts. chunk.Split bounds a
+ // chunk by WORDS, so text without spaces stays whole: the first version
+ // of this fixture used ordinary sentences, the chunker cut it into
+ // small passages, and the old per-proposal behaviour ran fast enough to
+ // pass. This is the reviewer's shape — a single 60 KB passage.
+ big := "## Contact\n\nOffice address: 12 Main Street, Dublin 2.\n" +
+ strings.Repeat("D02X285,info@tito.io,", 3000)
+
+ t.Run("a thousand identical proposals", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", big)
+ s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) {
+ out := make([]provider.Pointer, 1000)
+ for i := range out {
+ out[i] = provider.Pointer{Passage: 0, Text: "12 Main Street, Dublin 2"}
+ }
+ return out, nil
+ }}
+ start := time.Now()
+ rows := modelRows(searchFacts(t, s, "office address"))
+ // Against the 6.8 s measured, with an order of magnitude to spare
+ // for a slow machine; the fixed code takes a few milliseconds.
+ if took := time.Since(start); took > 700*time.Millisecond {
+ t.Errorf("took %v to process one repeated proposal", took)
+ }
+ if len(rows) != 1 {
+ t.Errorf("rows = %d, want 1: a thousand copies are one proposal", len(rows))
+ }
+ })
+
+ t.Run("hundreds of distinct proposals are refused by the cap, quickly", func(t *testing.T) {
+ s := storeForTest(t)
+ body := "## Our addresses\n\n"
+ var want []provider.Pointer
+ for i := 0; i < 400; i++ {
+ a := fmt.Sprintf("%d Harbour Street, Cork", i+1)
+ body += "Address: " + a + ".\n"
+ want = append(want, provider.Pointer{Passage: 0, Text: a})
+ }
+ upload(t, s, "Tito office", body)
+ s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) { return want, nil }}
+ start := time.Now()
+ if rows := modelRows(searchFacts(t, s, "address")); len(rows) != 0 {
+ t.Errorf("rows = %d; four hundred addresses are past the cap", len(rows))
+ }
+ if took := time.Since(start); took > 700*time.Millisecond {
+ t.Errorf("took %v", took)
+ }
+ })
+
+ // The whole model path runs under one deadline: the extractor is
+ // handed a context that expires within the provider's budget even
+ // when the request's own context never does.
+ t.Run("the model path carries its own deadline", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Street, Dublin 2")
+ var left time.Duration
+ var had bool
+ s.extractor = ctxRecorder(func(ctx context.Context) {
+ var dl time.Time
+ dl, had = ctx.Deadline()
+ left = time.Until(dl)
+ })
+ searchFacts(t, s, "office address") // context.Background: no deadline of its own
+ if !had || left <= 0 || left > provider.Deadline {
+ t.Errorf("deadline present=%v, remaining %v; want one within %v", had, left, provider.Deadline)
+ }
+ })
+}
diff --git a/internal/memoria/native.go b/internal/memoria/native.go
index 0aaffa5..483da16 100644
--- a/internal/memoria/native.go
+++ b/internal/memoria/native.go
@@ -50,7 +50,10 @@ func (a *app) nativeSearch(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Enter something to search for", 400)
return
}
- found, err := a.store.Search(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: address}, in.Query, 25)
+ // WithoutFacts: this response has no field for the fact box, so building
+ // one would call the model (§8.9) and send passages for an answer the
+ // native client never receives.
+ found, err := a.store.SearchWithoutFacts(r.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: address}, in.Query, 25)
if err != nil {
http.Error(w, "Search is unavailable. Try again.", 503)
return
diff --git a/internal/memoria/native_test.go b/internal/memoria/native_test.go
index 49c6111..6e16e33 100644
--- a/internal/memoria/native_test.go
+++ b/internal/memoria/native_test.go
@@ -309,3 +309,43 @@ func TestNativeNoteReplayWithSameClientIDIsANoOp(t *testing.T) {
}
}
}
+
+// Native search must not call the model. Its response has no field for
+// the fact box, so a model call there is egress for an answer the native
+// client never receives. Through the real native route, with AI on.
+//
+// The presence half is the SAME store and query through the web search,
+// which does call the model — without it, "zero calls" would be true of
+// any setup in which the model could not be reached at all.
+func TestNativeSearchDoesNotCallTheModel(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ if _, err := f.app.store.Ingest(t.Context(), IngestSpec{Source: "Upload", Key: "office", Title: "Tito office", Body: "## Contact\n\nOffice address: 12 Main Street, Dublin 2"}); err != nil {
+ t.Fatal(err)
+ }
+ rec := &recorder{reply: point(0, "12 Main Street, Dublin 2")}
+ f.app.store.extractor = rec
+ f.local(t)
+ f.cookies = nativeClientCookies(t, f.handler, f.cookies)
+
+ r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/search", strings.NewReader(`{"query":"office address"}`))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Origin", homeTestOrigin)
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ if w.Code != 200 || !strings.Contains(w.Body.String(), "Tito office") {
+ t.Fatalf("native search did not find the document (%d); the zero below would prove nothing", w.Code)
+ }
+ if len(rec.calls) != 0 {
+ t.Errorf("native search sent %d requests to the model for an answer it cannot show", len(rec.calls))
+ }
+
+ if _, err := f.app.store.Search(t.Context(), suite.Subject{Kind: suite.SubjectAddress, Value: "alice@example.test"}, "office address", 25); err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.calls) != 1 {
+ t.Errorf("the web search made %d calls; the native zero is meaningful only if this is 1", len(rec.calls))
+ }
+}
diff --git a/internal/memoria/provider/provider.go b/internal/memoria/provider/provider.go
index 965c139..1ddf5a1 100644
--- a/internal/memoria/provider/provider.go
+++ b/internal/memoria/provider/provider.go
@@ -17,6 +17,8 @@ package provider
import (
"bytes"
"context"
+ "crypto/tls"
+ "crypto/x509"
"encoding/json"
"errors"
"fmt"
@@ -114,6 +116,14 @@ func New(key string) (*Client, error) {
// transport below refuses every other host, and TestClientDialsOnlyTensorX
// asserts it against this constructor.
func newClient(key, base string) (*Client, error) {
+ return newClientWithRoots(key, base, nil)
+}
+
+// newClientWithRoots is newClient with the TLS roots replaceable, so a
+// test can present a server certificate the client TRUSTS — the presence
+// half of "an untrusted certificate receives nothing". nil means the
+// system roots, which is what production always gets.
+func newClientWithRoots(key, base string, roots *x509.CertPool) (*Client, error) {
probe, err := http.NewRequest(http.MethodPost, base, nil)
if err != nil || probe.URL.Hostname() == "" || probe.URL.User != nil {
return nil, ErrForbiddenHost
@@ -129,6 +139,19 @@ func newClient(key, base string) (*Client, error) {
// environment instead. TLS verification stays the default.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.Proxy = nil
+ // **No keep-alives.** An idle pooled connection is read by net/http's
+ // own loop, and bytes arriving on it unsolicited are reported with the
+ // standard library's log.Printf — "Unsolicited response received on
+ // idle HTTP channel starting with %q" — quoting them, straight past
+ // every sanitised error path in this package and in the store. A
+ // review had TensorX's side of a real TLS connection send passage text
+ // after a valid response and found it on stderr. With no idle
+ // connection there is nothing for that loop to read. The cost is a
+ // fresh handshake per call, inside the 800 ms deadline.
+ tr.DisableKeepAlives = true
+ if roots != nil {
+ tr.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
+ }
return &Client{
base: base,
key: key,
diff --git a/internal/memoria/provider/provider_test.go b/internal/memoria/provider/provider_test.go
index adcc0b1..d005516 100644
--- a/internal/memoria/provider/provider_test.go
+++ b/internal/memoria/provider/provider_test.go
@@ -2,13 +2,19 @@ package provider
import (
"bufio"
+ "bytes"
"context"
+ "crypto/x509"
"encoding/json"
"errors"
+ "fmt"
"io"
+ "log"
"net"
"net/http"
"net/http/httptest"
+ "os"
+ "reflect"
"regexp"
"strings"
"sync/atomic"
@@ -142,9 +148,12 @@ func TestExtractAddressesHappyPath(t *testing.T) {
}
// The passages go as indexed data, in the order given — the caller
// resolves Pointer.Passage against that order.
+ // Every index and every text, as given — a review blanked passage 0
+ // before serialising and a check of passage 1 alone stayed green.
var sent []sentPassage
- if err := json.Unmarshal([]byte(req.Messages[1].Content), &sent); err != nil || len(sent) != 2 || sent[1].Index != 1 || sent[1].Text != "At 12 Main Street, Dublin 2 we" {
- t.Errorf("passages sent = %+v (err %v)", sent, err)
+ want := []sentPassage{{0, "first"}, {1, "At 12 Main Street, Dublin 2 we"}}
+ if err := json.Unmarshal([]byte(req.Messages[1].Content), &sent); err != nil || !reflect.DeepEqual(sent, want) {
+ t.Errorf("passages sent = %+v, want %+v (err %v)", sent, want, err)
}
}
@@ -222,8 +231,12 @@ func TestTheDeadlineHolds(t *testing.T) {
s.delay = 5 * time.Second
start := time.Now()
_, err := s.client().ExtractAddresses(context.Background(), []string{"x"})
- if err == nil {
- t.Fatal("a provider past the deadline returned no error")
+ // DeadlineExceeded specifically: the store logs a deadline and stays
+ // silent on a cancellation, so a deadline reported as Canceled is an
+ // outage nobody hears about. A review swapped them and this stayed
+ // green while it checked only err != nil.
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("err = %v, want context.DeadlineExceeded", err)
}
if took := time.Since(start); took > 1200*time.Millisecond {
t.Errorf("took %v; the deadline is 800ms", took)
@@ -454,3 +467,98 @@ func TestALongSEnvelopeDuplicateIsRefused(t *testing.T) {
t.Errorf("err = %v, want ErrMalformed: the duplicate would have decoded as a clean stop", err)
}
}
+
+// **The standard library's own logger is a content path too.** net/http
+// reads idle pooled connections, and bytes arriving on one unsolicited
+// are reported with log.Printf, quoting them — bypassing every sanitised
+// error in this package. A review reproduced it over real TLS: a valid
+// response, then passage text on the same connection, then that text on
+// stderr. The client now disables keep-alives, so there is no idle
+// connection to read.
+//
+// Captures the STANDARD logger, because that is the channel. Served from
+// a raw socket that answers correctly and then keeps talking.
+func TestUnsolicitedBytesDoNotReachTheStandardLogger(t *testing.T) {
+ var logged bytes.Buffer
+ log.SetOutput(&logged)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ln.Close() })
+ served := make(chan struct{})
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ br := bufio.NewReader(conn)
+ for {
+ line, err := br.ReadString('\n')
+ if err != nil || line == "\r\n" {
+ break
+ }
+ }
+ body := reply(`{"addresses":[]}`, "stop")
+ fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body)
+ // A moment for the client to finish and pool the connection, then
+ // talk unasked.
+ time.Sleep(50 * time.Millisecond)
+ _, _ = conn.Write([]byte(canary + " unsolicited team content"))
+ time.Sleep(150 * time.Millisecond)
+ close(served)
+ }()
+
+ c, err := newClient("k", "http://"+ln.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := c.ExtractAddresses(context.Background(), []string{"x"}); err != nil {
+ t.Fatalf("the valid response failed: %v", err)
+ }
+ <-served
+ time.Sleep(50 * time.Millisecond) // let any read loop run
+ if strings.Contains(logged.String(), canary) {
+ t.Errorf("the standard logger carries provider bytes: %q", logged.String())
+ }
+}
+
+// **An untrusted certificate receives nothing, and a trusted one works.**
+// A review set InsecureSkipVerify on the transport and every test stayed
+// green, because every stub spoke plain HTTP. Both halves: without the
+// trusted half, a client that refused ALL TLS would pass.
+func TestTLSIsVerified(t *testing.T) {
+ var hits atomic.Int32
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits.Add(1)
+ _, _ = io.WriteString(w, reply(`{"addresses":[]}`, "stop"))
+ }))
+ t.Cleanup(srv.Close)
+
+ untrusted, err := newClient("k", srv.URL) // system roots: the test CA is not among them
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := untrusted.ExtractAddresses(context.Background(), []string{"x"}); !errors.Is(err, ErrTransport) {
+ t.Fatalf("err = %v, want ErrTransport", err)
+ }
+ if n := hits.Load(); n != 0 {
+ t.Fatalf("%d requests reached a server whose certificate is not trusted", n)
+ }
+
+ pool := x509.NewCertPool()
+ pool.AddCert(srv.Certificate())
+ trusted, err := newClientWithRoots("k", srv.URL, pool)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := trusted.ExtractAddresses(context.Background(), []string{"x"}); err != nil {
+ t.Fatalf("a trusted certificate failed: %v", err)
+ }
+ if n := hits.Load(); n != 1 {
+ t.Errorf("hits = %d, want 1", n)
+ }
+}
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index c068e2f..01df9e4 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -561,6 +561,23 @@ func (s *Store) Rebuild(ctx context.Context) (int, error) {
// — §4.1's typed subject travels on the wire precisely so a receiver that
// cannot make sense of it says UNKNOWN rather than guessing.
func (s *Store) Search(ctx context.Context, asker suite.Subject, query string, limit int) (Page, error) {
+ return s.search(ctx, asker, query, limit, true)
+}
+
+// SearchWithoutFacts is Search for a surface that cannot show the fact
+// box: identical ruling, no box, and therefore no model call.
+//
+// **It exists because native search sent passages and threw the answer
+// away.** POST /api/native/search went through Search, which builds the
+// box — including §8.9's model call — and the native response has no field
+// for facts. So the native client paid the latency and caused the egress
+// without ever receiving the address it was for. Found by review. When the
+// native client can show attributed facts, it moves back to Search.
+func (s *Store) SearchWithoutFacts(ctx context.Context, asker suite.Subject, query string, limit int) (Page, error) {
+ return s.search(ctx, asker, query, limit, false)
+}
+
+func (s *Store) search(ctx context.Context, asker suite.Subject, query string, limit int, facts bool) (Page, error) {
if asker.Value == "" {
return Page{}, errors.New("search: no asker; nothing can be authorized")
}
@@ -695,7 +712,9 @@ func (s *Store) Search(ctx context.Context, asker suite.Subject, query string, l
// **After the ruling, never before it.** §8.8's box is built from the
// passages rule() kept, so a withheld one cannot contribute a
// candidate and cannot be counted towards the cap.
- s.addFacts(ctx, &page, query)
+ if facts {
+ s.addFacts(ctx, &page, query)
+ }
return page, nil
}
@@ -1146,6 +1165,14 @@ type sentPassage struct {
// see, or it is dropped — and from the coordinate on, the span, the label
// and the citation all come from the DOCUMENT.
func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int64]string, k fact.Kind) []Fact {
+ // **The deadline covers the whole model path, not only the call.**
+ // Processing a response happens after the provider's own 800 ms has
+ // run out, and a review made that processing take 6.8 seconds with a
+ // valid response under the size cap. One budget for asking and
+ // answering; past it, the page renders as AI-off would.
+ ctx, cancel := context.WithTimeout(ctx, provider.Deadline)
+ defer cancel()
+
// What is sent: of the top eight ruled results, those still present,
// in rank order.
//
@@ -1190,8 +1217,40 @@ func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int6
type coord struct {
passage, start, end int
}
+ // **Work is per DISTINCT proposal, and per passage where it can be.**
+ // A response may repeat one proposal a thousand times, and the
+ // recognisers run once per passage rather than once per proposal. Both
+ // preserve every rule below — dedupe by identical (passage, text) cannot
+ // change which occurrence is found. What bounds the rest is the
+ // deadline, checked on every proposal.
+ //
+ // (An early return once more distinct spans were accepted than the cap
+ // allows was here too. It was removed rather than kept: with the
+ // recognisers cached, its absence could not be made measurably slow, so
+ // no test could ever see it go — AGENTS.md's unreachable-defence rule.)
+ type proposal struct {
+ passage int
+ text string
+ }
+ tried := map[proposal]bool{}
+ recognised := map[int][][2]int{}
+ spansOf := func(i int) [][2]int {
+ sp, ok := recognised[i]
+ if !ok {
+ sp = fact.RecognisedSpans(passages[i])
+ recognised[i] = sp
+ }
+ return sp
+ }
var accepted []coord
for _, ptr := range ptrs {
+ if ctx.Err() != nil {
+ return nil
+ }
+ if tried[proposal{ptr.Passage, ptr.Text}] {
+ continue
+ }
+ tried[proposal{ptr.Passage, ptr.Text}] = true
// 1. It must name a passage that was SENT. An authorised result
// ranked ninth was never in the request, and a proposal pointing
// at it is pointing at something the model was not shown.
@@ -1219,7 +1278,7 @@ func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int6
// recognisers found — a street name inside an email address is
// not an address. (An address that CONTAINS an eircode is fine;
// most Irish ones do.)
- if !fact.WordBounded(text, start, end) || insideRecognised(text, start, end) {
+ if !fact.WordBounded(text, start, end) || insideRecognised(spansOf(ptr.Passage), start, end) {
continue
}
accepted = append(accepted, coord{ptr.Passage, start, end})
@@ -1250,7 +1309,7 @@ func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int6
}
seen[span] = true
r := sent[c.passage].result
- label, noBox := fact.LabelAt(text, c.start, r.Heading, r.Title)
+ label, noBox := fact.LabelAtSpans(text, c.start, spansOf(c.passage), r.Heading, r.Title)
if noBox {
return nil
}
@@ -1311,10 +1370,10 @@ func admissibleSpan(t string) bool {
return len(t) >= 12 && strings.ContainsAny(t, "0123456789,\n")
}
-// insideRecognised reports whether [start,end) lies wholly inside a span
-// the deterministic recognisers found.
-func insideRecognised(text string, start, end int) bool {
- for _, sp := range fact.RecognisedSpans(text) {
+// insideRecognised reports whether [start,end) lies wholly inside one of
+// spans — the passage's recognised facts, computed once by the caller.
+func insideRecognised(spans [][2]int, start, end int) bool {
+ for _, sp := range spans {
if sp[0] <= start && end <= sp[1] {
return true
}
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index ba57fb0..8c884a9 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -56,8 +56,18 @@
zero, which is the failure shape this app has already had once. */}}
<p rst-cell-mut>
<span rst-refresh="passages">{{if .Signed}}{{if lt .Chunks 0}}The passage count is unavailable.{{else}}{{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.{{end}}{{end}}</span>
- Search is lexical only — no assistant, and <strong>no document ever
- leaves this instance</strong>.
+ {{/* The egress claim, and it depends on the model switch (§8.9). With
+ AI off the strong sentence is true and is stated. With AI on it is
+ FALSE: a search that asks for a postal address sends up to eight
+ passages to TensorX. The first build left the strong sentence on
+ every page while that happened; a review sent a passage and got
+ this footer back in the same response. */}}
+ {{if .ModelOn}}Search is lexical. <strong>AI is on:</strong> a search
+ that asks for a postal address sends up to eight of its passages to
+ TensorX, including while you type. No other document leaves this
+ instance.
+ {{else}}Search is lexical only — no assistant, and <strong>no document ever
+ leaves this instance</strong>.{{end}}
</p>
</footer>{{end}}
</body>
diff --git a/internal/memoriatest/footer_model_test.go b/internal/memoriatest/footer_model_test.go
new file mode 100644
index 0000000..b2adacb
--- /dev/null
+++ b/internal/memoriatest/footer_model_test.go
@@ -0,0 +1,163 @@
+package memoriatest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ "amadan.net/oficina/memoria/internal/memoria"
+ "amadan.net/oficina/memoria/internal/memoria/provider"
+)
+
+// countingModel is a stand-in model that counts calls and points at any
+// "address: " line, as a compliant model would.
+type countingModel struct{ calls int }
+
+func (m *countingModel) ExtractAddresses(_ context.Context, passages []string) ([]provider.Pointer, error) {
+ m.calls++
+ var out []provider.Pointer
+ for i, p := range passages {
+ if j := strings.Index(p, "address: "); j >= 0 {
+ line := p[j+len("address: "):]
+ if k := strings.IndexByte(line, '\n'); k >= 0 {
+ line = line[:k]
+ }
+ out = append(out, provider.Pointer{Passage: i, Text: line})
+ }
+ }
+ return out, nil
+}
+
+// appWithModel is newApp with the model layer switched on by a stand-in,
+// and a document stored so every page has something to show.
+func appWithModel(t *testing.T, m memoria.Extractor) (http.Handler, int64) {
+ 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.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if m != nil {
+ a.UseFakeModel(m)
+ }
+ id, err := a.Store().Ingest(context.Background(), memoria.IngestSpec{
+ Source: "upload", Key: "office", Title: "Tito office",
+ Body: "## Contact\n\nOffice address: 12 Main Street, Dublin 2",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return mux, id
+}
+
+// footers requests every declared GET route and returns the footer of
+// each page that has one, keyed by route. Walking declaredChiRoutes rather
+// than a hand-written list is the point: the first build of §8.9 updated
+// the README and missed the footer on every page, and a list of pages
+// written by the same hand misses a page the same way.
+func footers(t *testing.T, h http.Handler, docID int64) map[string]string {
+ t.Helper()
+ out := map[string]string{}
+ for route := range declaredChiRoutes {
+ method, path, _ := strings.Cut(route, " ")
+ if method != "GET" {
+ continue
+ }
+ path = strings.ReplaceAll(path, "{id}", strconv.FormatInt(docID, 10))
+ rec := get(t, h, path)
+ body := rec.Body.String()
+ i := strings.Index(body, "<footer")
+ if rec.Code != http.StatusOK || i < 0 {
+ continue
+ }
+ // Whitespace collapsed, as a browser renders it: the template wraps
+ // its sentences across source lines, and a substring search over
+ // the raw bytes misses "leaves this\n instance".
+ out[route] = strings.Join(strings.Fields(body[i:]), " ")
+ }
+ return out
+}
+
+// **The footer's egress claim is right on every page, with AI on and off.**
+//
+// With AI on, "no document ever leaves this instance" is FALSE — a search
+// that asks for a postal address has just sent passages to TensorX — and
+// the first build of §8.9 printed it on every page anyway, including the
+// response to the search that sent them. Found by review, reproduced by
+// sending a passage and reading this footer back.
+func TestTheFooterStatesTheModelSwitch(t *testing.T) {
+ m := &countingModel{}
+ on, id := appWithModel(t, m)
+
+ // Presence: the model really is reached. Without this, every "the
+ // footer names TensorX" below is a statement about an app that never
+ // sends anything.
+ if body := get(t, on, "/?q=office+address").Body.String(); !strings.Contains(body, "12 Main Street, Dublin 2") {
+ t.Fatal("the address search did not render a model row")
+ }
+ if m.calls == 0 {
+ t.Fatal("the model was never called; the footers below prove nothing")
+ }
+
+ withAI := footers(t, on, id)
+ // A page may make NO egress claim — /help/open-source deliberately
+ // renders an empty footer — and that is fine: an absent claim is not a
+ // false one. What is checked is every page that makes a claim, and
+ // there must be enough of those to mean something.
+ claims := 0
+ for route, f := range withAI {
+ // Never the zero-egress claim while AI is on, on any page.
+ if strings.Contains(f, "no document ever") {
+ t.Errorf("%s: with AI on the footer still says no document ever leaves", route)
+ }
+ if strings.Contains(f, "leaves this instance") {
+ claims++
+ if !strings.Contains(f, "TensorX") {
+ t.Errorf("%s: with AI on the footer makes an egress claim without naming TensorX", route)
+ }
+ }
+ }
+ // Index, a document, sign-in, the connectors: fewer means the walk
+ // stopped finding pages, and the loop above proved nothing.
+ if claims < 4 {
+ t.Fatalf("only %d pages made an egress claim with AI on: %v", claims, keys(withAI))
+ }
+
+ // And the strong claim still appears when it is TRUE.
+ off, id2 := appWithModel(t, nil)
+ offClaims := 0
+ for route, f := range footers(t, off, id2) {
+ if strings.Contains(f, "TensorX") {
+ t.Errorf("%s: with AI off the footer names a provider nothing is sent to", route)
+ }
+ if strings.Contains(f, "leaves this instance") {
+ offClaims++
+ if !strings.Contains(f, "no document ever") {
+ t.Errorf("%s: with AI off the footer does not make the true, strong claim", route)
+ }
+ }
+ }
+ if offClaims != claims {
+ t.Errorf("%d pages make a claim with AI off against %d with it on", offClaims, claims)
+ }
+}
+
+func keys(m map[string]string) []string {
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 69cd07b..bcf1481 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -323,6 +323,12 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
//
// This gate pins the copy, so nobody restores the stronger sentence
// without also removing the dial.
+//
+// **Since §8.9 the sentence is conditional**: it is the AI-OFF branch of
+// the footer, and with AI on the footer names TensorX instead. This gate
+// reads the template source, so it pins the wording of both branches;
+// TestTheFooterStatesTheModelSwitch renders every declared page both ways
+// and is what checks the right branch is chosen.
func TestFooterDoesNotOverclaim(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "memoria", "templates", "layout.html"))
if err != nil {
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go
index eeee817..b1ad64c 100644
--- a/internal/memoriatest/routes_test.go
+++ b/internal/memoriatest/routes_test.go
@@ -55,63 +55,8 @@ import (
// 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{
- "POST /native/request": true,
- "POST /native/exchange": true,
- "GET /native/start": true,
- "GET /native/authorize": true,
- "POST /native/authorize": true,
- "GET /api/native/session": true,
- "POST /api/native/notes": true,
- "POST /api/native/search": true,
- "GET /help/open-source": true,
- "GET /signin": true,
- "GET /signin/local": true,
- "GET /signed-out": true,
- "GET /account/home": true,
- "POST /account/home": true,
- "GET /account/home/confirm": true,
- "POST /account/home/confirm": true,
- "GET /auth/start": true,
- "POST /auth/assertion": true,
- "GET /auth/finish": true,
- "GET /auth/local/complete": 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,
- }
+ // The list is at package scope — see declaredChiRoutes.
+ declaredChi := declaredChiRoutes
// http.ServeMux patterns, verbatim — these are mounts, and the method
// (where there is one) lives inside the string.
declaredMux := map[string]bool{
@@ -284,3 +229,67 @@ func TestProductionMountsAuthThroughTheGuardedPath(t *testing.T) {
}
}
}
+
+// declaredChiRoutes is every chi registration, as METHOD + pattern.
+//
+// Package scope, 2026-09-27, so a second gate can walk it: the footer's
+// egress claim must be right on EVERY page, and a hand-written list of
+// pages is how the first build of §8.9 missed the footer altogether.
+// TestNoUndeclaredRoutes is unchanged in behaviour.
+//
+// §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.
+var declaredChiRoutes = map[string]bool{
+ "POST /native/request": true,
+ "POST /native/exchange": true,
+ "GET /native/start": true,
+ "GET /native/authorize": true,
+ "POST /native/authorize": true,
+ "GET /api/native/session": true,
+ "POST /api/native/notes": true,
+ "POST /api/native/search": true,
+ "GET /help/open-source": true,
+ "GET /signin": true,
+ "GET /signin/local": true,
+ "GET /signed-out": true,
+ "GET /account/home": true,
+ "POST /account/home": true,
+ "GET /account/home/confirm": true,
+ "POST /account/home/confirm": true,
+ "GET /auth/start": true,
+ "POST /auth/assertion": true,
+ "GET /auth/finish": true,
+ "GET /auth/local/complete": 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,
+}