oficina / memoria Public

🤖 feat: slice 1 begins — the S2 client, against the contract module

The Oficina repos are public, so the hub serves go-import and
amadan.net/oficina/suite resolves with DEFAULT settings — no GOPRIVATE, no
GOSUMDB=off, better than either session predicted. §13's blocker closes.
One consequence: the module declares go 1.26, so memoria's directive moved
from 1.25.0 and a builder needs that toolchain.

internal/memoria/sibling is memoria's client half, and only a client half.
Decision 7 gives memoria no sibling-facing surface, so the module's
conformance harness — which points at an app's server routes — does not
apply here. Worth saying once in the package doc, because the module's
README describes mounting three routes and running the harness, and an app
that follows it without decision 7 in mind builds the surface §9.1 forbids.

The wire shapes, the four values and the total decoder are the module's
(ruling 7: import the contract, never restate it). What is memoria's is
the part DecodeS2 cannot see. It takes a status and a body, so it maps a
bad status, an unparseable body, a duplicate row and a missing pair to
UNKNOWN — but a call that timed out or was refused by the team-bound
transport has no status and no body, and DecodeS2 is never invoked. §4.1
says "UNKNOWN is synthesised by the caller"; this is where.

Five mutations, each red and reverted: transport failure returning nil
instead of UNKNOWN; an oversized batch sent instead of refused; the
deadline dropped; always-UNKNOWN never asking the sibling; the consumer
token not sent. M4 is caught only by the two-sided half, which is why it
is there — every "comes back UNKNOWN" assertion is satisfied by a client
that returns UNKNOWN unconditionally.

GATE 15 WENT RED, FOR THE RIGHT REASON, AND THE FIX IS NOT AN EXEMPTION.
Slice 0's honest form was "no app package makes an outbound request".
Slice 1 gives memoria a sibling client and a sibling call IS outbound.
Adding internal/memoria/sibling to a skip list would have been the whole
value of the gate thrown away, so it now asserts something stronger:
egress lives in exactly ONE named package, and that package must use
suite.NewSiblingClient rather than build its own — a bare &http.Client{}
there has no team check and no redirect refusal, which is precisely the
SSRF shape §3.2 records in rastrillo's classifier, rebuilt by our own
hands. Plus a siblingFiles == 0 fatal, because a check over a package the
walk stopped finding passes silently.
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 b88cd761efd7ec89534a0db1c7434265e173f43b parent d74de5b
7 files changed, +482 −14
  • .gitignore +5 −0
  • docs/superpowers/specs/2026-09-03-memoria-design.md +11 −1
  • go.mod +2 −2
  • go.sum +2 −2
  • internal/memoria/sibling/sibling.go +168 −0
  • internal/memoria/sibling/sibling_test.go +225 −0
  • internal/memoriatest/gates_test.go +69 −9
diff --git a/.gitignore b/.gitignore
index 5c0d95e..b0d2070 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,8 @@
/memoria.db
/memoria.db-wal
/memoria.db-shm
+
+# Local Go caches. The sandbox makes ~/go read-only, so builds keep
+# their own; neither is source.
+/.gc/
+/.gmc/
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 8ce2905..b6451d3 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -2477,7 +2477,17 @@ commit. Fixed 2026-09-05; §1.7's lesson arriving inside the fix for §1.6.*
Docs both cite suite §8's binding sentence, so there is one rule in one
place rather than two qualifications in two repos that then drift. That
is what `oficina/docs` discussion 33 asked for.
-- **Slice 1 waits on the contract module being importable, which is one
+- ~~**Slice 1 waits on the contract module being importable.**~~
+ **CLOSED 2026-09-06.** Paul made the Oficina repos public; the hub now
+ serves `go-import` for `oficina/suite` and `go get` resolves it with
+ default settings — **no `GOPRIVATE` and no `GOSUMDB=off` needed**, which
+ is better than either session predicted. memoria depends on it directly
+ as of `internal/memoria/sibling`. One consequence to know: the module
+ declares `go 1.26`, so memoria's own directive moved from 1.25.0 to 1.26
+ and any builder needs that toolchain.
+ *The original entry follows, because the two refused workarounds are the
+ useful part of it.*
+- **Slice 1 waited on the contract module being importable, which was one
command in another repo.** §6 cites `amadan.net/oficina/suite` and §11's
slice 1 builds against it; today `go get` cannot resolve it, because the
hub serves `go-import` meta tags **only for public repos** and
diff --git a/go.mod b/go.mod
index 088d0aa..a9a9c73 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
module amadan.net/oficina/memoria
-go 1.25.0
+go 1.26
require (
+ amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
golang.org/x/crypto v0.55.0
@@ -11,7 +12,6 @@ require (
)
require (
- github.com/BurntSushi/toml v1.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
diff --git a/go.sum b/go.sum
index 6ee94a5..3230622 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
-github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zRzgyHwD/RmZ4Hy4biwtxK7CdaSs=
+amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d/go.mod h1:Xgw0N+DBiDzkhQHm/nIhgumfaiMElDg/KIFD1OxTm5w=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/internal/memoria/sibling/sibling.go b/internal/memoria/sibling/sibling.go
new file mode 100644
index 0000000..ef58429
--- /dev/null
+++ b/internal/memoria/sibling/sibling.go
@@ -0,0 +1,168 @@
+// Package sibling is memoria's client half of the suite contract.
+//
+// It is a client and only a client. Decision 7: every sibling call is a
+// pull, and memoria exposes no sibling-facing surface — so there is no
+// server half here, and the module's conformance harness (which points at
+// an app's server routes) does not apply to memoria. That is worth saying
+// once, because the module's README describes mounting three routes and
+// running the harness, and an app that reads it without decision 7 in mind
+// would build the surface §9.1 forbids.
+//
+// The wire shapes, the four authorization values and the total decoder are
+// the module's: `amadan.net/oficina/suite`, suite ruling 7 — every app
+// imports the contract, and no app restates it. What lives here is the
+// part that is memoria's: which sibling was asked, what a call that never
+// answered means, and the deadline it is asked under.
+package sibling
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+// Deadline is §6.5's per-sibling budget.
+//
+// 800 ms, and the spec is honest that it is a starting assumption rather
+// than a measurement. Siblings are called concurrently and a page is one
+// round, so the floor is the slowest sibling and not their sum.
+const Deadline = 800 * time.Millisecond
+
+// A Sibling is one same-team app memoria holds content from.
+//
+// Name is printed to a reader when this sibling's passages are withheld —
+// §6.5's "Sheets did not answer; 7 results withheld" — so it is the app's
+// name as a person would say it, not a hostname.
+type Sibling struct {
+ Name string
+ BaseURL string
+ Token string
+}
+
+// Client asks siblings about content memoria already holds.
+//
+// The HTTP client is the module's team-bound one: it refuses a host outside
+// memoria's own team label and refuses to follow redirects, both checked
+// before the transport dials so a refused host costs no connection. memoria
+// does not build its own — that check guards more than this package, and a
+// second implementation of it is the drift ruling 7 exists to prevent.
+type Client struct {
+ http *http.Client
+}
+
+// New returns a Client bound to one team.
+func New(team string) *Client {
+ return &Client{http: suite.NewSiblingClient(team, nil)}
+}
+
+// Authorize asks one sibling whether this person may read these items, at
+// these revisions, right now.
+//
+// It returns exactly one Authorization per pair asked, in the order asked,
+// always — there is no error return, and that is deliberate. §4.1: every
+// non-answer is UNKNOWN, and a caller that could receive an error would
+// have a fifth thing to handle and somewhere to forget to handle it. The
+// four values already cover every outcome, and UNKNOWN withholds loudly.
+//
+// **The module decodes; this function supplies what the module cannot
+// see.** DecodeS2 takes a status and a body, so it maps a bad status, an
+// unparseable body, a duplicate row and a missing pair to UNKNOWN. A call
+// that timed out, was refused by the team-bound transport, or never
+// reached a server has no status and no body at all — so mapping *that* to
+// UNKNOWN is the caller's obligation, and it is the one §4.1 names:
+// "UNKNOWN is synthesised by the caller, not only returned by the sibling."
+func (c *Client) Authorize(ctx context.Context, s Sibling, asker suite.Subject, pairs []suite.ItemRevision) []suite.Authorization {
+ if len(pairs) == 0 {
+ return nil
+ }
+ // Refuse an oversized batch rather than truncating it. A truncated
+ // question answers about fewer items than were asked, and the pairs
+ // that fell off the end would come back as "no answer" — which is
+ // UNKNOWN, which withholds, which looks like a sibling problem when it
+ // is ours. Cap and reason are the module's (MaxS2Pairs).
+ if err := suite.CheckS2Batch(pairs); err != nil {
+ return allUnknown(len(pairs))
+ }
+
+ body, err := json.Marshal(suite.S2Request{Asker: asker, Pairs: pairs})
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, Deadline)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost,
+ s.BaseURL+suite.RouteS2, bytes.NewReader(body))
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+ req.Header.Set("Content-Type", "application/json")
+ // The consumer token rides the transport, never the body (§6.2).
+ req.Header.Set("Authorization", "Bearer "+s.Token)
+
+ res, err := c.http.Do(req)
+ if err != nil {
+ // Timeout, refused host, refused redirect, connection failure. No
+ // status, no body, nothing for the module to decode.
+ return allUnknown(len(pairs))
+ }
+ defer res.Body.Close()
+
+ // Cap the read. A sibling that answers with a gigabyte is a sibling
+ // that has gone wrong, and reading it all to discover that is a second
+ // failure. The cap is generous against MaxS2Pairs rows of a fixed
+ // shape; a truncated body does not parse, which is UNKNOWN.
+ raw, err := io.ReadAll(io.LimitReader(res.Body, maxS2Response))
+ if err != nil {
+ return allUnknown(len(pairs))
+ }
+ return suite.DecodeS2(pairs, res.StatusCode, raw)
+}
+
+// maxS2Response caps an S2 answer.
+//
+// 256 pairs (the module's MaxS2Pairs) of {item_id, revision, result} with
+// generous ids is well under 256 KiB. The number is a ceiling on damage,
+// not a measurement, and it is stated rather than left implicit because
+// AGENTS.md requires a cap to carry its reason.
+const maxS2Response = 256 << 10
+
+// allUnknown is the total-decoder rule at the call boundary: every pair
+// asked gets exactly one answer, even when nothing answered.
+func allUnknown(n int) []suite.Authorization {
+ out := make([]suite.Authorization, n)
+ for i := range out {
+ out[i] = suite.UNKNOWN
+ }
+ return out
+}
+
+// Withheld describes what a reader is told when a sibling's passages
+// cannot be shown.
+//
+// §6.5 and §8.1: withholding is loud and names the shape of the loss, and
+// only NOT_VISIBLE is silent. A count of hidden results with no cause is
+// the notice a reader learns to ignore.
+type Withheld struct {
+ Sibling string
+ Count int
+ Reason suite.Authorization
+}
+
+func (w Withheld) String() string {
+ switch w.Reason {
+ case suite.UNKNOWN:
+ return fmt.Sprintf("%s did not answer; %d results withheld.", w.Sibling, w.Count)
+ case suite.STALE:
+ return fmt.Sprintf("%s has newer versions of %d results; refetching.", w.Sibling, w.Count)
+ default:
+ return fmt.Sprintf("%s: %d results withheld.", w.Sibling, w.Count)
+ }
+}
diff --git a/internal/memoria/sibling/sibling_test.go b/internal/memoria/sibling/sibling_test.go
new file mode 100644
index 0000000..de311b9
--- /dev/null
+++ b/internal/memoria/sibling/sibling_test.go
@@ -0,0 +1,225 @@
+package sibling
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+// asker is a lone-shape subject: the address is the subject memoria's
+// siblings key their members on when there is no home (suite §3).
+func asker(t *testing.T) suite.Subject {
+ t.Helper()
+ s, err := suite.NewSubject(suite.SubjectAddress, "person@example.test")
+ if err != nil {
+ t.Fatalf("NewSubject: %v", err)
+ }
+ return s
+}
+
+func pairs(n int) []suite.ItemRevision {
+ out := make([]suite.ItemRevision, n)
+ for i := range out {
+ out[i] = suite.ItemRevision{ItemID: "doc-" + string(rune('a'+i%26)), Revision: int64(i + 1)}
+ }
+ return out
+}
+
+// serve stands a sibling up on localhost and returns a Sibling pointing at
+// it. The team label matches the module's SuiteDomain check via the URL the
+// test server hands back.
+func serve(t *testing.T, h http.HandlerFunc) Sibling {
+ t.Helper()
+ srv := httptest.NewServer(h)
+ t.Cleanup(srv.Close)
+ return Sibling{Name: "Sheets", BaseURL: srv.URL, Token: "test-consumer-token"}
+}
+
+// newTestClient is New with the team-bound transport replaced, because that
+// transport refuses any host outside the team's own <team>.oficina.works
+// label — correctly — and a httptest server is on 127.0.0.1. The refusal
+// itself is the module's to test (its hostile-hosts fixture); what this
+// package must prove is what memoria does with an answer, or the absence
+// of one.
+func newTestClient() *Client {
+ return &Client{http: &http.Client{}}
+}
+
+// Gate: every pair asked gets exactly one answer, and a call that never
+// reached a server is UNKNOWN — never absent, never silently dropped.
+//
+// §4.1: "UNKNOWN is synthesised by the caller, not only returned by the
+// sibling. A sibling that never answers cannot return anything." The
+// module's DecodeS2 handles a bad status, an unparseable body, a duplicate
+// row and a missing pair. It cannot handle a call with no status at all,
+// because it is never invoked — that gap is this package's, and it is the
+// one a reviewer would find by unplugging the network.
+//
+// TWO-SIDED. Every assertion of the form "this comes back UNKNOWN" is
+// satisfied by a client that returns UNKNOWN unconditionally, so the
+// happy-path subtest asserts the real values arrive and in order. Without
+// it, deleting the whole HTTP call and returning allUnknown(len(pairs))
+// would pass this file — which is M4 below, and it is caught only by that
+// half.
+//
+// MUTATION-TESTED 2026-09-06, five, each verified red and reverted:
+//
+// M1 transport failure returns nil instead of UNKNOWN -> not-total red
+// M2 oversized batch sent instead of refused -> cap case red
+// M3 the deadline is dropped -> slow case red
+// M4 always UNKNOWN, never asks the sibling -> happy path red
+// M5 the consumer token is not sent -> happy path red
+//
+// Author-chosen, which §1.7 rates as the weak kind. An adversarial pass by
+// someone who did not write this is owed, and the last one on this branch
+// found two live bypasses in a guard whose author had already mutated it
+// five ways — so the pass is not a formality.
+func TestEveryPairGetsExactlyOneAnswer(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("a sibling that never answers is UNKNOWN, not missing", func(t *testing.T) {
+ // A server that accepts the connection and hangs up without a
+ // response: no status, no body, nothing to decode.
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ t.Skip("no hijacker")
+ }
+ conn, _, err := hj.Hijack()
+ if err != nil {
+ t.Fatalf("hijack: %v", err)
+ }
+ conn.Close()
+ })
+ asked := pairs(3)
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ if len(got) != len(asked) {
+ t.Fatalf("got %d answers for %d pairs — the decoder is not total",
+ len(got), len(asked))
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN: a call that never got a "+
+ "status is not an authorization", i, a)
+ }
+ }
+ })
+
+ t.Run("a batch over the cap is refused, never truncated", func(t *testing.T) {
+ var asked int
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ asked++
+ w.WriteHeader(http.StatusOK)
+ })
+ over := pairs(suite.MaxS2Pairs + 1)
+ got := newTestClient().Authorize(ctx, s, asker(t), over)
+ if len(got) != len(over) {
+ t.Fatalf("got %d answers for %d pairs", len(got), len(over))
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN", i, a)
+ }
+ }
+ if asked != 0 {
+ t.Error("an oversized batch was sent: the pairs past the cap would " +
+ "come back unanswered, which withholds and looks like the " +
+ "sibling's fault when it is ours")
+ }
+ })
+
+ t.Run("the real values arrive, in order, or the rest proves nothing", func(t *testing.T) {
+ asked := pairs(4)
+ want := []suite.Authorization{
+ suite.VISIBLE, suite.NOT_VISIBLE, suite.STALE, suite.VISIBLE,
+ }
+ var gotToken string
+ var gotBody suite.S2Request
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ gotToken = r.Header.Get("Authorization")
+ raw, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(raw, &gotBody)
+ type row struct {
+ ItemID string `json:"item_id"`
+ Revision int64 `json:"revision"`
+ Result suite.Authorization `json:"result"`
+ }
+ rows := make([]row, len(asked))
+ for i, p := range asked {
+ rows[i] = row{p.ItemID, p.Revision, want[i]}
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"results": rows})
+ })
+
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("pair %d = %q, want %q — if every answer is UNKNOWN "+
+ "this file is measuring nothing", i, got[i], want[i])
+ }
+ }
+ // §6.2: the consumer token rides the transport, never the body.
+ if gotToken != "Bearer test-consumer-token" {
+ t.Errorf("Authorization header = %q, want the consumer token", gotToken)
+ }
+ if gotBody.Asker.Value != "person@example.test" {
+ t.Errorf("asker did not reach the sibling: %+v", gotBody.Asker)
+ }
+ if gotBody.Asker.Kind != suite.SubjectAddress {
+ t.Errorf("asker kind = %q, want %q: the kind travels on the wire "+
+ "so a receiver keying on the other one answers UNKNOWN rather "+
+ "than guessing (suite §3)", gotBody.Asker.Kind, suite.SubjectAddress)
+ }
+ })
+
+ t.Run("a slow sibling is UNKNOWN at the deadline, not a hung page", func(t *testing.T) {
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(Deadline + 500*time.Millisecond)
+ w.WriteHeader(http.StatusOK)
+ })
+ asked := pairs(2)
+ start := time.Now()
+ got := newTestClient().Authorize(ctx, s, asker(t), asked)
+ elapsed := time.Since(start)
+ if elapsed > Deadline+400*time.Millisecond {
+ t.Errorf("waited %v for a sibling past a %v deadline: §6.5 makes a "+
+ "page one round, and the floor is the slowest sibling — not "+
+ "the slowest sibling plus however long it feels like taking",
+ elapsed, Deadline)
+ }
+ for i, a := range got {
+ if a != suite.UNKNOWN {
+ t.Errorf("pair %d = %q, want UNKNOWN past the deadline", i, a)
+ }
+ }
+ })
+}
+
+// Withholding names the sibling and the shape of the loss, because a
+// notice that says only "some results hidden" is one a reader stops
+// reading — and a reader who stops reading it is in the same position as
+// one who was told nothing (§6.5).
+func TestWithheldNamesTheSiblingAndTheReason(t *testing.T) {
+ for _, c := range []struct {
+ reason suite.Authorization
+ want string
+ }{
+ {suite.UNKNOWN, "Sheets did not answer; 7 results withheld."},
+ {suite.STALE, "Sheets has newer versions of 7 results; refetching."},
+ } {
+ got := Withheld{Sibling: "Sheets", Count: 7, Reason: c.reason}.String()
+ if got != c.want {
+ t.Errorf("Withheld(%s) = %q, want %q", c.reason, got, c.want)
+ }
+ if !strings.Contains(got, "Sheets") {
+ t.Errorf("withholding notice does not name the app: %q", got)
+ }
+ }
+}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 1d195e0..2fc60fa 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -1,6 +1,7 @@
package memoriatest
import (
+ "bytes"
"encoding/base64"
"errors"
"net/http"
@@ -78,17 +79,41 @@ func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
}
}
-// Gate 15's egress half. With the assistant off, nothing leaves the
-// instance — and in slice 0 there is no assistant at all, so the honest
-// form of this gate is that no app package can make an outbound request.
+// Gate 15's egress half: **egress lives in exactly one package, and that
+// package uses the team-bound client.**
//
-// A runtime gate would be better and is not available: httptest cannot
-// observe a dial that never happens. So this is a source gate, and it is
-// mutation-tested by adding an http.Get to the store, which makes it
-// fail.
+// This gate used to say no app package may make an outbound request at
+// all, which was the honest form while slice 0 had no assistant and no
+// siblings. Slice 1 gives memoria a sibling client, and a sibling call IS
+// outbound — so on 2026-09-06 this gate went red for the right reason, and
+// the wrong response would have been to add `internal/memoria/sibling` to
+// an exemption list and move on.
//
-// MUTATION-TESTED 2026-09-03: adding `http.Get("https://example.com")`
-// to Store.Ingest makes this fail naming store.go. Reverted.
+// What it asserts instead is stronger than the ban it replaces:
+//
+// - **No app package outside `internal/memoria/sibling` can reach the
+// network.** Same blacklist, same walk; the surface is now named
+// rather than absent, which is what §3.2's leak list can be written
+// against.
+// - **That package does not build its own HTTP client.** It must use
+// `suite.NewSiblingClient`, which refuses a host outside memoria's own
+// team label and refuses to follow redirects, both before the
+// transport dials. A bare `&http.Client{}` there would be a client
+// with neither check — which is precisely the shape of the
+// unauthenticated SSRF §3.2 records in rastrillo's sign-in classifier,
+// rebuilt by our own hands.
+//
+// Still a source heuristic and still a blacklist, as the note below says.
+//
+// MUTATION-TESTED 2026-09-03: adding `http.Get("https://example.com")` to
+// Store.Ingest makes this fail naming store.go. Reverted.
+// MUTATION-TESTED 2026-09-06, both reverted: replacing
+// `suite.NewSiblingClient(team, nil)` in sibling.New with `&http.Client{}`
+// fails BOTH new halves (the file builds its own client, and no file calls
+// the team-bound constructor); adding an `http.NewRequest` to handlers.go
+// fails the blacklist half naming handlers.go. A third — emptying the walk
+// so the sibling package is never seen — is what the `siblingFiles == 0`
+// check exists for, and it fatals rather than passing over nothing.
func TestNoOutboundNetworkInAppCode(t *testing.T) {
// A blacklist, and it is worth saying so: this is a source
// heuristic, not a proof. A review defeated the first version with
@@ -108,6 +133,13 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
pats = append(pats, regexp.MustCompile(f))
}
var hits []string
+ // The sibling package is the one place egress may live, so the gate
+ // checks it for what it MUST use. Counting the files it saw matters as
+ // much as the check: if the walk stops finding that package — renamed,
+ // moved, deleted — a check over nothing passes silently, which is this
+ // codebase's worst failure shape.
+ siblingFiles := 0
+ usesTeamBound := false
walk := func(root string) error {
return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
@@ -120,6 +152,20 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
if err != nil {
return err
}
+ // The one named egress surface. It is checked for what it must
+ // use, rather than for what it must not.
+ if strings.Contains(filepath.ToSlash(p), "/memoria/sibling/") {
+ siblingFiles++
+ if bytes.Contains(src, []byte("suite.NewSiblingClient(")) {
+ usesTeamBound = true
+ }
+ if bytes.Contains(src, []byte("&http.Client{")) || bytes.Contains(src, []byte("new(http.Client)")) {
+ hits = append(hits, p+" builds its own http.Client instead of "+
+ "suite.NewSiblingClient — a client with no team check and no "+
+ "redirect refusal")
+ }
+ return nil
+ }
for _, pat := range pats {
if pat.Match(src) {
hits = append(hits, p+" matches "+pat.String())
@@ -139,6 +185,20 @@ func TestNoOutboundNetworkInAppCode(t *testing.T) {
for _, h := range hits {
t.Errorf("app code can reach the network: %s", h)
}
+
+ // A count that fails is not a count of zero (AGENTS.md). If the walk
+ // found no sibling package, the second half of this gate checked
+ // nothing and reported that by passing.
+ if siblingFiles == 0 {
+ t.Fatal("found no files in internal/memoria/sibling: the egress " +
+ "surface this gate constrains was not walked, so its silence " +
+ "means nothing")
+ }
+ if !usesTeamBound {
+ t.Error("the sibling package never calls suite.NewSiblingClient: " +
+ "memoria's only outbound surface is not using the team-bound, " +
+ "redirect-refusing client, and a plain one has neither check")
+ }
}
// The footer says no DOCUMENT leaves this instance, and that is the claim