🤖 feat: the sibling roster, refused at boot rather than at query time
Everything before this made the §4.1 path correct; nothing made it reachable. `MEMORIA_SIBLINGS`, `MEMORIA_TEAM` and a per-app `MEMORIA_SIBLING_TOKEN_<NAME>` are what an instance is configured with, and main wires the result. **Every refusal here exists because the alternative is silence.** A sibling with no token cannot be asked; one that cannot be asked withholds every passage it owns; and what a reader sees is a permanently short page with a notice blaming an app that is working perfectly. So a missing token, a missing team, a duplicate name and a cleartext address are all boot failures — the one place that can be said out loud. An *empty* roster is neither an error nor a warning: slice 0 holds only memoria-owned content, which has no app of record. The token is a separate variable from the address, per app, for two reasons that are not style. A roster gets logged and pasted into tickets while somebody works out why a page is short, and a token in the same string goes with it. And §6.2's three-phase rotation changes one variable rather than rewriting a list, which is what makes it resumable rather than half-applied. **The wiring gate now parses instead of grepping.** It asserted that main.go *mentions* UseAuth; a mutation writing `_ = memoria.LoadSiblings` — a reference that does nothing — passed it. Where the problem is a call NOT happening, only the parse can tell a call from a mention. The seam scan above it stays a grep, because there the string appearing at all is the problem. Nine mutations, all red, none surviving: the roster unread, the setter uncalled, no-token accepted, cleartext accepted, a duplicate name accepted, a missing team accepted, the roster unsorted, a team demanded of an instance with none, and the token variable's case folding dropped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3VKtXnq6fSmM2F11Bjcsy
4 files changed,
+392
−2
cmd/memoria/main.go+31 −0internal/memoria/config_siblings.go+124 −0internal/memoria/config_siblings_test.go+169 −0internal/memoriatest/gates_test.go+68 −2
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go| index f7615f1..8e121f7 100644 |
| --- a/cmd/memoria/main.go |
| +++ b/cmd/memoria/main.go |
| @@ -86,6 +86,37 @@ func main() { |
| app.UseAuth(a) |
| app.MountAuth(mux, cfg.Origin) |
| + // The apps of record memoria may ask about passages (§4.1). |
| + // |
| + // Fatal on error, like every other refusal in this file, and for the |
| + // characteristic reason: a half-configured roster does not fail |
| + // loudly at query time. A sibling with no token cannot be asked, a |
| + // sibling that cannot be asked withholds every passage it owns, and |
| + // what a reader sees is a permanently short page blaming an app that |
| + // is working fine. Boot is the only place that failure can be said |
| + // out loud. |
| + // |
| + // An EMPTY roster is not an error and not a warning: slice 0 holds |
| + // only memoria-owned content (§4.2), which has no app of record. |
| + sibs, err := memoria.LoadSiblings(os.Getenv) |
| + if err != nil { |
| + logger.Error("sibling roster", "err", err) |
| + os.Exit(1) |
| + } |
| + if len(sibs.Apps) > 0 { |
| + // nil dial: the module's team-bound transport does its own host |
| + // check before dialling. The hook is for tests. |
| + custody, err := memoria.NewSiblings(sibs.Team, nil, sibs.Apps) |
| + if err != nil { |
| + logger.Error("sibling roster", "err", err) |
| + os.Exit(1) |
| + } |
| + app.UseSiblings(custody) |
| + } |
| + // Names, never the roster itself: the structs carry consumer tokens, |
| + // and `carlos logs` is storage we cannot grep. |
| + logger.Info("siblings configured", "team", sibs.Team, "apps", sibs.Names()) |
| + |
| opts.Mux = mux |
| opts.DBPath = "" |
| if err := rastrillo.Serve(opts); err != nil { |
diff --git a/internal/memoria/config_siblings.go b/internal/memoria/config_siblings.go| new file mode 100644 |
| index 0000000..24af43c |
| --- /dev/null |
| +++ b/internal/memoria/config_siblings.go |
| @@ -0,0 +1,124 @@ |
| +package memoria |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| + "strings" |
| + |
| + "amadan.net/oficina/memoria/internal/memoria/sibling" |
| +) |
| + |
| +// SiblingConfig is the roster of apps of record memoria may ask about |
| +// passages, read from platform config. |
| +// |
| +// **The token is a separate variable from the address**, per app. Two |
| +// reasons, and neither is style. A roster is the sort of thing that gets |
| +// logged, printed by an operator, or pasted into a ticket while somebody |
| +// works out why a page is short; if the token travels in the same string |
| +// it goes with it. And rotation (§6.2's three phases) changes one |
| +// variable rather than rewriting a list, which is what makes a rotation |
| +// interruptible and resumable rather than an edit that is half-applied. |
| +type SiblingConfig struct { |
| + // Team is the label the transport binds to, so a call cannot leave |
| + // the team's own hosts (§6.4). Required as soon as one sibling is |
| + // configured, and meaningless without one. |
| + Team string |
| + Apps []sibling.Sibling |
| +} |
| + |
| +// LoadSiblings reads the roster. |
| +// |
| +// MEMORIA_TEAM=acme |
| +// MEMORIA_SIBLINGS=Docs=https://docs.acme.oficina.works,Sheets=https://sheets.acme.oficina.works |
| +// MEMORIA_SIBLING_TOKEN_DOCS=... |
| +// MEMORIA_SIBLING_TOKEN_SHEETS=... |
| +// |
| +// **No siblings is a valid configuration and not a warning.** Slice 0 |
| +// holds only memoria-owned content (§4.2), which has no app of record to |
| +// ask, so an empty roster is the ordinary state of an instance that has |
| +// not adopted a source yet. |
| +// |
| +// **A half-configured one is not.** Every refusal below is a case where |
| +// the alternative is silence: a sibling with no token, or no team to bind |
| +// the transport to, cannot be asked — and a sibling that cannot be asked |
| +// withholds every passage it owns. The reader sees a page that is |
| +// permanently short with a notice blaming an app that is, in fact, fine. |
| +// So it is refused at boot, where it is a startup error somebody reads, |
| +// rather than at query time, where it is a mystery. |
| +func LoadSiblings(getenv func(string) string) (SiblingConfig, error) { |
| + var c SiblingConfig |
| + c.Team = strings.TrimSpace(getenv("MEMORIA_TEAM")) |
| + |
| + raw := strings.TrimSpace(getenv("MEMORIA_SIBLINGS")) |
| + if raw == "" { |
| + // An instance with no siblings needs no team. Saying so here |
| + // rather than requiring MEMORIA_TEAM unconditionally keeps slice |
| + // 0's configuration exactly as small as slice 0 needs. |
| + return c, nil |
| + } |
| + |
| + seen := map[string]bool{} |
| + for _, entry := range strings.Split(raw, ",") { |
| + entry = strings.TrimSpace(entry) |
| + if entry == "" { |
| + continue |
| + } |
| + name, base, ok := strings.Cut(entry, "=") |
| + name, base = strings.TrimSpace(name), strings.TrimSpace(base) |
| + if !ok || name == "" || base == "" { |
| + return c, fmt.Errorf("%w: MEMORIA_SIBLINGS entry %q is not name=url", ErrMissing, entry) |
| + } |
| + if seen[name] { |
| + return c, fmt.Errorf("memoria: MEMORIA_SIBLINGS lists %q twice; the name is the key a document's source resolves through", name) |
| + } |
| + seen[name] = true |
| + |
| + // https only, and said here rather than left to the transport. |
| + // A consumer token on a cleartext hop is a credential handed to |
| + // anyone on the path, and the roster is where the address is |
| + // chosen. |
| + if !strings.HasPrefix(base, "https://") { |
| + return c, fmt.Errorf("memoria: sibling %q is configured as %q; a consumer token may not travel over cleartext", name, base) |
| + } |
| + |
| + token := strings.TrimSpace(getenv(tokenVar(name))) |
| + if token == "" { |
| + return c, fmt.Errorf("%w: %s for sibling %q. A sibling with no token cannot be asked, and one that cannot be asked withholds every passage it owns — silently short pages rather than a boot failure", ErrMissing, tokenVar(name), name) |
| + } |
| + c.Apps = append(c.Apps, sibling.Sibling{Name: name, BaseURL: strings.TrimRight(base, "/"), Token: token}) |
| + } |
| + |
| + if len(c.Apps) > 0 && c.Team == "" { |
| + return c, fmt.Errorf("%w: MEMORIA_TEAM, which binds the sibling transport to one team's hosts (§6.4). Without it there is nothing for the guard to compare a host against", ErrMissing) |
| + } |
| + sort.Slice(c.Apps, func(i, j int) bool { return c.Apps[i].Name < c.Apps[j].Name }) |
| + return c, nil |
| +} |
| + |
| +// tokenVar is the per-app token variable. Upper-cased with non-alphanumerics |
| +// folded to underscores, so a display name like "Team Docs" has one |
| +// spelling here and cannot be configured two ways. |
| +func tokenVar(name string) string { |
| + var b strings.Builder |
| + b.WriteString("MEMORIA_SIBLING_TOKEN_") |
| + for _, r := range strings.ToUpper(name) { |
| + switch { |
| + case r >= 'A' && r <= 'Z', r >= '0' && r <= '9': |
| + b.WriteRune(r) |
| + default: |
| + b.WriteByte('_') |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// Names lists the configured apps, for a log line that must never carry a |
| +// token. The tokens are in the same structs; this is the accessor that |
| +// exists so nobody reaches for %+v. |
| +func (c SiblingConfig) Names() []string { |
| + out := make([]string, 0, len(c.Apps)) |
| + for _, s := range c.Apps { |
| + out = append(out, s.Name) |
| + } |
| + return out |
| +} |
diff --git a/internal/memoria/config_siblings_test.go b/internal/memoria/config_siblings_test.go| new file mode 100644 |
| index 0000000..08241dc |
| --- /dev/null |
| +++ b/internal/memoria/config_siblings_test.go |
| @@ -0,0 +1,169 @@ |
| +package memoria |
| + |
| +import ( |
| + "errors" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func env(pairs map[string]string) func(string) string { |
| + return func(k string) string { return pairs[k] } |
| +} |
| + |
| +// The roster, and every way a half-configured one must refuse to boot. |
| +// |
| +// The shape of every refusal here is the same: **the alternative is |
| +// silence.** A sibling that cannot be asked withholds every passage it |
| +// owns, so a missing token or a missing team does not produce an error a |
| +// reader sees — it produces a page that is permanently short with a notice |
| +// blaming an app that is fine. Refused at boot, that is a startup message. |
| +// Left to query time, it is a mystery nobody can diagnose from the page. |
| +// |
| +// TWO-SIDED: the last case parses a good roster, or "refuses everything" |
| +// satisfies every assertion above it. |
| +// |
| +// MUTATION-TESTED 2026-09-06, each verified red then reverted: |
| +// |
| +// accept an entry with no token (empty Token in the struct) |
| +// -> the no-token case red |
| +// accept http:// as well as https:// |
| +// -> the cleartext case red |
| +// skip the duplicate check |
| +// -> the duplicate case red |
| +// default Team to "" and carry on |
| +// -> the no-team case red |
| +// return the apps unsorted |
| +// -> the ordering assertion red |
| +// require MEMORIA_TEAM even with no siblings |
| +// -> the empty-roster case red, which is slice 0's whole config |
| +func TestLoadSiblings(t *testing.T) { |
| + t.Run("no siblings is a configuration, not a warning", func(t *testing.T) { |
| + got, err := LoadSiblings(env(nil)) |
| + if err != nil { |
| + t.Fatalf("an instance with no siblings refused to boot: %v", err) |
| + } |
| + if len(got.Apps) != 0 { |
| + t.Errorf("got %d apps from an empty roster", len(got.Apps)) |
| + } |
| + }) |
| + |
| + t.Run("a sibling with no token is refused", func(t *testing.T) { |
| + _, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_TEAM": "acme", |
| + "MEMORIA_SIBLINGS": "Docs=https://docs.acme.oficina.works", |
| + })) |
| + if !errors.Is(err, ErrMissing) { |
| + t.Fatalf("err = %v, want ErrMissing", err) |
| + } |
| + if !strings.Contains(err.Error(), "MEMORIA_SIBLING_TOKEN_DOCS") { |
| + t.Errorf("the error does not name the variable to set: %v", err) |
| + } |
| + }) |
| + |
| + t.Run("a sibling on cleartext is refused", func(t *testing.T) { |
| + _, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_TEAM": "acme", |
| + "MEMORIA_SIBLINGS": "Docs=http://docs.acme.oficina.works", |
| + "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| + "MEMORIA_SIBLING_TOKEN_SHEET": "t", |
| + })) |
| + if err == nil { |
| + t.Error("a consumer token was allowed to travel over cleartext") |
| + } |
| + }) |
| + |
| + t.Run("a duplicate name is refused", func(t *testing.T) { |
| + _, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_TEAM": "acme", |
| + "MEMORIA_SIBLINGS": "Docs=https://a.acme.oficina.works,Docs=https://b.acme.oficina.works", |
| + "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| + })) |
| + if err == nil { |
| + t.Error("two apps configured under one name: one's item ids would " + |
| + "go to the other, and a conforming sibling answers NOT_VISIBLE " + |
| + "for ids it has no row for — the value that may be silent") |
| + } |
| + }) |
| + |
| + t.Run("siblings without a team are refused", func(t *testing.T) { |
| + _, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_SIBLINGS": "Docs=https://docs.acme.oficina.works", |
| + "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| + })) |
| + if !errors.Is(err, ErrMissing) { |
| + t.Fatalf("err = %v, want ErrMissing: without a team there is "+ |
| + "nothing for the host guard to compare against", err) |
| + } |
| + }) |
| + |
| + t.Run("a good roster parses, or none of the above proves anything", func(t *testing.T) { |
| + got, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_TEAM": "acme", |
| + "MEMORIA_SIBLINGS": " Sheets=https://sheets.acme.oficina.works/ , Docs=https://docs.acme.oficina.works ", |
| + "MEMORIA_SIBLING_TOKEN_DOCS": "docs-token", |
| + "MEMORIA_SIBLING_TOKEN_SHEETS": "sheets-token", |
| + })) |
| + if err != nil { |
| + t.Fatalf("a good roster was refused: %v", err) |
| + } |
| + if got.Team != "acme" { |
| + t.Errorf("team = %q, want acme", got.Team) |
| + } |
| + // Sorted, so a log line and a page's notices do not depend on the |
| + // order somebody happened to type the variable in. |
| + if len(got.Apps) != 2 || got.Apps[0].Name != "Docs" || got.Apps[1].Name != "Sheets" { |
| + t.Fatalf("apps = %+v, want Docs then Sheets", got.Names()) |
| + } |
| + if got.Apps[0].Token != "docs-token" || got.Apps[1].Token != "sheets-token" { |
| + t.Error("a token went to the wrong app, which would send one " + |
| + "sibling's credential to another") |
| + } |
| + // The trailing slash is trimmed, or every route becomes |
| + // `//suite/s1` — which some routers serve and some 404. |
| + if got.Apps[1].BaseURL != "https://sheets.acme.oficina.works" { |
| + t.Errorf("base url = %q, want no trailing slash", got.Apps[1].BaseURL) |
| + } |
| + }) |
| + |
| + t.Run("a display name maps to one token variable", func(t *testing.T) { |
| + // One spelling, so an app cannot be configured two ways and the |
| + // operator cannot set the variable that is never read. |
| + for _, c := range []struct{ name, want string }{ |
| + {"Docs", "MEMORIA_SIBLING_TOKEN_DOCS"}, |
| + {"Team Docs", "MEMORIA_SIBLING_TOKEN_TEAM_DOCS"}, |
| + {"sheets-2", "MEMORIA_SIBLING_TOKEN_SHEETS_2"}, |
| + } { |
| + if got := tokenVar(c.name); got != c.want { |
| + t.Errorf("tokenVar(%q) = %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// A roster must never be printed with its tokens in it. |
| +// |
| +// Names() exists so nobody reaches for %+v on the config, and this asserts |
| +// what it leaves out. §3.2's leak list and AGENTS.md's rule about logs: |
| +// `carlos logs` is platform-held storage we cannot grep, so a token that |
| +// reaches it is a credential that cannot be retracted — the same failure |
| +// as the sign-in links gate 4 was written for. |
| +func TestTheRosterCanBeLoggedWithoutItsTokens(t *testing.T) { |
| + got, err := LoadSiblings(env(map[string]string{ |
| + "MEMORIA_TEAM": "acme", |
| + "MEMORIA_SIBLINGS": "Docs=https://docs.acme.oficina.works", |
| + "MEMORIA_SIBLING_TOKEN_DOCS": "super-secret-token", |
| + })) |
| + if err != nil { |
| + t.Fatalf("LoadSiblings: %v", err) |
| + } |
| + // The presence half first: the token IS there to leak, so the absence |
| + // below is an assertion rather than a description of an empty world. |
| + if got.Apps[0].Token != "super-secret-token" { |
| + t.Fatal("the fixture has no token in it, so the check below proves nothing") |
| + } |
| + for _, n := range got.Names() { |
| + if strings.Contains(n, "super-secret-token") { |
| + t.Errorf("Names() carried a token: %q", n) |
| + } |
| + } |
| +} |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index 0f2c574..0d64b0b 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -4,6 +4,9 @@ import ( |
| "encoding/base64" |
| "errors" |
| "fmt" |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| "net/http" |
| "os" |
| "path/filepath" |
| @@ -445,8 +448,36 @@ func TestProductionDoesNotUseTheTestConstructor(t *testing.T) { |
| t.Errorf("%s calls the test seam %s; production must not reach it", p, seam) |
| } |
| } |
| - if !strings.Contains(string(raw), "UseAuth") && strings.Contains(string(raw), "memoria.App(") { |
| - t.Errorf("%s builds the app but never calls UseAuth", p) |
| + if !strings.Contains(string(raw), "memoria.App(") { |
| + return nil |
| + } |
| + // Every setter the app needs and cannot supply itself. |
| + // |
| + // UseAuth's omission serves every route unauthenticated, which is |
| + // the failure this gate was written for. The other two fail in the |
| + // other direction and are here anyway, because a failure that is |
| + // closed is not thereby a failure that is noticed: |
| + // |
| + // LoadSiblings omitted -> a roster is never read, so a |
| + // misconfigured one is never refused at boot and the instance |
| + // runs with a permanently short page instead. |
| + // UseSiblings omitted -> custody stays nil, every sibling |
| + // passage withholds, and the notice blames apps that are fine. |
| + // |
| + // Both present a working instance that quietly serves less than it |
| + // holds, and neither shows up in a test that only asks whether the |
| + // app starts. |
| + // |
| + // **Checked as calls, not as text.** A grep for the name passes on |
| + // `_ = memoria.LoadSiblings` — a mention, not a call — and a |
| + // review's mutation did exactly that and stayed green. The seam |
| + // scan above can stay a grep, because there the string appearing |
| + // at all is the problem; here the problem is the call NOT |
| + // happening, and only the parse can tell those apart. |
| + for _, call := range []string{"UseAuth", "LoadSiblings", "UseSiblings"} { |
| + if !callsFunction(t, p, raw, call) { |
| + t.Errorf("%s builds the app but never calls %s", p, call) |
| + } |
| } |
| return nil |
| }) |
| @@ -455,6 +486,41 @@ func TestProductionDoesNotUseTheTestConstructor(t *testing.T) { |
| } |
| } |
| +// callsFunction reports whether the file actually CALLS name, as opposed |
| +// to merely mentioning it. |
| +// |
| +// The distinction is the whole point: `memoria.LoadSiblings(os.Getenv)` is |
| +// a call, `_ = memoria.LoadSiblings` is a reference that does nothing, and |
| +// a grep cannot tell them apart. It matches either a bare `name(...)` or a |
| +// selector `x.name(...)`, since the wiring here is a mix of package |
| +// functions and methods on the app. |
| +func callsFunction(t *testing.T, path string, src []byte, name string) bool { |
| + t.Helper() |
| + f, err := parser.ParseFile(token.NewFileSet(), path, src, 0) |
| + if err != nil { |
| + t.Fatalf("parse %s: %v", path, err) |
| + } |
| + found := false |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + switch fn := call.Fun.(type) { |
| + case *ast.Ident: |
| + if fn.Name == name { |
| + found = true |
| + } |
| + case *ast.SelectorExpr: |
| + if fn.Sel.Name == name { |
| + found = true |
| + } |
| + } |
| + return true |
| + }) |
| + return found |
| +} |
| + |
| // Every colour in the app's own stylesheet is a token, so the reader's |
| // light/dark choice resolves through theme.css. A literal here paints a |
| // passage against the wrong ground in one theme and nobody notices until |