| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "amadan.net/oficina/suite" |
| 12 | |
| 13 | "amadan.net/oficina/memoria/internal/memoria/seal" |
| 14 | "amadan.net/oficina/memoria/internal/memoria/sibling" |
| 15 | ) |
| 16 | |
| 17 | // countingCustody answers every candidate the same way and is a real |
| 18 | // Custody, so a search goes through the whole production path: rule(), |
| 19 | // recordForBreaker(), RecordAuthorizations(). |
| 20 | type countingCustody struct { |
| 21 | apps map[string]sibling.Sibling |
| 22 | give suite.Authorization |
| 23 | } |
| 24 | |
| 25 | func (c *countingCustody) Sibling(source string) (sibling.Sibling, bool) { |
| 26 | s, ok := c.apps[source] |
| 27 | return s, ok |
| 28 | } |
| 29 | |
| 30 | func (c *countingCustody) Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition { |
| 31 | out := make([]sibling.Disposition, len(cands)) |
| 32 | for i := range cands { |
| 33 | out[i] = sibling.Decide(c.give) |
| 34 | } |
| 35 | return out |
| 36 | } |
| 37 | |
| 38 | // **A search writes the breaker's counters**, end to end, and nothing |
| 39 | // pinned it. |
| 40 | // |
| 41 | // A review deleted the `recordForBreaker` call from `Store.rule` and every |
| 42 | // package stayed green: the breaker's own tests write counters directly, |
| 43 | // so they prove the predicate and say nothing about whether anything in |
| 44 | // production ever feeds it. The wiring and the arithmetic were tested |
| 45 | // separately and the join was not tested at all — which is how a breaker |
| 46 | // ships permanently reading "cannot determine" on a working instance. |
| 47 | // |
| 48 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 49 | // |
| 50 | // delete the recordForBreaker call in Store.rule |
| 51 | // -> red, no counters at all |
| 52 | // record under the request context rather than WithoutCancel |
| 53 | // -> green here (the ctx is not cancelled in this test) and NOT a |
| 54 | // finding this test can make; the rule it enforces is AGENTS.md's |
| 55 | // about derived state, and its cost shows up under load rather |
| 56 | // than in a fixture. Listed so the next reader knows it was tried. |
| 57 | // map Render to NOT_VISIBLE |
| 58 | // -> red on the visible count |
| 59 | func TestASearchWritesTheBreakersCounters(t *testing.T) { |
| 60 | ctx := context.Background() |
| 61 | |
| 62 | for _, c := range []struct { |
| 63 | name string |
| 64 | give suite.Authorization |
| 65 | visible, refused int |
| 66 | wantAnyCounterRow bool |
| 67 | }{ |
| 68 | {"a rendered passage counts as visible", suite.VISIBLE, 1, 0, true}, |
| 69 | {"a silently omitted one counts as refused", suite.NOT_VISIBLE, 0, 1, true}, |
| 70 | // An outage counts as neither, and writes nothing at all — which |
| 71 | // is what keeps the predicate from reading it as refusal. |
| 72 | {"an outage counts as neither", suite.UNKNOWN, 0, 0, false}, |
| 73 | {"a stale passage counts as neither", suite.STALE, 0, 0, false}, |
| 74 | } { |
| 75 | t.Run(c.name, func(t *testing.T) { |
| 76 | s := storeForTest(t) |
| 77 | s.custody = &countingCustody{ |
| 78 | apps: map[string]sibling.Sibling{ |
| 79 | "Docs": {Name: "Docs", BaseURL: "https://acme.docs.oficina.works", Token: "t"}, |
| 80 | }, |
| 81 | give: c.give, |
| 82 | } |
| 83 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 84 | Source: "Docs", Key: "docs/a", Title: "A", |
| 85 | Body: "The quarterly planning process runs in March.", |
| 86 | ItemID: "docs-a", |
| 87 | SiblingRevision: 3, |
| 88 | }); err != nil { |
| 89 | t.Fatalf("ingest: %v", err) |
| 90 | } |
| 91 | |
| 92 | if _, err := s.Search(ctx, testAsker, "quarterly planning", 10); err != nil { |
| 93 | t.Fatalf("search: %v", err) |
| 94 | } |
| 95 | |
| 96 | var rows []SiblingTally |
| 97 | if err := s.db.G.Find(&rows).Error; err != nil { |
| 98 | t.Fatalf("read tallies: %v", err) |
| 99 | } |
| 100 | if !c.wantAnyCounterRow { |
| 101 | if len(rows) != 0 { |
| 102 | t.Errorf("%d counter rows for an answer this breaker does "+ |
| 103 | "not count; a zero row puts the asker in the window as "+ |
| 104 | "somebody who asked and got nothing", len(rows)) |
| 105 | } |
| 106 | return |
| 107 | } |
| 108 | if len(rows) != 1 { |
| 109 | t.Fatalf("%d counter rows, want 1: a search that answers from "+ |
| 110 | "a sibling must feed the breaker, or it reads 'cannot "+ |
| 111 | "determine' on a working instance forever", len(rows)) |
| 112 | } |
| 113 | got, err := s.openTally(rows[0].SealedCounts) |
| 114 | if err != nil { |
| 115 | t.Fatalf("open: %v", err) |
| 116 | } |
| 117 | if got.Visible != c.visible || got.NotVisible != c.refused { |
| 118 | t.Errorf("tally = %+v, want {%d %d}", got, c.visible, c.refused) |
| 119 | } |
| 120 | if rows[0].App != "Docs" { |
| 121 | t.Errorf("counted against %q, want Docs", rows[0].App) |
| 122 | } |
| 123 | }) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // **`WireSiblings` starts the counting clock**, and nothing pinned that |
| 128 | // either. |
| 129 | // |
| 130 | // A review deleted the `StartCounting` loop and the suite stayed green. |
| 131 | // In production that leaves `CountingSince` zero for every sibling, which |
| 132 | // `RefusalBreaker` reads as "I have not been looking long enough" — so |
| 133 | // every source reports cannot-determine forever, on an instance that has |
| 134 | // been counting perfectly well the whole time. |
| 135 | // |
| 136 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 137 | // |
| 138 | // delete the StartCounting loop in WireSiblings -> red |
| 139 | // StartCounting overwrites an existing timestamp -> red on the second |
| 140 | // call: a wake must not reset the clock, or an instance that |
| 141 | // hibernates nightly never accumulates a baseline |
| 142 | func TestWiringStartsTheCountingClock(t *testing.T) { |
| 143 | ctx := context.Background() |
| 144 | env := func(pairs map[string]string) func(string) string { |
| 145 | return func(k string) string { return pairs[k] } |
| 146 | } |
| 147 | cfg := map[string]string{ |
| 148 | "MEMORIA_TEAM": "acme", |
| 149 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works", |
| 150 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 151 | } |
| 152 | |
| 153 | s := storeForTest(t) |
| 154 | a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| 155 | if err := WireSiblings(a, env(cfg), nil); err != nil { |
| 156 | t.Fatalf("WireSiblings: %v", err) |
| 157 | } |
| 158 | |
| 159 | var row SiblingFeed |
| 160 | if err := s.db.G.Where("app = ?", "Docs").First(&row).Error; err != nil { |
| 161 | t.Fatalf("no feed row after wiring: %v", err) |
| 162 | } |
| 163 | first := row.CountingSince |
| 164 | if first.IsZero() { |
| 165 | t.Fatal("CountingSince is zero after wiring: every sibling then " + |
| 166 | "reports cannot-determine forever on an instance that has been " + |
| 167 | "counting the whole time") |
| 168 | } |
| 169 | |
| 170 | // A second wake must not reset it. memoria hibernates; an instance |
| 171 | // that restarts the clock nightly never accumulates seven days. |
| 172 | if err := s.StartCounting(ctx, "Docs", time.Now().UTC().Add(time.Hour)); err != nil { |
| 173 | t.Fatalf("StartCounting: %v", err) |
| 174 | } |
| 175 | if err := s.db.G.Where("app = ?", "Docs").First(&row).Error; err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | if !row.CountingSince.Equal(first) { |
| 179 | t.Errorf("a second wake moved CountingSince from %v to %v: an "+ |
| 180 | "instance that hibernates nightly would never accumulate a "+ |
| 181 | "baseline, and every source would read cannot-determine", |
| 182 | first, row.CountingSince) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // The firing count reaches the owner's page and no reader's. |
| 187 | // |
| 188 | // §8.1: a message telling one reader how much they were not allowed to see |
| 189 | // is a disclosure with a number in it. The first version of this assertion |
| 190 | // checked a `Page.Notices` slice the test had written itself — it |
| 191 | // constrained nothing, and a review rendered the breaker sentence straight |
| 192 | // into results.html with it still green. |
| 193 | // |
| 194 | // This asserts the two real surfaces: what `siblingRows` produces for the |
| 195 | // owner, and what a search's own notices carry. |
| 196 | // |
| 197 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 198 | // |
| 199 | // siblingRows returns no Breaker sentence -> owner case red |
| 200 | // Store.rule copies the breaker verdict into Page.Notices |
| 201 | // -> reader case red |
| 202 | func TestTheFiringCountReachesOnlyTheOwner(t *testing.T) { |
| 203 | ctx := context.Background() |
| 204 | s := breakerStore(t) |
| 205 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 206 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 207 | countOn(t, s, "Sheets", p, breakerClock, 0, 4) |
| 208 | } |
| 209 | s.custody = &countingCustody{ |
| 210 | apps: map[string]sibling.Sibling{ |
| 211 | "Sheets": {Name: "Sheets", BaseURL: "https://acme.sheets.oficina.works", Token: "t"}, |
| 212 | }, |
| 213 | give: suite.NOT_VISIBLE, |
| 214 | } |
| 215 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 216 | Source: "Sheets", Key: "s/a", Title: "A", |
| 217 | Body: "The quarterly planning process runs in March.", |
| 218 | ItemID: "s-a", SiblingRevision: 1, |
| 219 | }); err != nil { |
| 220 | t.Fatalf("ingest: %v", err) |
| 221 | } |
| 222 | |
| 223 | // The reader's side: a real search, with a real withholding. |
| 224 | page, err := s.Search(ctx, testAsker, "quarterly planning", 10) |
| 225 | if err != nil { |
| 226 | t.Fatalf("search: %v", err) |
| 227 | } |
| 228 | joined := strings.Join(page.Notices, " ") |
| 229 | for _, forbidden := range []string{"refusing", "used to allow", "people who could read"} { |
| 230 | if strings.Contains(strings.ToLower(joined), forbidden) { |
| 231 | t.Errorf("a reader's page carried the breaker's words (%q): %q", |
| 232 | forbidden, joined) |
| 233 | } |
| 234 | } |
| 235 | // NOT_VISIBLE is the silent value, so this page says nothing at all — |
| 236 | // which is also the assertion that no count leaked. |
| 237 | if len(page.Notices) != 0 { |
| 238 | t.Errorf("a silently-omitted passage produced notices: %v", page.Notices) |
| 239 | } |
| 240 | |
| 241 | // The owner's side: the sentence, with its number. |
| 242 | b, err := s.RefusalBreaker(ctx, "Sheets", breakerClock) |
| 243 | if err != nil { |
| 244 | t.Fatalf("RefusalBreaker: %v", err) |
| 245 | } |
| 246 | if b.Verdict != BreakerFiring { |
| 247 | t.Fatalf("the fixture did not fire (%v); nothing above is about "+ |
| 248 | "placement if there is no notice to place", b.Verdict) |
| 249 | } |
| 250 | sentence, alarming := breakerSentence(b) |
| 251 | if !strings.Contains(sentence, "3") || !strings.Contains(sentence, "Sheets") { |
| 252 | t.Errorf("the owner's sentence lacks the app or the count: %q", sentence) |
| 253 | } |
| 254 | if !alarming { |
| 255 | t.Error("a firing breaker did not mark its row") |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | // **A zero row is not a refusal**, and this is the only thing that can |
| 260 | // reach the clause saying so. |
| 261 | // |
| 262 | // `RecordAuthorizations` writes no row when an answer set contains neither |
| 263 | // a VISIBLE nor a NOT_VISIBLE, so nothing on the production path produces |
| 264 | // `{0, 0}` — which makes `NotVisible >= 1` unreachable through it, and a |
| 265 | // review's mutation dropping that half stayed green. That is the same |
| 266 | // shape as the `ASKED` counter it replaced: a guard nothing can reach. |
| 267 | // |
| 268 | // The difference is that this one is defending an invariant enforced two |
| 269 | // functions away, in a different file, on a path a future change could |
| 270 | // easily alter — write a row to record "this person asked", and every |
| 271 | // baseline asker in an outage becomes a refusal. So rather than delete the |
| 272 | // clause, the row is inserted directly here, which is the same remedy |
| 273 | // `scatter` got in the sibling package: make the branch reachable from a |
| 274 | // test and pin what it defends. |
| 275 | // |
| 276 | // MUTATION-TESTED 2026-09-07, each verified red then reverted: |
| 277 | // |
| 278 | // drop `&& t.NotVisible >= 1` -> red, the outage fires |
| 279 | // count a zero row as an ask -> red, same |
| 280 | func TestAZeroRowIsNotARefusal(t *testing.T) { |
| 281 | s := breakerStore(t) |
| 282 | |
| 283 | // Three people the sibling allowed last week. |
| 284 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 285 | countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) |
| 286 | } |
| 287 | |
| 288 | // Today each has a row saying they saw nothing and were refused |
| 289 | // nothing — the shape an "asked, got UNKNOWN" record would take. |
| 290 | // Inserted directly, because no production path writes one. |
| 291 | for _, p := range []string{"ana@x", "ben@x", "cleo@x"} { |
| 292 | print, err := s.sealer.Fingerprint(seal.PurposeAsker, []byte("asker\x00"+p)) |
| 293 | if err != nil { |
| 294 | t.Fatalf("fingerprint: %v", err) |
| 295 | } |
| 296 | sealed, err := s.sealTally(tally{}) |
| 297 | if err != nil { |
| 298 | t.Fatalf("seal: %v", err) |
| 299 | } |
| 300 | if err := s.db.G.Create(&SiblingTally{ |
| 301 | App: "Sheets", AskerPrint: print, |
| 302 | Day: breakerClock.Format("2006-01-02"), |
| 303 | SealedCounts: sealed, |
| 304 | }).Error; err != nil { |
| 305 | t.Fatalf("insert: %v", err) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | // The presence half: the rows really are there, so what follows is an |
| 310 | // assertion about the predicate and not about an empty window. |
| 311 | var n int64 |
| 312 | if err := s.db.G.Model(&SiblingTally{}). |
| 313 | Where("day = ?", breakerClock.Format("2006-01-02")).Count(&n).Error; err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | if n != 3 { |
| 317 | t.Fatalf("%d zero rows seeded, want 3", n) |
| 318 | } |
| 319 | |
| 320 | if b := verdict(t, s); b.Verdict != BreakerQuiet { |
| 321 | t.Errorf("verdict = %v (askers=%d), want quiet: three baseline people "+ |
| 322 | "with nothing recorded against them today were counted as refused, "+ |
| 323 | "which is what turns a 24-hour outage into a permissions anomaly", |
| 324 | b.Verdict, b.Askers) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // The wake prunes. |
| 329 | // |
| 330 | // `PruneTallies` was written with a comment saying a retention rule |
| 331 | // nothing enforces is a sentence in a document, and then nothing called |
| 332 | // it — a review found the sentence describing its own state. §3.2: an |
| 333 | // unbounded version of these counters is a year-long history of who was |
| 334 | // locked out of what, and it is the only per-person record memoria keeps. |
| 335 | // |
| 336 | // MUTATION-TESTED 2026-09-07: drop the PruneTallies call from |
| 337 | // WireSiblings and this goes red with the old row still there. Reverted. |
| 338 | func TestWiringPrunesOldCounters(t *testing.T) { |
| 339 | s := storeForTest(t) |
| 340 | |
| 341 | // A row far outside any window, and one inside it. |
| 342 | countOn(t, s, "Docs", "ana@x", time.Now().UTC().AddDate(0, 0, -40), 1, 0) |
| 343 | countOn(t, s, "Docs", "ana@x", time.Now().UTC(), 1, 0) |
| 344 | if got := tallyDays(t, s); len(got) != 2 { |
| 345 | t.Fatalf("%d rows seeded, want 2", len(got)) |
| 346 | } |
| 347 | |
| 348 | a := &app{store: s, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} |
| 349 | if err := WireSiblings(a, func(k string) string { |
| 350 | return map[string]string{ |
| 351 | "MEMORIA_TEAM": "acme", |
| 352 | "MEMORIA_SIBLINGS": "Docs=https://acme.docs.oficina.works", |
| 353 | "MEMORIA_SIBLING_TOKEN_DOCS": "t", |
| 354 | }[k] |
| 355 | }, nil); err != nil { |
| 356 | t.Fatalf("WireSiblings: %v", err) |
| 357 | } |
| 358 | |
| 359 | got := tallyDays(t, s) |
| 360 | if got[time.Now().UTC().AddDate(0, 0, -40).Format("2006-01-02")] { |
| 361 | t.Error("a 40-day-old counter survived the wake: this is the only " + |
| 362 | "per-person record memoria keeps, and the retention rule is the " + |
| 363 | "whole of what bounds it") |
| 364 | } |
| 365 | if !got[time.Now().UTC().Format("2006-01-02")] { |
| 366 | t.Error("today's counter was pruned; the prune is meant to bound the " + |
| 367 | "history, not to empty it") |
| 368 | } |
| 369 | } |
| 370 | |