oficina / memoria Public

🤖 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
Paul Campbell pushed by paul@keymail.dev 3fdfb9ec0b42fe2103578d564b0c692553040528 parent 9207c0c
4 files changed, +392 −2
  • cmd/memoria/main.go +31 −0
  • internal/memoria/config_siblings.go +124 −0
  • internal/memoria/config_siblings_test.go +169 −0
  • internal/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