oficina / memoria Public

🤖 feat: gate 24's wake warm-up, asserted as presence rather than a race

§6.5: a cold sibling is a wake, and a wake is slower than a deadline, so
without this the first search after a quiet period reliably withholds —
the reader pays the sibling's cold start and gets a notice that is
technically honest and completely useless. memoria now polls every
configured sibling on its own wake, where nobody is waiting.

**S1, not S2 or S7.** Draft 3 said the warm-up fired those and neither
call can be made: both take a person, and there is nobody on a wake.
Gate 24 is what makes that catchable — a warm-up naming a person is one
no fixture could ever have received, so its absence was invisible.

**The gate asserts unprompted presence and issues no query at all**,
which is the spec's own resolution of a shape that cannot be written
either way. Asynchronous, "before the handler served a request" is a race
between goroutines. Synchronous, satisfying it means the first query
waits for three sibling wakes — putting the latency back on the reader
and undoing the point. So the claim is that the polls happen on their
own, within a stated two seconds, with nothing to prompt them.

One dead sibling does not stop the others being warmed, and a poll
failure is not returned: there is nobody to return it to on a wake, and
the reader learns about it from the withholding notice on the page.

Five mutations. Two survived and both were worth the pass:

- **`WarmSiblings` was never exercised.** The gate drove `Siblings.Warm`
  directly, so emptying the method production actually calls left every
  warm-up test green. It now has its own test.
- **Logging the poll error verbatim** stayed green — and that one is
  **recorded as a survivor rather than fixed**, because it is not a
  defect. Every error the module raises from a bad S1 or S3 names a
  status, a field or a row index and never the body; checked in s1.go and
  s3.go rather than assumed. The error is still not logged, for the
  reason AGENTS.md gives about a provider's 4xx: that safety is somebody
  else's discretion about their own error strings, and this side should
  not depend on it.

The canary test that came out of chasing it stays, because a sibling's
response body *is* content and the warm-up is the one path that handles
an error with nobody to hand it to — which is precisely where the
temptation to log it lives.

`make ci` green.
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 5adafc0ace88db47dfae9d4720259f9ae4cc0630 parent 1fc0f93
5 files changed, +371 −1
  • cmd/memoria/main.go +5 −0
  • internal/memoria/app.go +24 −0
  • internal/memoria/custody.go +47 −0
  • internal/memoria/warmup_test.go +294 −0
  • internal/memoriatest/gates_test.go +1 −1
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go
index 8e121f7..4255a1c 100644
--- a/cmd/memoria/main.go
+++ b/cmd/memoria/main.go
@@ -3,6 +3,7 @@
package main
import (
+ "context"
"log/slog"
"os"
@@ -112,6 +113,10 @@ func main() {
os.Exit(1)
}
app.UseSiblings(custody)
+ // §6.5's wake poll, started here and not awaited: the point is to
+ // spend a cold sibling's latency where nobody is waiting, and
+ // waiting for it would put that latency back on the first reader.
+ app.WarmSiblings(context.Background())
}
// Names, never the roster itself: the structs carry consumer tokens,
// and `carlos logs` is storage we cannot grep.
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index 206cb24..7fcfc3a 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -214,6 +214,30 @@ func (a *app) isOwner(addr string) bool {
// the first reader, not silent.
func (a *app) UseSiblings(c Custody) { a.store.custody = c }
+// WarmSiblings starts §6.5's wake poll and returns immediately.
+//
+// **Asynchronous on purpose, and gate 24 is written around that.** A
+// synchronous warm-up would make the first request wait for three sibling
+// wakes, which puts the latency back on the reader and undoes the whole
+// point; an assertion that it finishes "before the first query" is either
+// that, or a race between goroutines. So the gate asserts UNPROMPTED
+// PRESENCE instead — every fixture is polled within a stated few seconds
+// of boot, with no query issued at all — and the claim is that the poll
+// happens on its own rather than that it wins a race.
+//
+// context.WithoutCancel: the poll outlives the request context that
+// happened to be current, because it belongs to the process's wake and
+// not to anybody's request.
+func (a *app) WarmSiblings(ctx context.Context) {
+ s, ok := a.store.custody.(*Siblings)
+ if !ok || s == nil {
+ // No siblings configured (slice 0), or a Custody that is not the
+ // production roster. Nothing to warm, and not an error.
+ return
+ }
+ go s.Warm(context.WithoutCancel(ctx), a.store, a.logger)
+}
+
// SetOwners supplies the owner list. Called by main from Config.
func (a *app) SetOwners(owners []string) { a.owners = owners }
diff --git a/internal/memoria/custody.go b/internal/memoria/custody.go
index d3e2dfa..6d4305a 100644
--- a/internal/memoria/custody.go
+++ b/internal/memoria/custody.go
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
+ "log/slog"
+ "sync"
"amadan.net/oficina/suite"
@@ -81,3 +83,48 @@ func (s *Siblings) Sibling(source string) (sibling.Sibling, bool) {
func (s *Siblings) Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition {
return s.client.Rule(ctx, asker, cands)
}
+
+// Warm polls every configured sibling once, concurrently, and returns
+// when they have all finished or failed.
+//
+// §6.5: **a cold sibling is a wake, and a wake is slower than a
+// deadline.** Without this, the first search after a quiet period
+// reliably withholds — the reader pays for the sibling's cold start, and
+// the notice they get is technically honest and completely useless. So
+// memoria spends that latency on its own wake, where nobody is waiting.
+//
+// **S1, not S2 or S7.** Draft 3 said the warm-up fired those, and neither
+// call can be made here: both take a person, and there is nobody on a
+// wake. S1 is the call that already has no asker, it is the one memoria
+// makes anyway, and warming a sibling is warming a sibling whichever
+// endpoint does it. §6.5 records this correction; gate 24 is why it could
+// not have been caught otherwise, since a warm-up naming a person is one
+// no fixture could ever have received.
+//
+// Errors are logged and not returned. One unreachable sibling must not
+// stop the others being warmed, and there is nobody to return an error to
+// on a wake — the reader learns about it from the withholding notice on
+// the page, which is where §6.5 puts it.
+func (s *Siblings) Warm(ctx context.Context, store *Store, logger *slog.Logger) {
+ var wg sync.WaitGroup
+ for _, app := range s.byName {
+ wg.Add(1)
+ go func(sib sibling.Sibling) {
+ defer wg.Done()
+ res, err := store.Poll(ctx, s.client, sib)
+ if err != nil {
+ // The app's name and nothing else. A poll error can carry
+ // a sibling's response body, and a body is content
+ // (AGENTS.md) — so the error is not logged, only that
+ // there was one.
+ logger.Warn("sibling poll failed", "app", sib.Name)
+ return
+ }
+ // Counts only: no item id, no title, no body.
+ logger.Info("sibling polled", "app", sib.Name,
+ "stored", res.Stored, "deleted", res.Deleted,
+ "missing", res.Missing, "more", res.More)
+ }(app)
+ }
+ wg.Wait()
+}
diff --git a/internal/memoria/warmup_test.go b/internal/memoria/warmup_test.go
new file mode 100644
index 0000000..b97c058
--- /dev/null
+++ b/internal/memoria/warmup_test.go
@@ -0,0 +1,294 @@
+package memoria
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/suite"
+
+ "amadan.net/oficina/memoria/internal/memoria/sibling"
+)
+
+// warmWindow is gate 24's N: how long after the app comes up every
+// configured sibling must have been polled, unprompted.
+//
+// **A stated constant in the low seconds, and deliberately not a race
+// against anything.** Two seconds is far longer than three local polls
+// need and far shorter than "eventually", so it fails a warm-up that was
+// deleted or made lazy without failing on a loaded machine.
+const warmWindow = 2 * time.Second
+
+// Gate 24: **every configured sibling is polled on the wake, with no
+// query issued at all.**
+//
+// §6.5: a cold sibling is a wake, and a wake is slower than a deadline, so
+// the first search after a quiet period reliably withholds unless somebody
+// has already paid that cost. memoria pays it on its own wake, where
+// nobody is waiting.
+//
+// **The assertion is unprompted presence, not an ordering**, and the spec
+// works through why the ordering version cannot be written. Asynchronous,
+// "before the handler served a request" is a race between goroutines and
+// the gate is flaky. Synchronous, satisfying it means the first query
+// waits for three sibling wakes — which puts the latency back on the
+// reader and undoes the entire reason for warming. So: no query is issued
+// here, and the claim is that the polls happen on their own.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// WarmSiblings returns without starting anything
+// -> every fixture unpolled; red
+// the warm-up fires lazily on the first query instead
+// -> red, because this test issues no query — which is the
+// assertion the ordering version could not make
+// Warm polls only the first sibling in the roster
+// -> red on the other two
+// Warm calls Authorize instead of Poll
+// -> red: S2 needs an asker and there is nobody on a wake, so the
+// fixtures record no S1 at all. This is the mistake draft 3 made
+// and the one no fixture could have received.
+func TestEveryConfiguredSiblingIsPolledOnTheWake(t *testing.T) {
+ var polled [3]int64
+ apps := make([]sibling.Sibling, 0, 3)
+ for i, name := range []string{"Docs", "Sheets", "Calendar"} {
+ counter := &polled[i]
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != suite.RouteS1 {
+ t.Errorf("%s received %s %s on a wake; the warm-up is S1, and "+
+ "S2 and S7 both need a person who does not exist here",
+ name, r.Method, r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ atomic.AddInt64(counter, 1)
+ _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`))
+ }))
+ t.Cleanup(srv.Close)
+ apps = append(apps, sibling.Sibling{Name: name, BaseURL: srv.URL, Token: "t"})
+ }
+
+ s := storeForTest(t)
+ custody := &Siblings{client: sibling.NewForTest(), byName: map[string]sibling.Sibling{}}
+ for _, a := range apps {
+ custody.byName[a.Name] = a
+ }
+ s.custody = custody
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ start := time.Now()
+ go custody.Warm(context.Background(), s, logger)
+
+ // **No query is issued anywhere in this test.** That is the whole
+ // point: a lazily-fired warm-up has nothing to fire on.
+ deadline := time.Now().Add(warmWindow)
+ for {
+ all := true
+ for i := range polled {
+ if atomic.LoadInt64(&polled[i]) == 0 {
+ all = false
+ }
+ }
+ if all {
+ break
+ }
+ if time.Now().After(deadline) {
+ for i, name := range []string{"Docs", "Sheets", "Calendar"} {
+ if atomic.LoadInt64(&polled[i]) == 0 {
+ t.Errorf("%s was never polled within %v of the wake: the "+
+ "first reader after a quiet period pays that sibling's "+
+ "cold start and gets a withholding notice for it",
+ name, warmWindow)
+ }
+ }
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Logf("three siblings warmed in %v", time.Since(start))
+}
+
+// The warm-up survives a sibling that is down, because one unreachable
+// app must not stop the others being warmed.
+//
+// The failure is not returned and not fatal: there is nobody to return it
+// to on a wake, and the reader learns about it from the withholding notice
+// on the page, which is where §6.5 puts it.
+//
+// MUTATION-TESTED 2026-09-07: return from Warm on the first error and the
+// reachable sibling goes unpolled. Reverted.
+func TestOneDeadSiblingDoesNotStopTheWarmUp(t *testing.T) {
+ // One server per app, so the assertion is "which siblings were
+ // reached" and not "how many calls arrived". A first poll costs two S1
+ // calls — one to take the cursor forward, one to confirm nothing
+ // follows it — so a call count answers a different question than the
+ // one this test asks.
+ var docs, also int64
+ serve := func(counter *int64) string {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(counter, 1)
+ _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`))
+ }))
+ t.Cleanup(srv.Close)
+ return srv.URL
+ }
+
+ custody := &Siblings{
+ client: sibling.NewForTest(),
+ byName: map[string]sibling.Sibling{
+ // Port 1 is reserved and nothing listens there.
+ "Dead": {Name: "Dead", BaseURL: "http://127.0.0.1:1", Token: "t"},
+ "Docs": {Name: "Docs", BaseURL: serve(&docs), Token: "t"},
+ "AlsoUp": {Name: "AlsoUp", BaseURL: serve(&also), Token: "t"},
+ },
+ }
+ s := storeForTest(t)
+ s.custody = custody
+
+ custody.Warm(context.Background(), s, slog.New(slog.NewTextHandler(io.Discard, nil)))
+
+ if atomic.LoadInt64(&docs) == 0 || atomic.LoadInt64(&also) == 0 {
+ t.Errorf("Docs polled %d times, AlsoUp %d: one app being down must not "+
+ "leave the others cold, and Warm returning on the first error is "+
+ "how that happens", docs, also)
+ }
+}
+
+// **A sibling's response body is content, and it must not reach a log.**
+//
+// AGENTS.md states this for a model provider's 4xx, which can echo the
+// input that caused it; a sibling's error body is the same shape of
+// hazard and closer to home, because the thing it can echo is a passage.
+// `carlos logs` is platform-held storage we cannot grep, so a body that
+// lands there is a disclosure nobody can retract.
+//
+// The warm-up is where this bites: it is the one path that handles a poll
+// error with nobody to return it to, so the tempting thing to do with the
+// error is log it. A review's mutation added `"err", err` to that line and
+// every test stayed green.
+//
+// PRESENCE FIRST, per §10's second rule: the sibling really does put the
+// canary in a body memoria really does read, so the absence check below is
+// an assertion rather than a description of an empty world.
+//
+// MUTATION-TESTED 2026-09-07:
+//
+// the error is wrapped with the response body before it is logged
+// -> red, the canary in the log
+//
+// **And one that SURVIVES, recorded rather than hidden**, because a gate's
+// coverage is a claim like any other:
+//
+// logger.Warn(..., "err", err) in Warm
+// -> GREEN, and it is not a defect. Every error the module produces
+// from a bad S1 or S3 names a status, a field or a row index and
+// never the body — checked, in s1.go and s3.go, rather than
+// assumed. So logging the error today leaks nothing.
+//
+// The error is still not logged, for the reason AGENTS.md gives about a
+// provider's 4xx: the safety here belongs to somebody else's discretion
+// about their own error strings, and this side should not depend on it.
+// What this test defends is memoria's own handling; the module's part of
+// it is the module's to keep.
+func TestASiblingsErrorBodyNeverReachesTheLog(t *testing.T) {
+ const canary = "SALARY-BANDS-Q3-CONFIDENTIAL"
+
+ var served int64
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(&served, 1)
+ // A sibling erroring while echoing content back. Not exotic: an
+ // OpenAI-compatible 4xx does exactly this, and so does any handler
+ // that renders the request into its error page.
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":"could not index ` + canary + `"}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ var logged strings.Builder
+ logger := slog.New(slog.NewTextHandler(&logged, &slog.HandlerOptions{Level: slog.LevelDebug}))
+
+ custody := &Siblings{
+ client: sibling.NewForTest(),
+ byName: map[string]sibling.Sibling{
+ "Docs": {Name: "Docs", BaseURL: srv.URL, Token: "t"},
+ },
+ }
+ s := storeForTest(t)
+ s.custody = custody
+ custody.Warm(context.Background(), s, logger)
+
+ // (a) the body was really served and really read.
+ if atomic.LoadInt64(&served) == 0 {
+ t.Fatal("the sibling was never called, so nothing below is an assertion")
+ }
+ if logged.Len() == 0 {
+ t.Fatal("nothing was logged at all; the absence check below would " +
+ "pass against a logger that swallowed everything")
+ }
+ if !strings.Contains(logged.String(), "Docs") {
+ t.Error("the failure was not reported at all — silence is not the fix")
+ }
+
+ // (b) and the canary is not in it.
+ if strings.Contains(logged.String(), canary) {
+ t.Errorf("a sibling's error body reached the log:\n%s", logged.String())
+ }
+}
+
+// WarmSiblings starts the poll, and this is what says so.
+//
+// Gate 24 above drives `Siblings.Warm` directly, which leaves the method
+// production actually calls untested: a review's mutation emptied
+// `WarmSiblings` and every warm-up test stayed green, because none of them
+// went through it.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// WarmSiblings returns without starting the goroutine
+// -> red
+// WarmSiblings polls synchronously
+// -> green, and deliberately not a finding: this asserts the poll
+// happens, not how. §6.5's reason for asynchrony is latency, and
+// gate 23 is where latency is held.
+func TestWarmSiblingsStartsThePoll(t *testing.T) {
+ var polled int64
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(&polled, 1)
+ _, _ = w.Write([]byte(`{"cursor":"c1","rows":[]}`))
+ }))
+ t.Cleanup(srv.Close)
+
+ s := storeForTest(t)
+ s.custody = &Siblings{
+ client: sibling.NewForTest(),
+ byName: map[string]sibling.Sibling{
+ "Docs": {Name: "Docs", BaseURL: srv.URL, Token: "t"},
+ },
+ }
+ a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ a.WarmSiblings(context.Background())
+
+ deadline := time.Now().Add(warmWindow)
+ for atomic.LoadInt64(&polled) == 0 {
+ if time.Now().After(deadline) {
+ t.Fatalf("WarmSiblings started nothing within %v", warmWindow)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
+// An instance with no siblings warms nothing and does not panic.
+//
+// Slice 0's wiring: custody is nil, and `WarmSiblings` is still called by
+// main because the gate requires the call to be there unconditionally.
+func TestWarmSiblingsWithNoRosterIsQuiet(t *testing.T) {
+ s := storeForTest(t)
+ a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ a.WarmSiblings(context.Background())
+}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index b10e73c..687970d 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -479,7 +479,7 @@ func TestProductionDoesNotUseTheTestConstructor(t *testing.T) {
// 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"} {
+ for _, call := range []string{"UseAuth", "LoadSiblings", "UseSiblings", "WarmSiblings"} {
if !callsFunction(t, p, raw, call) {
t.Errorf("%s builds the app but never calls %s", p, call)
}