oficina / memoria Public

🤖 feat: §4.1's policy layer and §6.5's fan-out, with gates 6 and 23

The four S2 values become what memoria does about them, and a page's
worth of them becomes one round of calls.

**The policy layer (gate 6).** §4.1's table as a total function. The
weight is not that every value maps somewhere — `return Withhold` for
everything satisfies that — but that the *silence* is where the table
puts it: `NOT_VISIBLE` alone may be quiet, because a notice there
reports the existence of a passage this person may not have. Everything
else is loud, including a word this client does not recognise, which is
gate 6's named mutation.

`Withhold` is the zero value on purpose, and it is the load-bearing line
in the file. An unset field, a map miss and a forgotten assignment all
produce it; if `Render` sat at iota's first position each of those
accidents would speak a passage nobody authorised.

**The fan-out (gate 23).** §6.5: a page is one round, so its floor is
the slowest sibling and not their sum. The deadline and the concurrency
are asserted together because either alone passes on the other's
failure, and the wall-clock bounds are written as literals rather than
as multiples of `Deadline` — a review previously widened that constant
to 30s and the timing test stayed green, because it measured the code
against itself. These go red if it moves.

Two things fell out of building it that were not in the plan:

- **A document's ten chunks must ask about it once.** Every chunk of one
  document carries the same `(item_id, revision)`, and `DecodeS2` maps a
  duplicated row to UNKNOWN — so the obvious loop makes a sibling's
  honest answer withhold nine passages the reader is entitled to, on the
  most ordinary shape there is.
- **Over the batch cap, split rather than truncate.** `Authorize`
  refuses an oversized batch outright, so without splitting a large page
  withholds everything from that sibling and blames the sibling.

**AHEAD has a tripwire rather than an arm.** The pinned module has four
constants; revision 10 adopted the fifth and it has not shipped (§13).
`TestAheadIsStillAShippingLag` asserts the module still folds "AHEAD" to
UNKNOWN, so the day it stops the test goes red and names what is then
owed. A shipping dependency nothing checks is one nobody notices has
arrived.

Twenty mutations across the two files, all verified red and reverted.
**Two survived first and both were the same failure this codebase keeps
having.** `Record` filing silent rulings changed no rendered line while
growing a per-app count of what the reader may not see — caught only by
asserting the state rather than the copy. And `scatter`'s short-slice
fallback could not be reached from any fixture, because `Authorize` is
total: an unreachable guard is one written to pass, so the mapping is
now a function a test calls directly with a deliberately short slice.

Race detector clean at -count=3.
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 00e2998a4c9515f61ca14dd5f96c576b01bd9989 parent 829acc6
4 files changed, +978 −0
  • internal/memoria/sibling/page.go +153 −0
  • internal/memoria/sibling/page_test.go +386 −0
  • internal/memoria/sibling/policy.go +201 −0
  • internal/memoria/sibling/policy_test.go +238 −0
diff --git a/internal/memoria/sibling/page.go b/internal/memoria/sibling/page.go
new file mode 100644
index 0000000..1b86560
--- /dev/null
+++ b/internal/memoria/sibling/page.go
@@ -0,0 +1,153 @@
+package sibling
+
+import (
+ "context"
+ "sync"
+
+ "amadan.net/oficina/suite"
+)
+
+// A Candidate is one passage the ranker proposes, carrying the app of
+// record that has to rule on it before anybody reads it.
+//
+// The one rule, at the only place it can be enforced: **memoria never
+// speaks a passage without asking whoever owns it whether this person may
+// see it, right now.** Every path that would render a passage goes through
+// Rule below, so there is no passage-only route that skips the question.
+type Candidate struct {
+ Sibling Sibling
+ Item suite.ItemRevision
+}
+
+// Rule asks every contributing sibling about its own candidates, **all of
+// them at once**, and answers for every candidate in the order given.
+//
+// §6.5, and the number that matters is not the deadline but the shape:
+// *"One page of results is one round of calls, so the page's floor is the
+// slowest sibling, not their sum."* Three siblings at 800 ms each is a
+// page that waits 800 ms, and a page that waits 2.4 s is a bug that no
+// per-call assertion can see — which is why gate 23 holds the deadline and
+// the concurrency together. Either alone passes on the other's failure.
+//
+// **Identical pairs are asked once.** A document contributes many chunks
+// and every one of them carries the same `(item_id, revision)`, so the
+// obvious loop asks a ten-chunk document about ten times over. That is not
+// merely wasteful: `DecodeS2` maps a duplicated row to UNKNOWN, so a
+// sibling answering the same pair once for a question that named it ten
+// times is a batch that partly withholds itself. Dedupe first, fan the
+// answers back out.
+//
+// **A sibling with more candidates than one batch allows gets several
+// concurrent batches**, for the same reason the siblings are concurrent: a
+// page whose cost grows with the number of batches is a page whose cost
+// grows with the corpus. Gate 19's mixed-provenance artefact is the case
+// that spans them.
+//
+// Every candidate gets exactly one Disposition. There is no error return
+// and no short slice: a caller with a fifth outcome to handle has somewhere
+// to forget to handle it, and the forgotten branch renders.
+func (c *Client) Rule(ctx context.Context, asker suite.Subject, candidates []Candidate) ([]Disposition, *Outcome) {
+ out := make([]Disposition, len(candidates))
+ notices := &Outcome{}
+ if len(candidates) == 0 {
+ return out, notices
+ }
+
+ // Group by sibling, deduping pairs as we go. positions maps a pair back
+ // to every candidate index that asked about it, which is what makes one
+ // answer serve ten chunks.
+ type work struct {
+ sib Sibling
+ pairs []suite.ItemRevision
+ positions map[suite.ItemRevision][]int
+ }
+ byS := map[Sibling]*work{}
+ var order []Sibling
+ for i, cand := range candidates {
+ w, ok := byS[cand.Sibling]
+ if !ok {
+ w = &work{sib: cand.Sibling, positions: map[suite.ItemRevision][]int{}}
+ byS[cand.Sibling] = w
+ order = append(order, cand.Sibling)
+ }
+ if _, asked := w.positions[cand.Item]; !asked {
+ w.pairs = append(w.pairs, cand.Item)
+ }
+ w.positions[cand.Item] = append(w.positions[cand.Item], i)
+ }
+
+ // One goroutine per batch, every batch in flight together. The answers
+ // are written into `out` at indices owned by exactly one goroutine —
+ // a pair belongs to one sibling and each sibling's batches partition
+ // its pairs — so no lock guards `out`.
+ var wg sync.WaitGroup
+ for _, sib := range order {
+ w := byS[sib]
+ for _, batch := range batches(w.pairs) {
+ wg.Add(1)
+ go func(sib Sibling, batch []suite.ItemRevision, positions map[suite.ItemRevision][]int) {
+ defer wg.Done()
+ scatter(batch, c.Authorize(ctx, sib, asker, batch), positions, out)
+ }(w.sib, batch, w.positions)
+ }
+ }
+ wg.Wait()
+
+ // Notices are accumulated after the fan-in, in candidate order, so a
+ // page's lines do not depend on which sibling answered first.
+ for i, cand := range candidates {
+ notices.Record(cand.Sibling.Name, out[i])
+ }
+ return out, notices
+}
+
+// scatter writes one batch's answers out to every candidate that asked
+// about each pair — the ten chunks of one document that produced a single
+// question.
+//
+// **It is a function rather than four lines inside the goroutine because
+// its short-slice arm is otherwise unreachable, and an unreachable guard is
+// one written to pass.** `Authorize` returns exactly one answer per pair
+// asked, always, so nothing a test can stand up makes `answers` short. The
+// mutation that proved it: change the fallback from UNKNOWN to VISIBLE and
+// every test stayed green, because the branch never ran.
+//
+// So the branch is reachable here, `TestAShortAnswerSliceWithholds` reaches
+// it, and the invariant it is defending against a future change to —
+// Authorize's totality — has its own test one file over. The alternative
+// was to delete the arm and index blindly, which turns a contract change
+// into a panic that takes the page down mid-render.
+func scatter(batch []suite.ItemRevision, answers []suite.Authorization, positions map[suite.ItemRevision][]int, out []Disposition) {
+ for j, pair := range batch {
+ // A missing answer is an answer memoria does not have, which is
+ // UNKNOWN, which withholds loudly. Never Render, and never the
+ // silent omission — a page cannot go quiet because a slice was short.
+ a := suite.UNKNOWN
+ if j < len(answers) {
+ a = answers[j]
+ }
+ d := Decide(a)
+ for _, idx := range positions[pair] {
+ out[idx] = d
+ }
+ }
+}
+
+// batches splits a sibling's pairs into calls the module will accept.
+//
+// The cap and its reason are the module's (`MaxS2Pairs`). Splitting rather
+// than truncating is `Authorize`'s rule one level up: a truncated question
+// answers about fewer items than were asked, and the pairs that fell off
+// the end come back as no answer at all — which withholds, and looks like
+// a sibling problem when it is ours.
+func batches(pairs []suite.ItemRevision) [][]suite.ItemRevision {
+ var out [][]suite.ItemRevision
+ for len(pairs) > suite.MaxS2Pairs {
+ out = append(out, pairs[:suite.MaxS2Pairs])
+ pairs = pairs[suite.MaxS2Pairs:]
+ }
+ if len(pairs) > 0 {
+ out = append(out, pairs)
+ }
+ return out
+}
diff --git a/internal/memoria/sibling/page_test.go b/internal/memoria/sibling/page_test.go
new file mode 100644
index 0000000..b8f0a39
--- /dev/null
+++ b/internal/memoria/sibling/page_test.go
@@ -0,0 +1,386 @@
+package sibling
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+func askerForTest() suite.Subject {
+ return suite.Subject{Kind: suite.SubjectAddress, Value: "paul@rslw.com"}
+}
+
+// answerBody writes an S2 answer as it goes on the wire.
+//
+// The module keeps its response struct unexported — DecodeS2 is the whole
+// public surface — so a fixture has to spell the JSON out. That is the
+// better fixture anyway: this is the shape a real sibling emits, and a
+// field renamed upstream shows up here as a decode failure rather than as
+// a struct literal that follows the rename silently.
+func answerBody(pairs []suite.ItemRevision, result suite.Authorization) []byte {
+ rows := make([]map[string]any, 0, len(pairs))
+ for _, p := range pairs {
+ rows = append(rows, map[string]any{
+ "item_id": p.ItemID, "revision": p.Revision, "result": string(result),
+ })
+ }
+ raw, err := json.Marshal(map[string]any{"results": rows})
+ if err != nil {
+ panic(err)
+ }
+ return raw
+}
+
+// answering stands up a sibling that pauses, then answers `result` for
+// every pair it was asked, counting the calls it received.
+func answering(t *testing.T, name string, pause time.Duration, result suite.Authorization, calls *int64) Sibling {
+ t.Helper()
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(calls, 1)
+ var req suite.S2Request
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Errorf("%s: undecodable S2 request: %v", name, err)
+ return
+ }
+ select {
+ case <-time.After(pause):
+ case <-r.Context().Done():
+ return
+ }
+ _, _ = w.Write(answerBody(req.Pairs, result))
+ })
+ s.Name = name
+ return s
+}
+
+// Gate 23: **the deadline and the concurrency, held together**, because
+// either alone passes on the other's failure.
+//
+// §6.5: "One page of results is one round of calls, so the page's floor is
+// the slowest sibling, not their sum." A per-call deadline assertion is
+// satisfied by three sequential calls of 800 ms each — a page that waits
+// 2.4 s, which no per-call test can see. A concurrency assertion alone is
+// satisfied by a client that gives up instantly.
+//
+// **The wall-clock bounds are literals, not multiples of Deadline**, and
+// that is the point of writing them out. A test bounded against the
+// constant it is testing widens when the constant does: an earlier review
+// of this package mutated `Deadline` to 30 s and the timing test stayed
+// green, because it was measuring the code against itself. These numbers
+// go red if the deadline moves, which is the correct behaviour — §6.5 owes
+// a measurement (§13), and the day it arrives this test is where the new
+// number gets argued for.
+//
+// TWO-SIDED throughout: every timing bound is paired with an assertion
+// about what came back, so a client that returns instantly with everything
+// UNKNOWN fails.
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted — see the
+// per-subtest notes.
+func TestOnePageIsOneRoundOfCalls(t *testing.T) {
+ t.Run("three siblings cost one sibling's time, not three", func(t *testing.T) {
+ // MUTATION: run the batches sequentially (wg.Wait() inside the loop)
+ // -> elapsed ~1.2s, red.
+ var a, b, c int64
+ sheets := answering(t, "Sheets", 400*time.Millisecond, suite.VISIBLE, &a)
+ docs := answering(t, "Docs", 400*time.Millisecond, suite.VISIBLE, &b)
+ cal := answering(t, "Calendar", 400*time.Millisecond, suite.VISIBLE, &c)
+
+ cands := []Candidate{
+ {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}},
+ {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}},
+ {cal, suite.ItemRevision{ItemID: "c1", Revision: 1}},
+ }
+
+ start := time.Now()
+ got, notices := newTestClient().Rule(context.Background(), askerForTest(), cands)
+ elapsed := time.Since(start)
+
+ // 1.2s is the sum; 900ms leaves room for scheduling on a loaded
+ // machine while staying far below it.
+ if elapsed > 900*time.Millisecond {
+ t.Errorf("three 400ms siblings took %v: the page is paying their "+
+ "sum, not their slowest", elapsed)
+ }
+ // The other half. Without it, `return allUnknown` in 0ms passes.
+ for i, d := range got {
+ if !d.Renders() {
+ t.Errorf("candidate %d = %v, want Render — a page that answers "+
+ "fast by answering nothing is not a fast page", i, d)
+ }
+ }
+ if lines := notices.Lines(); len(lines) != 0 {
+ t.Errorf("a fully-answered page carried notices: %v", lines)
+ }
+ })
+
+ t.Run("a slow sibling costs its deadline and no more, and the rest render", func(t *testing.T) {
+ // MUTATION: drop the ctx deadline in Authorize (use context.Background)
+ // -> elapsed ~3s, red.
+ // MUTATION: return the caller's ctx unchanged into Do
+ // -> same, red.
+ var slow, fast int64
+ sheets := answering(t, "Sheets", 3*time.Second, suite.VISIBLE, &slow)
+ docs := answering(t, "Docs", 50*time.Millisecond, suite.VISIBLE, &fast)
+
+ cands := []Candidate{
+ {sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}},
+ {docs, suite.ItemRevision{ItemID: "d1", Revision: 1}},
+ }
+
+ start := time.Now()
+ got, notices := newTestClient().Rule(context.Background(), askerForTest(), cands)
+ elapsed := time.Since(start)
+
+ if elapsed > 1500*time.Millisecond {
+ t.Errorf("a sibling sleeping 3s cost the page %v: the deadline is "+
+ "not being applied, or not per call", elapsed)
+ }
+ if got[0].Renders() {
+ t.Error("the passage from the sibling that never answered rendered")
+ }
+ if !got[0].Notices() {
+ t.Error("a sibling past its deadline was silent: §6.5 makes that " +
+ "UNKNOWN, which withholds loudly")
+ }
+ if !got[1].Renders() {
+ t.Error("a fast sibling's passage was withheld because a different " +
+ "sibling was slow — partial pages are the normal case, not an " +
+ "error path")
+ }
+ want := "Sheets did not answer; 1 result withheld."
+ if lines := notices.Lines(); len(lines) != 1 || lines[0] != want {
+ t.Errorf("notices = %v, want [%q]", lines, want)
+ }
+ })
+
+ t.Run("the page waits for a sibling inside the deadline", func(t *testing.T) {
+ // Without this, a Rule that never waits at all passes both bounds
+ // above: it would answer in 0ms and every timing assertion is an
+ // upper bound. This one is a lower bound on the answer, not on time.
+ var n int64
+ sheets := answering(t, "Sheets", 300*time.Millisecond, suite.VISIBLE, &n)
+ got, _ := newTestClient().Rule(context.Background(), askerForTest(),
+ []Candidate{{sheets, suite.ItemRevision{ItemID: "s1", Revision: 1}}})
+ if !got[0].Renders() {
+ t.Error("a sibling answering in 300ms, well inside an 800ms " +
+ "deadline, was treated as no answer")
+ }
+ if atomic.LoadInt64(&n) != 1 {
+ t.Errorf("the sibling received %d calls, want 1", n)
+ }
+ })
+}
+
+// **A document's ten chunks ask about it once.**
+//
+// Every chunk of one document carries the same `(item_id, revision)`, so
+// the obvious loop asks a ten-chunk document ten times. That is not merely
+// wasteful: the module maps a duplicated row to UNKNOWN, so a sibling
+// answering a pair once for a question that named it ten times partly
+// withholds its own correct answer — a page that goes quiet for a document
+// the reader is fully entitled to, on the most ordinary shape there is.
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted:
+//
+// skip the dedupe (append every candidate's pair)
+// -> the wire assertion goes red at 10 pairs, and the batch the
+// sibling answers honestly comes back UNKNOWN for nine of them
+// map answers back to the first index only
+// -> nine candidates left at the zero Disposition; red, and it is
+// worth noting they fail *closed* — the zero value doing its job
+func TestOneDocumentIsAskedAboutOnce(t *testing.T) {
+ var pairsSeen int64
+ var calls int64
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(&calls, 1)
+ var req suite.S2Request
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Errorf("undecodable S2 request: %v", err)
+ return
+ }
+ atomic.AddInt64(&pairsSeen, int64(len(req.Pairs)))
+ _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE))
+ })
+ s.Name = "Sheets"
+
+ var cands []Candidate
+ for i := 0; i < 10; i++ {
+ cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-a", Revision: 4}})
+ }
+ // One other document, so "send exactly one pair always" does not pass.
+ cands = append(cands, Candidate{s, suite.ItemRevision{ItemID: "doc-b", Revision: 1}})
+
+ got, _ := newTestClient().Rule(context.Background(), askerForTest(), cands)
+
+ if pairsSeen != 2 {
+ t.Errorf("%d pairs went on the wire for 11 chunks of 2 documents, want 2 — "+
+ "a duplicated row decodes to UNKNOWN, so asking twice withholds "+
+ "a passage the reader is entitled to", pairsSeen)
+ }
+ if calls != 1 {
+ t.Errorf("%d calls, want 1", calls)
+ }
+ if len(got) != 11 {
+ t.Fatalf("%d dispositions for 11 candidates", len(got))
+ }
+ for i, d := range got {
+ if !d.Renders() {
+ t.Errorf("candidate %d = %v, want Render: one answer must reach "+
+ "every chunk of the document it was about", i, d)
+ }
+ }
+}
+
+// A sibling with more candidates than one batch allows gets several calls,
+// concurrently, and every pair still gets its own answer.
+//
+// Gate 19's mixed-provenance artefact is the case that spans batches, and
+// `Authorize` refuses an oversized batch outright rather than truncating
+// it — so without splitting here, a page over the cap withholds everything
+// from that sibling and blames the sibling.
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted:
+//
+// pass the whole slice as one batch
+// -> CheckS2Batch refuses it, everything UNKNOWN; red
+// batches() drops the final short batch
+// -> the tail candidates sit at the zero Disposition; red
+func TestASiblingOverTheBatchCapIsSplitNotTruncated(t *testing.T) {
+ var calls int64
+ var seen int64
+ s := serve(t, func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt64(&calls, 1)
+ var req suite.S2Request
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Errorf("undecodable S2 request: %v", err)
+ return
+ }
+ if len(req.Pairs) > suite.MaxS2Pairs {
+ t.Errorf("a batch of %d exceeds the cap of %d", len(req.Pairs), suite.MaxS2Pairs)
+ }
+ atomic.AddInt64(&seen, int64(len(req.Pairs)))
+ _, _ = w.Write(answerBody(req.Pairs, suite.VISIBLE))
+ })
+ s.Name = "Sheets"
+
+ const n = suite.MaxS2Pairs*2 + 7 // two full batches and a short one
+ cands := make([]Candidate, n)
+ for i := range cands {
+ cands[i] = Candidate{s, suite.ItemRevision{
+ ItemID: fmt.Sprintf("doc-%d", i), Revision: int64(i),
+ }}
+ }
+
+ got, notices := newTestClient().Rule(context.Background(), askerForTest(), cands)
+
+ if calls != 3 {
+ t.Errorf("%d calls for %d pairs at a cap of %d, want 3", calls, n, suite.MaxS2Pairs)
+ }
+ if seen != n {
+ t.Errorf("%d pairs reached the sibling, want %d: a batch was truncated, "+
+ "and the pairs that fell off the end withhold while looking like "+
+ "the sibling's fault", seen, n)
+ }
+ for i, d := range got {
+ if !d.Renders() {
+ t.Fatalf("candidate %d of %d = %v, want Render", i, n, d)
+ }
+ }
+ if lines := notices.Lines(); len(lines) != 0 {
+ t.Errorf("notices on a fully-answered page: %v", lines)
+ }
+}
+
+// Totality at the page boundary: no candidate ever goes unanswered, and
+// nothing about an empty page is special-cased into a nil the caller then
+// ranges over expecting content.
+func TestRuleAnswersEveryCandidate(t *testing.T) {
+ got, notices := newTestClient().Rule(context.Background(), askerForTest(), nil)
+ if len(got) != 0 {
+ t.Errorf("an empty page produced %d dispositions", len(got))
+ }
+ if notices == nil {
+ t.Fatal("an empty page produced a nil Outcome, which the page will " +
+ "call Lines() on")
+ }
+ if lines := notices.Lines(); len(lines) != 0 {
+ t.Errorf("an empty page carried notices: %v", lines)
+ }
+
+ t.Run("an unreachable sibling still answers for every candidate", func(t *testing.T) {
+ dead := Sibling{Name: "Sheets", BaseURL: "http://127.0.0.1:1", Token: "t"}
+ cands := []Candidate{
+ {dead, suite.ItemRevision{ItemID: "a", Revision: 1}},
+ {dead, suite.ItemRevision{ItemID: "b", Revision: 1}},
+ }
+ got, notices := newTestClient().Rule(context.Background(), askerForTest(), cands)
+ if len(got) != 2 {
+ t.Fatalf("%d dispositions for 2 candidates", len(got))
+ }
+ for i, d := range got {
+ if d.Renders() || !d.Notices() {
+ t.Errorf("candidate %d from an unreachable sibling = %v, want "+
+ "withheld loudly", i, d)
+ }
+ }
+ want := "Sheets did not answer; 2 results withheld."
+ if lines := notices.Lines(); len(lines) != 1 || lines[0] != want {
+ t.Errorf("notices = %v, want [%q]", lines, want)
+ }
+ })
+}
+
+// The short-answer arm of `scatter`, which nothing on the wire can reach.
+//
+// `Authorize` returns exactly one Authorization per pair asked, always, so
+// no fixture makes its result short — which is why the fallback was a guard
+// that could not fail. A mutation proved it: changing the missing-answer
+// value from UNKNOWN to VISIBLE left every test in this package green.
+//
+// So the mapping is a function and this calls it directly. The property is
+// the one §4.1 keeps repeating: **a missing answer is not a permissive one,
+// and it is never the silent one.**
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted:
+//
+// the fallback becomes suite.VISIBLE -> red (this is the one that
+// survived before the refactor)
+// the fallback becomes suite.NOT_VISIBLE -> red on Notices()
+// scatter indexes answers[j] blindly -> panic, red
+func TestAShortAnswerSliceWithholds(t *testing.T) {
+ batch := []suite.ItemRevision{
+ {ItemID: "a", Revision: 1},
+ {ItemID: "b", Revision: 1},
+ {ItemID: "c", Revision: 1},
+ }
+ positions := map[suite.ItemRevision][]int{
+ batch[0]: {0},
+ batch[1]: {1, 3}, // two chunks of the same document
+ batch[2]: {2},
+ }
+ out := make([]Disposition, 4)
+
+ // One answer for three pairs: the shape a contract change would produce.
+ scatter(batch, []suite.Authorization{suite.VISIBLE}, positions, out)
+
+ if !out[0].Renders() {
+ t.Error("the answer that did arrive was not applied, so this test " +
+ "would pass for a scatter that withholds unconditionally")
+ }
+ for _, i := range []int{1, 2, 3} {
+ if out[i].Renders() {
+ t.Errorf("candidate %d rendered on an answer that never came", i)
+ }
+ if !out[i].Notices() {
+ t.Errorf("candidate %d went silent on an answer that never came: a "+
+ "page cannot quietly shorten itself because a slice was short", i)
+ }
+ }
+}
diff --git a/internal/memoria/sibling/policy.go b/internal/memoria/sibling/policy.go
new file mode 100644
index 0000000..0f0066b
--- /dev/null
+++ b/internal/memoria/sibling/policy.go
@@ -0,0 +1,201 @@
+package sibling
+
+import (
+ "fmt"
+ "sort"
+
+ "amadan.net/oficina/suite"
+)
+
+// Disposition is what memoria does with one candidate passage once its app
+// of record has ruled on it. §4.1's table, as a type.
+//
+// The table has five rows and **exactly one of them is silent.** Every
+// other row produces a line on the page, because the alternative is a
+// shorter answer that looks like a complete one — the plausible empty list,
+// which §4.1 says is why each value exists separately at all. A
+// synchronisation condition is not an authorization decision and must not
+// share its value; the same sentence, one layer down, says it must not
+// share its silence either.
+type Disposition uint8
+
+const (
+ // Withhold is the zero value **on purpose**, and this is the load-bearing
+ // line in the file.
+ //
+ // Go gives an unset field, a missing map entry and a
+ // forgotten-to-be-assigned variable the zero value, and here that means
+ // the safest and loudest outcome rather than the most disclosing one. If
+ // Render were iota's first constant, every one of those accidents would
+ // speak a passage nobody authorised — the exact failure `requireSession`
+ // was rewritten to fail closed for, arriving through a different door.
+ //
+ // It is loud as well as closed: an outcome nobody assigned is not the
+ // one value permitted to say nothing.
+ Withhold Disposition = iota
+
+ // Render: VISIBLE. This person may read this revision.
+ Render
+
+ // OmitSilently: NOT_VISIBLE. The only value that may be silent, and
+ // correct — a notice here would report the existence of a passage this
+ // person may not have, which is the same oracle S3's "404, never 403"
+ // and §4.2's indistinguishability were both built to deny.
+ OmitSilently
+
+ // WithholdAndRefetch: STALE. memoria is behind; the item exists and is
+ // readable and refetching fixes it. Not a denial.
+ WithholdAndRefetch
+
+ // WithholdAndReannounce: AHEAD. **Nothing produces this yet**, and that
+ // is recorded rather than hidden — see aheadNotYetOnTheWire below, which
+ // is the tripwire that fails the day it becomes reachable.
+ WithholdAndReannounce
+)
+
+// Renders reports whether the passage's text may be spoken.
+func (d Disposition) Renders() bool { return d == Render }
+
+// Notices reports whether the page must say something was withheld.
+//
+// Not the negation of Renders: `OmitSilently` neither renders nor notices,
+// and it is the only disposition that does neither.
+func (d Disposition) Notices() bool {
+ switch d {
+ case Withhold, WithholdAndRefetch, WithholdAndReannounce:
+ return true
+ }
+ return false
+}
+
+// Decide maps one S2 answer to what memoria does about it. §4.1's table.
+//
+// **Total, with the default on the safe side.** An answer this client does
+// not recognise withholds loudly; it never falls through to omission. Gate
+// 6's mutation is exactly this — a sixth value `PERHAPS` in the fixture,
+// which must go red rather than be quietly dropped — and the reason is that
+// omission is the one outcome a reader cannot tell from an honest absence.
+//
+// The module folds any unrecognised wire word to UNKNOWN before this is
+// reached (`known`, suite/s2.go), so in practice the default arm is a
+// second line of the same defence rather than the only one. Both are here
+// because the module's rule is the module's to change.
+func Decide(a suite.Authorization) Disposition {
+ switch a {
+ case suite.VISIBLE:
+ return Render
+ case suite.NOT_VISIBLE:
+ return OmitSilently
+ case suite.STALE:
+ return WithholdAndRefetch
+ case suite.UNKNOWN:
+ return Withhold
+ }
+ return Withhold
+}
+
+// aheadNotYetOnTheWire is the fifth row of §4.1's table, and it is not a
+// constant this client can switch on.
+//
+// The module this repository pins carries four values. `AHEAD` is adopted —
+// suite doctrine revision 10, settled 2026-09-06 on memoria's own argument
+// — and lands on a branch that has not shipped, held by suite CI being
+// unconfigured (§13). Until it does, a sibling answering "AHEAD" is folded
+// to UNKNOWN by the module and withheld loudly here: **safe, and lossy.**
+// What is lost is the marking for re-announce, which is the collapse the
+// fifth value was cut to prevent.
+//
+// This constant exists so a test can assert the lag is still a lag. See
+// TestAheadIsStillAShippingLag: it goes red on the day the module gains the
+// value, which is the day `Decide` owes it an arm and this file owes the
+// paragraph above a deletion. A shipping dependency that nothing checks is
+// a shipping dependency nobody notices has arrived.
+const aheadNotYetOnTheWire = suite.Authorization("AHEAD")
+
+// Outcome accumulates one page's loud withholdings, per source app.
+//
+// **It is told an app and a disposition, and never an item.** §6.5 says the
+// page names what is missing and from which app; it does not name what was
+// missed, and the way to guarantee that is to give this type no way to
+// learn it. A parameter that is not there cannot reach a log line, a notice
+// or a metric label — which is the same argument gate 4 makes about
+// content in logs, made structurally instead of by grep.
+//
+// Counts are carried for the two dispositions §8.1's copy counts, and
+// **not** for reconciling: §4.1's post-restore rule 1 requires one line for
+// the source rather than one per withheld passage, because a source being
+// reconciled leaves the ranking wholesale and a per-passage count would
+// describe a page that was never built.
+type Outcome struct {
+ counts map[noticeKey]int
+ order []noticeKey
+}
+
+type noticeKey struct {
+ app string
+ kind Disposition
+}
+
+// Record files one ruling against the page. Dispositions that render or
+// omit silently are no-ops here, which is what "silent" means.
+func (o *Outcome) Record(app string, d Disposition) {
+ if !d.Notices() {
+ return
+ }
+ if o.counts == nil {
+ o.counts = make(map[noticeKey]int)
+ }
+ k := noticeKey{app: app, kind: d}
+ if _, seen := o.counts[k]; !seen {
+ o.order = append(o.order, k)
+ }
+ o.counts[k]++
+}
+
+// Lines renders the page's withholding notices, in the order the sources
+// were first heard from, so a page is stable across reloads.
+func (o *Outcome) Lines() []string {
+ out := make([]string, 0, len(o.order))
+ for _, k := range o.order {
+ n := o.counts[k]
+ switch k.kind {
+ case Withhold:
+ out = append(out, fmt.Sprintf("%s did not answer; %s withheld.",
+ k.app, results(n)))
+ case WithholdAndRefetch:
+ out = append(out, fmt.Sprintf("%s has moved on from %s; refetching.",
+ k.app, results(n)))
+ case WithholdAndReannounce:
+ // No count, deliberately. Rule 1 of §4.1's post-restore
+ // reconciliation: the source leaves the ranking entirely, so the
+ // number of passages it would have contributed is not a number
+ // about this page.
+ out = append(out, fmt.Sprintf(
+ "%s is reconciling after a restore; its results are not on this page.",
+ k.app))
+ }
+ }
+ return out
+}
+
+// SourcesToReannounce lists the apps §4.1 requires memoria to mark for a
+// full re-announce. **It never lists an app to delete from**: `AHEAD` is
+// not a reason to remove anything, and the only thing that ever retires a
+// phantom chunk is a re-announce that reached its closing marker.
+func (o *Outcome) SourcesToReannounce() []string {
+ var out []string
+ for _, k := range o.order {
+ if k.kind == WithholdAndReannounce {
+ out = append(out, k.app)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func results(n int) string {
+ if n == 1 {
+ return "1 result"
+ }
+ return fmt.Sprintf("%d results", n)
+}
diff --git a/internal/memoria/sibling/policy_test.go b/internal/memoria/sibling/policy_test.go
new file mode 100644
index 0000000..e4ebbd5
--- /dev/null
+++ b/internal/memoria/sibling/policy_test.go
@@ -0,0 +1,238 @@
+package sibling
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/suite"
+)
+
+// Gate 6, first half: **every S2 result is handled, and exactly one of them
+// is allowed to be silent.**
+//
+// §4.1's table row by row. The interesting assertion is not that each value
+// maps somewhere — a `return Withhold` for everything satisfies that — but
+// that the *silence* is where the table puts it. `NOT_VISIBLE` is silent
+// because a notice there reports the existence of a passage this person may
+// not have. Every other value is loud because a shorter page that looks
+// complete is the plausible empty list.
+//
+// TWO-SIDED in both directions: the table below pins what renders (so
+// "withhold everything" is red) and what stays quiet (so "notice
+// everything" is red).
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted:
+//
+// STALE -> OmitSilently (draft 2's actual bug) -> red
+// UNKNOWN -> OmitSilently -> red
+// NOT_VISIBLE -> Withhold -> red
+// the default arm -> OmitSilently -> red on PERHAPS
+// Notices() returns true for OmitSilently -> red
+// Renders() returns true for Withhold -> red
+func TestEveryS2ResultIsHandled(t *testing.T) {
+ for _, c := range []struct {
+ result suite.Authorization
+ want Disposition
+ renders bool
+ notices bool
+ }{
+ {suite.VISIBLE, Render, true, false},
+ {suite.NOT_VISIBLE, OmitSilently, false, false},
+ {suite.STALE, WithholdAndRefetch, false, true},
+ {suite.UNKNOWN, Withhold, false, true},
+
+ // Gate 6's named mutation: a sixth value the client does not know.
+ // It must withhold loudly, never fall through to omission — an
+ // unrecognised word is a sibling this client does not understand,
+ // and omission is the one outcome a reader cannot tell from an
+ // honest absence.
+ {suite.Authorization("PERHAPS"), Withhold, false, true},
+
+ // And the two shapes a value arrives in when something upstream went
+ // wrong rather than something being said: an empty string, and the
+ // value with the right letters in the wrong case. Neither renders.
+ {suite.Authorization(""), Withhold, false, true},
+ {suite.Authorization("visible"), Withhold, false, true},
+ } {
+ got := Decide(c.result)
+ if got != c.want {
+ t.Errorf("Decide(%q) = %v, want %v", c.result, got, c.want)
+ }
+ if got.Renders() != c.renders {
+ t.Errorf("Decide(%q).Renders() = %v, want %v", c.result, got.Renders(), c.renders)
+ }
+ if got.Notices() != c.notices {
+ t.Errorf("Decide(%q).Notices() = %v, want %v", c.result, got.Notices(), c.notices)
+ }
+ }
+}
+
+// Gate 6, second half: **the zero value is the safe one.**
+//
+// Go hands an unset field, a missing map entry and a variable nobody
+// assigned the same thing, and this asserts what that thing is. If Render
+// sat at iota's first position, each of those accidents would speak a
+// passage nobody authorised.
+//
+// MUTATION-TESTED 2026-09-06: move Render above Withhold in the const
+// block; red here and red in TestEveryS2ResultIsHandled. Reverted.
+func TestTheZeroDispositionWithholds(t *testing.T) {
+ var unset Disposition
+ if unset.Renders() {
+ t.Error("the zero Disposition renders: a struct field nobody set, a " +
+ "map miss, or a forgotten assignment now speaks a passage")
+ }
+ if !unset.Notices() {
+ t.Error("the zero Disposition is silent: an outcome nobody assigned " +
+ "is not the one value permitted to say nothing")
+ }
+
+ // And exactly one disposition is quiet without rendering, because "the
+ // only value that may be silent" is a claim about the whole set, not
+ // about NOT_VISIBLE alone.
+ quiet := 0
+ for _, d := range []Disposition{
+ Withhold, Render, OmitSilently, WithholdAndRefetch, WithholdAndReannounce,
+ } {
+ if !d.Renders() && !d.Notices() {
+ quiet++
+ if d != OmitSilently {
+ t.Errorf("%v neither renders nor notices, and is not OmitSilently: "+
+ "a second silent disposition is a second way to serve a "+
+ "short page that looks complete", d)
+ }
+ }
+ }
+ if quiet != 1 {
+ t.Errorf("%d silent dispositions, want exactly 1", quiet)
+ }
+}
+
+// The page's notices. §6.5: "the page names what is missing and from which
+// app", and §8.1's copy: *"Sheets did not answer; 7 results withheld."*
+//
+// The structural half of this is not asserted here because it cannot fail:
+// Record takes an app and a disposition and **no item**, so no title, id or
+// snippet can reach a notice line. That is gate 4's rule about content in
+// logs, made by leaving out the parameter rather than by grepping for what
+// went through it.
+//
+// MUTATION-TESTED 2026-09-06, each verified red then reverted:
+//
+// Record files rendered and silently-omitted rulings too
+// -> red on the stored state. **This one SURVIVED the first version of
+// this test**, which read only the rendered lines: Lines() has no arm
+// for the quiet dispositions, so the page was identical while the
+// Outcome quietly grew a per-app count of what the reader may not see.
+// The assertion that catches it reads o.counts directly.
+// Lines() iterates the map instead of the order slice
+// -> flaky, then red on the ordering assertion (10 runs, -count=10)
+// the reconciling line gains a count
+// -> red
+// results() drops the singular case
+// -> red on "1 result"
+func TestTheNoticesNameTheAppAndTheShapeOfTheLoss(t *testing.T) {
+ var o Outcome
+ o.Record("Sheets", Decide(suite.UNKNOWN))
+ o.Record("Sheets", Decide(suite.UNKNOWN))
+ o.Record("Sheets", Decide(suite.VISIBLE)) // renders; no notice
+ o.Record("Sheets", Decide(suite.NOT_VISIBLE)) // silent; no notice
+ o.Record("Docs", Decide(suite.STALE))
+ o.Record("Calendar", WithholdAndReannounce)
+
+ got := o.Lines()
+ want := []string{
+ "Sheets did not answer; 2 results withheld.",
+ "Docs has moved on from 1 result; refetching.",
+ "Calendar is reconciling after a restore; its results are not on this page.",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("notices =\n %s\nwant\n %s",
+ strings.Join(got, "\n "), strings.Join(want, "\n "))
+ }
+
+ // **The silent ruling must not have been stored, not merely not
+ // printed**, and this assertion exists because the copy-only version of
+ // it survived its mutation.
+ //
+ // `Record` filing everything and `Lines` having no arm for the quiet
+ // dispositions renders identically — every assertion above stays green —
+ // while `counts` grows a per-app tally of passages this person may not
+ // see. Nothing displays it today. It is one switch arm, one debug print
+ // or one metric label away from being exactly the count §4.1 forbids,
+ // and the version of this test that only read the rendered lines could
+ // not tell the two apart. So it reads the state.
+ for k := range o.counts {
+ if k.kind == Render || k.kind == OmitSilently {
+ t.Errorf("Outcome stored a %v ruling for %q: a count of what a "+
+ "person may not see, held per app, awaiting a line to print it",
+ k.kind, k.app)
+ }
+ }
+ if len(o.counts) != 3 {
+ t.Errorf("Outcome holds %d keys, want 3 — one per loud (app, kind) pair "+
+ "and nothing else", len(o.counts))
+ }
+
+ if reannounce := o.SourcesToReannounce(); !reflect.DeepEqual(reannounce, []string{"Calendar"}) {
+ t.Errorf("SourcesToReannounce() = %v, want [Calendar]", reannounce)
+ }
+
+ t.Run("a page with nothing withheld says nothing", func(t *testing.T) {
+ var clean Outcome
+ clean.Record("Sheets", Decide(suite.VISIBLE))
+ clean.Record("Sheets", Decide(suite.NOT_VISIBLE))
+ if lines := clean.Lines(); len(lines) != 0 {
+ t.Errorf("a fully-answered page carried notices: %v", lines)
+ }
+ if apps := clean.SourcesToReannounce(); len(apps) != 0 {
+ t.Errorf("a fully-answered page marked sources for re-announce: %v", apps)
+ }
+ })
+}
+
+// **AHEAD is adopted and not yet on the wire, and this is the tripwire.**
+//
+// §4.1: suite doctrine revision 10 settled `AHEAD` on 2026-09-06, on
+// memoria's argument, and the module carries it on a branch that has not
+// shipped — held by suite CI being unconfigured (§13). The version this
+// repository pins has four constants. So a sibling answering "AHEAD" today
+// is folded to UNKNOWN by the module and withheld loudly here: safe, and
+// lossy. What is lost is the marking for re-announce.
+//
+// A shipping dependency nothing checks is a shipping dependency nobody
+// notices has arrived. **This test goes red the day the module gains the
+// value** — DecodeS2 will stop folding it — and whoever bumps the
+// dependency then owes `Decide` its fifth arm and policy.go the deletion of
+// the paragraph explaining the lag.
+//
+// It is deliberately written as an assertion about the *module*, not about
+// memoria: memoria's own behaviour here is correct either way, and the
+// thing that changes underfoot is upstream.
+func TestAheadIsStillAShippingLag(t *testing.T) {
+ asked := []suite.ItemRevision{{ItemID: "doc-a", Revision: 7}}
+ body := []byte(`{"results":[{"item_id":"doc-a","revision":7,"result":"AHEAD"}]}`)
+
+ got := suite.DecodeS2(asked, 200, body)
+ if len(got) != 1 {
+ t.Fatalf("DecodeS2 returned %d answers for 1 pair", len(got))
+ }
+ if got[0] != suite.UNKNOWN {
+ t.Fatalf("the pinned suite module now understands %q (it answered %q).\n\n"+
+ "That is the shipping lag ending, not a bug. What is owed:\n"+
+ " - Decide() gains a `case suite.AHEAD: return WithholdAndReannounce`\n"+
+ " - policy.go's aheadNotYetOnTheWire paragraph is deleted\n"+
+ " - spec §4.1's \"four on the wire today\" and §13's dependency close\n"+
+ " - this test is replaced by the AHEAD row in gate 6's table",
+ aheadNotYetOnTheWire, got[0])
+ }
+
+ // And the memoria side of it, which is correct either way: whatever
+ // arrives, it withholds loudly and it does not omit.
+ d := Decide(got[0])
+ if d.Renders() || !d.Notices() {
+ t.Errorf("a folded AHEAD = %v; want withheld loudly. A sibling that "+
+ "lost history must not read as a sibling with nothing to say", d)
+ }
+}