oficina / memoria Public

🤖 test: build gate 17, which the spec has claimed for three drafts

§10 has listed "no sibling-facing endpoint exists — enumerate memoria's
routes and fail any inbound route not on the list" since draft 1. Nothing
in internal/memoriatest walked the router. That is the guard-written-to-
pass shape one step worse: a guard not written at all, named in the
document that is supposed to prove it.

It came up because I had just written a literal route allowlist into gate
17's text and claimed "adding a thread or chat route goes red on the way
in" — a claim about a test that did not exist. Decision 14 rests on it, so
it is now real.

Reads the AST rather than grepping, so a path in a comment or an unrelated
string cannot trip it, and refuses a route whose pattern is not a string
literal — otherwise the way past the gate is to compute the path.

Mutation-tested three ways, all reverted:
  - r.Post("/chat", a.index) in the authenticated group
    -> app.go:63: undeclared route: POST /chat
  - r.Get(root, a.index) with root a variable
    -> app.go:63: route pattern is not a literal
  - walk pointed at an empty directory
    -> found no route registrations at all: the walk is broken

The third exists because of the other AGENTS.md rule: a count that fails is
not a count of zero. Without it this gate passes over an empty set the day
someone moves the router, and reports that as the app being clean.

Honest about its limits in the comment: a route registered through a helper
outside the walk, or on a receiver renamed away from r/mux, is not caught.
The declared list is the real control; the walk is the finder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0f559e6a7b958ed527de84f692d8d42e67a379e0 parent f80944a
2 files changed, +157 −2
  • docs/superpowers/specs/2026-09-03-memoria-design.md +7 −2
  • internal/memoriatest/routes_test.go +150 −0
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index c49bda9..4a70019 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -1607,8 +1607,13 @@ renders on its own origin. The home never sees `q`.
`POST /documents`, `POST /documents/{id}/delete`, plus the auth routes.
Written that way so that adding a thread or chat route goes red on the
way in (decision 14) rather than being argued about afterwards.
- Mutation: register `POST /chat`, expect red. **Not built** — nothing in
- `internal/memoriatest` walks the router yet.
+ **Built 2026-09-04** as `TestNoUndeclaredRoutes`, and mutation-tested
+ three ways: a `POST /chat` registration goes red as an undeclared
+ route, a computed path goes red because the gate refuses a pattern it
+ cannot read, and an empty walk root goes red on a `seen == 0` check
+ rather than passing over nothing. It reads the AST, not a grep. What it
+ does not catch is a route registered through a helper outside the walk,
+ or on a receiver renamed away from `r`/`mux`.
18. **External-source authorization and disclosure.** A memoria-owned
document is governed by memoria's grants, an upstream revocation does
*not* remove it, and the copy the README promises is deletable is
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go
new file mode 100644
index 0000000..7047605
--- /dev/null
+++ b/internal/memoriatest/routes_test.go
@@ -0,0 +1,150 @@
+package memoriatest
+
+import (
+ "fmt"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// Gate 17, spec §10: no route exists that the spec has not listed.
+//
+// This is the regression gate for the sibling-facing push surface draft 1
+// shipped by accident, and — since decision 14 — the gate that makes
+// "memoria is a search page, not a chat" enforceable rather than prose. A
+// review pointed out that §10 had claimed this gate for three drafts while
+// nothing in this package walked the router: the guard-written-to-pass
+// shape one step worse, a guard not written at all.
+//
+// It lives in its own file rather than gates_test.go only because it needs
+// the go/ast imports and nothing else here does.
+//
+// It reads the AST rather than grepping, so a path in a comment or an
+// unrelated string cannot trip it, and it fails a route whose pattern is
+// not a string literal — otherwise the way past this gate is to compute the
+// path. What it does NOT catch: a route registered by a helper in a package
+// this walk does not cover, or on a receiver named something other than r
+// or mux. The honest claim is "a route added the way every current route is
+// added is caught", and the declared list below is the real control.
+//
+// MUTATION-TESTED 2026-09-04: adding `r.Post("/chat", a.index)` to the
+// authenticated group in app.go fails with `undeclared route: POST /chat`.
+// Replacing `r.Get("/", a.index)` with a `root` const fails with `route
+// pattern is not a literal`. Pointing the walk at an empty directory fails
+// the seen == 0 check rather than passing. All three reverted.
+func TestNoUndeclaredRoutes(t *testing.T) {
+ // The person-facing and connector surface, spelled out. §9.1's
+ // /query is deliberately NOT here: the app serves GET / and it is the
+ // spec that is wrong, which §8.4 now records. Adding to this list is
+ // a decision, which is the point of it being a list.
+ declared := map[string]bool{
+ "/signin": true,
+ "/": true,
+ "/documents": true,
+ "/documents/{id}": true,
+ "/documents/{id}/delete": true,
+ "/auth/begin": true,
+ "/auth/callback": true,
+ "/auth/verify": true,
+ "/signout": true,
+ // Mounts, not endpoints.
+ "GET /static/": true,
+ "/auth/": true,
+ "POST /signout": true,
+ }
+ // Router method name -> index of the pattern argument.
+ patternArg := map[string]int{
+ "Get": 0, "Post": 0, "Put": 0, "Patch": 0, "Delete": 0,
+ "Head": 0, "Options": 0, "Connect": 0, "Trace": 0,
+ "Handle": 0, "HandleFunc": 0, "Mount": 0,
+ "Method": 1, "MethodFunc": 1,
+ }
+
+ var problems []string
+ seen := 0
+
+ inspect := func(path string, fset *token.FileSet, file *ast.File) {
+ ast.Inspect(file, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return true
+ }
+ recv, ok := sel.X.(*ast.Ident)
+ if !ok || (recv.Name != "r" && recv.Name != "mux") {
+ return true
+ }
+ idx, ok := patternArg[sel.Sel.Name]
+ if !ok || len(call.Args) <= idx {
+ return true
+ }
+ seen++
+ line := fset.Position(call.Pos()).Line
+ lit, ok := call.Args[idx].(*ast.BasicLit)
+ if !ok || lit.Kind != token.STRING {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: route pattern is not a literal, so this gate cannot read it",
+ path, line))
+ return true
+ }
+ pattern, err := strconv.Unquote(lit.Value)
+ if err != nil {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: unreadable route pattern %s", path, line, lit.Value))
+ return true
+ }
+ if !declared[pattern] {
+ problems = append(problems, fmt.Sprintf(
+ "%s:%d: undeclared route: %s %s",
+ path, line, strings.ToUpper(sel.Sel.Name), pattern))
+ }
+ return true
+ })
+ }
+
+ walk := func(root string) error {
+ return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
+ return nil
+ }
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, p, nil, parser.SkipObjectResolution)
+ if err != nil {
+ return err
+ }
+ inspect(p, fset, file)
+ return nil
+ })
+ }
+
+ for _, root := range []string{
+ filepath.Join("..", "memoria"),
+ filepath.Join("..", "..", "cmd"),
+ } {
+ if err := walk(root); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // A count that fails is not a count of zero (AGENTS.md). If the walk
+ // found no registrations at all then the finder is broken, and a green
+ // gate over an empty set is this codebase's worst failure shape.
+ if seen == 0 {
+ t.Fatal("found no route registrations at all: the walk is broken, not the app clean")
+ }
+
+ for _, p := range problems {
+ t.Error(p)
+ }
+}