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>
17 files changed,
+649
−88
docs/superpowers/specs/2026-09-03-memoria-design.md+25 −0internal/memoria/credits.go+1 −1internal/memoria/fact/fact.go+10 −1internal/memoria/handlers.go+19 −13internal/memoria/handlers_connector.go+2 −1internal/memoria/home_render.go+1 −1internal/memoria/model.go+5 −0internal/memoria/modelfacts_test.go+94 −0internal/memoria/native.go+4 −1internal/memoria/native_test.go+40 −0internal/memoria/provider/provider.go+23 −0internal/memoria/provider/provider_test.go+112 −4internal/memoria/store.go+66 −7internal/memoria/templates/layout.html+12 −2internal/memoriatest/footer_model_test.go+163 −0internal/memoriatest/gates_test.go+6 −0internal/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, |
| +} |