oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12
13 "amadan.net/rastrillo/rastrillo/db"
14 "amadan.net/rastrillo/rastrillo/migrate"
15
16 "amadan.net/oficina/suite"
17
18 "amadan.net/oficina/memoria/internal/memoria/index"
19 "amadan.net/oficina/memoria/internal/memoria/seal"
20 "amadan.net/oficina/memoria/internal/memoria/sibling"
21)
22
23// fixedCustody rules every candidate the same way and records what it was
24// asked. It is the seam that lets §4.1's table be exercised end to end
25// without a live sibling.
26type fixedCustody struct {
27 // apps is the configured roster. A source not in it does not resolve.
28 apps map[string]sibling.Sibling
29 give suite.Authorization
30 // asked is what reached the client: the asker, and the pairs.
31 asker suite.Subject
32 pairs []suite.ItemRevision
33}
34
35func (f *fixedCustody) Sibling(source string) (sibling.Sibling, bool) {
36 s, ok := f.apps[source]
37 return s, ok
38}
39
40func (f *fixedCustody) Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition {
41 f.asker = asker
42 out := make([]sibling.Disposition, len(cands))
43 for i, c := range cands {
44 f.pairs = append(f.pairs, c.Item)
45 out[i] = sibling.Decide(f.give)
46 }
47 return out
48}
49
50func docsRoster() map[string]sibling.Sibling {
51 return map[string]sibling.Sibling{
52 "Docs": {Name: "Docs", BaseURL: "https://docs.example", Token: "t"},
53 }
54}
55
56// storeWithASiblingDocument seeds one document owned by Docs and one owned
57// by memoria, both matching the same query.
58//
59// **Two documents, not one, and that is the presence assertion.** A test
60// with only a sibling document cannot tell "the ruling withheld it" from
61// "the search found nothing"; the memoria-owned row is the control that
62// stays on the page in every case below, so an empty result is always
63// attributable.
64func storeWithASiblingDocument(t *testing.T, custody Custody) *Store {
65 t.Helper()
66 s := storeForTest(t)
67 s.custody = custody
68 ctx := context.Background()
69 if _, err := s.Ingest(ctx, IngestSpec{
70 Source: "Docs", Key: "docs/handbook", Title: "Handbook",
71 Body: "The quarterly planning process runs in March.",
72 ItemID: "docs-item-991",
73 SiblingRevision: 7,
74 }); err != nil {
75 t.Fatalf("ingest sibling doc: %v", err)
76 }
77 if _, err := s.Ingest(ctx, IngestSpec{
78 Source: "upload", Key: "notes.txt", Title: "Notes",
79 Body: "My own notes on the quarterly planning process.",
80 }); err != nil {
81 t.Fatalf("ingest owned doc: %v", err)
82 }
83 return s
84}
85
86// §4.1 end to end, through the only method that returns a renderable
87// passage.
88//
89// This is the gate the sibling package's unit tests cannot be: they prove
90// `Decide` maps values and `Rule` fans out, and neither proves that the
91// thing which actually renders a page consults either. The failure they
92// leave open is a `Search` that computes a disposition and ignores it.
93//
94// MUTATION-TESTED 2026-09-06, each verified red then reverted:
95//
96// Search returns `out` without calling rule()
97// -> every withholding case red
98// rule() keeps a passage when d.Notices() rather than d.Renders()
99// -> VISIBLE case red, NOT_VISIBLE case red
100// rule() treats an unresolvable source as memoria-owned
101// -> the unconfigured-sibling case red
102// rule() skips the nil-custody branch
103// -> nil case red (and it renders, which is the disclosure)
104// Search asks about the memoria-owned document too
105// -> the pairs assertion red
106func TestASiblingPassageIsNeverSpokenUnruled(t *testing.T) {
107 ctx := context.Background()
108
109 for _, c := range []struct {
110 name string
111 give suite.Authorization
112 wantSibling bool
113 wantNotice string
114 }{
115 {"VISIBLE renders", suite.VISIBLE, true, ""},
116 {"NOT_VISIBLE omits silently", suite.NOT_VISIBLE, false, ""},
117 {"STALE withholds loudly", suite.STALE, false, "Docs has moved on from 1 result; refetching."},
118 {"UNKNOWN withholds loudly", suite.UNKNOWN, false, "Docs did not answer; 1 result withheld."},
119 } {
120 t.Run(c.name, func(t *testing.T) {
121 cus := &fixedCustody{apps: docsRoster(), give: c.give}
122 s := storeWithASiblingDocument(t, cus)
123
124 page, err := s.Search(ctx, testAsker, "quarterly planning", 10)
125 if err != nil {
126 t.Fatalf("search: %v", err)
127 }
128
129 var sawSibling, sawOwned bool
130 for _, r := range page.Results {
131 switch r.Source {
132 case "Docs":
133 sawSibling = true
134 case "upload":
135 sawOwned = true
136 }
137 }
138 // The control. Without it, every "the sibling passage is not
139 // here" assertion below is satisfied by a search that found
140 // nothing at all.
141 if !sawOwned {
142 t.Fatal("memoria's own document did not render, so this test " +
143 "cannot tell withholding from an empty search")
144 }
145 if sawSibling != c.wantSibling {
146 t.Errorf("sibling passage rendered = %v, want %v", sawSibling, c.wantSibling)
147 }
148
149 switch c.wantNotice {
150 case "":
151 if len(page.Notices) != 0 {
152 t.Errorf("notices = %v, want none — %q is not a loud value",
153 page.Notices, c.give)
154 }
155 default:
156 if len(page.Notices) != 1 || page.Notices[0] != c.wantNotice {
157 t.Errorf("notices = %v, want [%q]", page.Notices, c.wantNotice)
158 }
159 }
160
161 // The question that went out names the sibling's own
162 // coordinate and this person — and names memoria's own
163 // document to nobody, because §4.2 makes memoria its app of
164 // record and there is nobody else to ask.
165 if len(cus.pairs) != 1 {
166 t.Fatalf("asked about %d items, want 1: memoria's own content "+
167 "has no app of record to ask", len(cus.pairs))
168 }
169 want := suite.ItemRevision{ItemID: "docs-item-991", Revision: 7}
170 if cus.pairs[0] != want {
171 t.Errorf("asked about %+v, want %+v", cus.pairs[0], want)
172 }
173 if cus.asker != testAsker {
174 t.Errorf("asked on behalf of %+v, want %+v", cus.asker, testAsker)
175 }
176 })
177 }
178}
179
180// The two ways there is nobody to ask, which must not become "nobody
181// objects".
182//
183// A sibling dropped from configuration leaves its documents in the store.
184// So does an instance wired without any custody at all. In both, memoria
185// holds a passage whose app of record it cannot reach — and the tempting
186// reading, that an unreachable owner imposes no restriction, is the
187// disclosure §4.1 exists to prevent.
188//
189// MUTATION-TESTED 2026-09-06: make either branch fall through to
190// rendering and the matching case goes red, showing the sibling passage on
191// the page. Reverted.
192func TestNobodyToAskIsNotNobodyObjecting(t *testing.T) {
193 ctx := context.Background()
194
195 t.Run("a source with no configured app of record", func(t *testing.T) {
196 // The roster knows Sheets. The document came from Docs.
197 cus := &fixedCustody{
198 apps: map[string]sibling.Sibling{"Sheets": {Name: "Sheets"}},
199 give: suite.VISIBLE,
200 }
201 s := storeWithASiblingDocument(t, cus)
202
203 page, err := s.Search(ctx, testAsker, "quarterly planning", 10)
204 if err != nil {
205 t.Fatalf("search: %v", err)
206 }
207 assertOnlyOwnedRendered(t, page)
208 if len(cus.pairs) != 0 {
209 t.Errorf("asked %d questions of a sibling that is not configured", len(cus.pairs))
210 }
211 })
212
213 t.Run("no custody at all", func(t *testing.T) {
214 s := storeWithASiblingDocument(t, nil)
215 page, err := s.Search(ctx, testAsker, "quarterly planning", 10)
216 if err != nil {
217 t.Fatalf("search: %v", err)
218 }
219 assertOnlyOwnedRendered(t, page)
220 })
221}
222
223func assertOnlyOwnedRendered(t *testing.T, page Page) {
224 t.Helper()
225 var sawOwned bool
226 for _, r := range page.Results {
227 if r.Source == "Docs" {
228 t.Errorf("a passage from an app of record memoria cannot reach was "+
229 "spoken: %q. An unreachable owner is not an absent one", r.Title)
230 }
231 if r.Source == "upload" {
232 sawOwned = true
233 }
234 }
235 if !sawOwned {
236 t.Fatal("memoria's own document did not render, so this proves nothing")
237 }
238 if len(page.Notices) != 1 || !strings.Contains(page.Notices[0], "did not answer") {
239 t.Errorf("notices = %v, want one saying Docs did not answer — a passage "+
240 "dropped without a notice is a page that reads as complete", page.Notices)
241 }
242}
243
244// A search with nobody on it is refused, not answered.
245//
246// §4.1's question is "may THIS PERSON read this passage", and there is no
247// answer to it for nobody. Refusing here rather than sending an empty
248// subject also keeps the failure on memoria's side of the wire, where it
249// can be read, instead of arriving at a sibling as an unanswerable call.
250//
251// MUTATION-TESTED 2026-09-06: drop the guard and the search returns
252// memoria-owned passages to an unidentified caller. Red. Reverted.
253func TestASearchWithNoAskerIsRefused(t *testing.T) {
254 s := storeWithASiblingDocument(t, &fixedCustody{apps: docsRoster(), give: suite.VISIBLE})
255 page, err := s.Search(context.Background(), suite.Subject{}, "quarterly planning", 10)
256 if err == nil {
257 t.Fatalf("a search with no asker returned %d results", len(page.Results))
258 }
259 if len(page.Results) != 0 {
260 t.Errorf("a refused search still returned passages: %d", len(page.Results))
261 }
262}
263
264// Both or neither: a sibling document needs an item id and a revision.
265//
266// Either half alone is worse than neither. An item id with revision 0 asks
267// a sibling about a body it never issued; a revision with no item id is a
268// document nothing can be asked about, which would then render as
269// memoria's own.
270//
271// MUTATION-TESTED 2026-09-06: default the missing half instead of
272// refusing, and both cases go red — the second one by rendering a sibling
273// document with no ruling at all. Reverted.
274func TestASiblingDocumentNeedsBothHalvesOfItsCoordinate(t *testing.T) {
275 ctx := context.Background()
276 for _, c := range []struct {
277 name string
278 spec IngestSpec
279 }{
280 {"an item id with no revision", IngestSpec{
281 Source: "Docs", Key: "k1", Title: "T", Body: "body", ItemID: "docs-1",
282 }},
283 {"a revision with no item id", IngestSpec{
284 Source: "Docs", Key: "k2", Title: "T", Body: "body", SiblingRevision: 4,
285 }},
286 } {
287 t.Run(c.name, func(t *testing.T) {
288 s := storeForTest(t)
289 if _, err := s.Ingest(ctx, c.spec); err == nil {
290 t.Error("stored a half-coordinated sibling document")
291 }
292 })
293 }
294
295 t.Run("both halves store", func(t *testing.T) {
296 // Without this the two refusals above are satisfied by an Ingest
297 // that refuses every sibling document.
298 s := storeForTest(t)
299 if _, err := s.Ingest(ctx, IngestSpec{
300 Source: "Docs", Key: "k3", Title: "T", Body: "body",
301 ItemID: "docs-3", SiblingRevision: 9,
302 }); err != nil {
303 t.Errorf("a fully-coordinated sibling document was refused: %v", err)
304 }
305 })
306}
307
308// storeForTest is a store with a real database and a real index and no
309// custody set — the caller supplies whatever apps of record the test is
310// about.
311func storeForTest(t *testing.T) *Store {
312 t.Helper()
313 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
314 d, err := db.Open(filepath.Join(t.TempDir(), "store.db"), logger)
315 if err != nil {
316 t.Fatalf("db.Open: %v", err)
317 }
318 t.Cleanup(func() { d.Close() })
319 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
320 t.Fatalf("migrate: %v", err)
321 }
322 dek := make([]byte, 32)
323 for i := range dek {
324 dek[i] = byte(i*11 + 3)
325 }
326 sealer, err := seal.New(dek)
327 if err != nil {
328 t.Fatalf("seal.New: %v", err)
329 }
330 ix, err := index.Open()
331 if err != nil {
332 t.Fatalf("index.Open: %v", err)
333 }
334 t.Cleanup(func() { ix.Close() })
335 return NewStore(d, sealer, ix, nil)
336}
337
338// A page's notices are in a fixed order, and the order is the reader's.
339//
340// **This is here because the first version was not.** `rule()` built its
341// unresolved-source notices by ranging a `map[int]string`, so a page with
342// two unreachable apps listed them differently on every reload — Go
343// randomises map iteration exactly so that this surfaces, and it did. A
344// notice that moves is a notice a reader stops trusting.
345//
346// The order asserted is the order the results themselves are in, which is
347// the order the reader meets the passages the lines are about.
348//
349// MUTATION-TESTED 2026-09-06, each verified red then reverted:
350//
351// build the notices from a map keyed by result index
352// -> flaky, then red at -count=20 (the failure the fix is for)
353// sort the notices alphabetically
354// -> red: Sheets would precede Wiki regardless of the ranking
355// record only the dropped passages, not every result
356// -> green here, and that is fine — Record ignores a Render. Kept in
357// this list so the next reader knows it was tried and why it is
358// not a finding.
359func TestNoticesAreInResultOrder(t *testing.T) {
360 // Two apps, neither on the roster, so both are unreachable and both
361 // have something to say. Wiki's document scores first because the
362 // query words are denser in it.
363 cus := &fixedCustody{apps: map[string]sibling.Sibling{}, give: suite.VISIBLE}
364 s := storeForTest(t)
365 s.custody = cus
366 ctx := context.Background()
367 for _, d := range []struct {
368 source, key, title, body, item string
369 }{
370 {"Wiki", "wiki/1", "Wiki", "Quarterly planning, quarterly planning, quarterly planning.", "wiki-1"},
371 {"Sheets", "sheets/1", "Sheet", "A note about quarterly planning somewhere in a longer document that dilutes it considerably with many other words.", "sheets-1"},
372 } {
373 if _, err := s.Ingest(ctx, IngestSpec{
374 Source: d.source, Key: d.key, Title: d.title, Body: d.body,
375 ItemID: d.item, SiblingRevision: 3,
376 }); err != nil {
377 t.Fatalf("ingest %s: %v", d.source, err)
378 }
379 }
380
381 want := []string{
382 "Wiki did not answer; 1 result withheld.",
383 "Sheets did not answer; 1 result withheld.",
384 }
385 for i := 0; i < 20; i++ {
386 page, err := s.Search(ctx, testAsker, "quarterly planning", 10)
387 if err != nil {
388 t.Fatalf("search: %v", err)
389 }
390 if len(page.Results) != 0 {
391 t.Fatalf("run %d: %d passages rendered from two unreachable apps",
392 i, len(page.Results))
393 }
394 if !reflect.DeepEqual(page.Notices, want) {
395 t.Fatalf("run %d: notices = %v, want %v — a page whose notices "+
396 "reorder between reloads is a page a reader learns to skip",
397 i, page.Notices, want)
398 }
399 }
400}
401
402// **A count in a notice counts passages, not documents**, and this pins it
403// because nothing did.
404//
405// The two readings differ the moment a document contributes more than one
406// chunk, which is the ordinary case. "3 results withheld" for a
407// three-passage document is right, because the page counts results and a
408// result is a passage — the reader is being told how much shorter their
409// page is, not how many files exist. A review found the choice unmade:
410// recording per distinct pair instead of per candidate left every test
411// green and quietly changed the sentence.
412//
413// MUTATION-TESTED 2026-09-06, each verified red then reverted:
414//
415// Store.rule records per distinct (item, revision) instead of per result
416// -> "1 result withheld"; red
417// Record is moved after the keep check so only drops are recorded
418// -> green, and correctly so: Record ignores a Render. Listed here so
419// the next reader knows it was tried and is not a finding.
420func TestAWithholdingCountCountsPassages(t *testing.T) {
421 ctx := context.Background()
422
423 // One document that chunks into several passages, all of which carry
424 // the same (item_id, revision). Paragraphs, because the chunker splits
425 // on them — a first version repeated one long line and produced a
426 // single chunk, which made the assertion below vacuous.
427 var body strings.Builder
428 for i := 0; i < 12; i++ {
429 body.WriteString("The quarterly planning process runs in March. ")
430 body.WriteString(strings.Repeat("Supporting detail follows in some length. ", 20))
431 body.WriteString("\n\n")
432 }
433 seed := func(t *testing.T, give suite.Authorization) (*Store, *fixedCustody) {
434 t.Helper()
435 cus := &fixedCustody{apps: docsRoster(), give: give}
436 s := storeForTest(t)
437 s.custody = cus
438 if _, err := s.Ingest(ctx, IngestSpec{
439 Source: "Docs", Key: "docs/long", Title: "Long",
440 Body: body.String(), ItemID: "docs-long", SiblingRevision: 2,
441 }); err != nil {
442 t.Fatalf("ingest: %v", err)
443 }
444 return s, cus
445 }
446
447 // **How many passages this document contributes is asked, not
448 // assumed.** Chunking is the chunker's business and it changes; a test
449 // that hard-codes the number tests the chunker by accident and goes
450 // green on 1 when the assertion needs >1 to mean anything.
451 visible, _ := seed(t, suite.VISIBLE)
452 shown, err := visible.Search(ctx, testAsker, "quarterly planning", 10)
453 if err != nil {
454 t.Fatalf("search: %v", err)
455 }
456 passages := len(shown.Results)
457 if passages < 2 {
458 t.Fatalf("the fixture document contributes %d passage(s); with fewer "+
459 "than 2 this test cannot tell passages from documents", passages)
460 }
461
462 withheld, cus := seed(t, suite.UNKNOWN)
463 page, err := withheld.Search(ctx, testAsker, "quarterly planning", 10)
464 if err != nil {
465 t.Fatalf("search: %v", err)
466 }
467 if len(page.Results) != 0 {
468 t.Fatalf("%d passages rendered from an UNKNOWN sibling", len(page.Results))
469 }
470 // The store hands the client **one candidate per passage**, and the
471 // pairs are identical because they are the same document. Collapsing
472 // them into one question is the client's job (`sibling.Client.Rule`,
473 // and TestOneDocumentIsAskedAboutOnce is where that is gated) — it has
474 // to be, because the client is what knows about batches and the wire.
475 //
476 // Keeping the store's side one-per-passage is what makes the mapping
477 // back from answers to results a plain index, which is the thing that
478 // must not go wrong. Asserted here so the division is written down
479 // rather than inferred from whichever test happens to fail.
480 if len(cus.pairs) != passages {
481 t.Errorf("the store offered %d candidates for %d passages of one "+
482 "document, want one each", len(cus.pairs), passages)
483 }
484 for _, p := range cus.pairs {
485 if want := (suite.ItemRevision{ItemID: "docs-long", Revision: 2}); p != want {
486 t.Errorf("candidate %+v, want %+v — every passage of a document "+
487 "carries that document's coordinate", p, want)
488 }
489 }
490 want := []string{fmt.Sprintf("Docs did not answer; %d results withheld.", passages)}
491 if !reflect.DeepEqual(page.Notices, want) {
492 t.Errorf("notices = %v, want %v — the count is passages, which is what "+
493 "the page calls results, and not documents", page.Notices, want)
494 }
495}
496
497// Two siblings both withholding on one page, which is §6.5's ordinary
498// cold-wake case and had no fixture at all.
499//
500// It is here for two reasons. The obvious one is the copy: two apps, two
501// lines, in result order. The other is that this is the shape that makes a
502// concurrency bug in notice accumulation observable — a reviewer moved the
503// recording inside the fan-out goroutines and it stayed green under
504// `-race` purely because no test ever had two loud siblings at once. The
505// aggregation has since moved out of the concurrent path entirely, and
506// this runs with `-race` in CI so the fixture is there if it ever moves
507// back.
508func TestTwoSiblingsWithholdingOnOnePage(t *testing.T) {
509 // Neither on the roster, so both are unreachable.
510 cus := &fixedCustody{apps: map[string]sibling.Sibling{}, give: suite.VISIBLE}
511 s := storeForTest(t)
512 s.custody = cus
513 ctx := context.Background()
514 for _, d := range []struct{ source, key, body, item string }{
515 {"Sheets", "s/1", "Quarterly planning, quarterly planning, quarterly planning, quarterly planning.", "s-1"},
516 {"Sheets", "s/2", "Quarterly planning, quarterly planning, quarterly planning.", "s-2"},
517 {"Docs", "d/1", "A shorter note on quarterly planning buried in a great many other unrelated words indeed.", "d-1"},
518 } {
519 if _, err := s.Ingest(ctx, IngestSpec{
520 Source: d.source, Key: d.key, Title: d.key, Body: d.body,
521 ItemID: d.item, SiblingRevision: 1,
522 }); err != nil {
523 t.Fatalf("ingest %s: %v", d.key, err)
524 }
525 }
526
527 page, err := s.Search(ctx, testAsker, "quarterly planning", 10)
528 if err != nil {
529 t.Fatalf("search: %v", err)
530 }
531 if len(page.Results) != 0 {
532 t.Fatalf("%d passages rendered from two unreachable apps", len(page.Results))
533 }
534 want := []string{
535 "Sheets did not answer; 2 results withheld.",
536 "Docs did not answer; 1 result withheld.",
537 }
538 if !reflect.DeepEqual(page.Notices, want) {
539 t.Errorf("notices = %v, want %v — grouped per app, counted per "+
540 "passage, ordered by the results they are about",
541 page.Notices, want)
542 }
543}
544
545// storeAt is storeForTest over a directory the caller keeps, so a test can
546// close one Store and open another over the same database — which is what
547// a wake is, and the only way to assert that anything is durable rather
548// than in memory.
549func storeAt(t *testing.T, dir string) *Store {
550 t.Helper()
551 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
552 d, err := db.Open(filepath.Join(dir, "store.db"), logger)
553 if err != nil {
554 t.Fatalf("db.Open: %v", err)
555 }
556 t.Cleanup(func() { d.Close() })
557 if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
558 t.Fatalf("migrate: %v", err)
559 }
560 // The SAME key both times. A fresh DEK would make every sealed row
561 // unreadable, and the test would then be asserting that a restart
562 // loses the counters rather than keeps them.
563 dek := make([]byte, 32)
564 for i := range dek {
565 dek[i] = byte(i*11 + 3)
566 }
567 sealer, err := seal.New(dek)
568 if err != nil {
569 t.Fatalf("seal.New: %v", err)
570 }
571 ix, err := index.Open()
572 if err != nil {
573 t.Fatalf("index.Open: %v", err)
574 }
575 t.Cleanup(func() { ix.Close() })
576 return NewStore(d, sealer, ix, nil)
577}
578