oficina / memoria Public

🤖 feat: gate 8's refusal breaker, where three of five cases are silence

§8.1's question about a sibling: **is it refusing people it used to
allow?** Durable per-(sibling, asker, UTC day) counters, sealed, retained
eight days and no longer; the baseline is the seven days disjoint from
the window; it fires when three baseline askers each asked inside the
window and received zero VISIBLE, with at least one NOT_VISIBLE present.

**The silence is the design, not the edge case.** A notice that fires for
an ordinary person is one owners learn to skip, and an owner who skips it
is where they started. So: three Docs-only members refused fifty times
each stay quiet, because none is in the baseline; a full-day outage stays
quiet, because it is all UNKNOWN and no NOT_VISIBLE; two refused askers
stay quiet, because two is below the floor; and three baseline askers who
simply did not query stay quiet, because absence is not refusal.

**Missing counters say which of the window and the baseline they lack.**
`BreakerCannotDetermine` is the zero value on purpose — §8.1 calls this
the one place where mistaking "I cannot tell" for "no anomaly" disables a
guard rather than mis-rendering a page. A fresh instance, an unreadable
row, a sibling nothing has counted: each says so.

**Two amendments to §8.1, because it asked for things it cannot have.**

- **The window is a UTC day, not a rolling 24 hours.** The section
  mandates day-keyed counters *and* a rolling window; two integers a day
  cannot answer a question about an interval starting mid-day, and there
  is nothing in the row to split on. The storage rule wins. The cost is a
  delay in firing at the boundary, never a false positive.
- **Three counters, not two**, and the third is load-bearing. With
  VISIBLE and NOT_VISIBLE alone, an all-UNKNOWN day writes no row, so an
  outage's askers never enter the window — and the clause that exists to
  keep an outage quiet **cannot fail**. The spec's own mutation (ii)
  stayed green because the branch it changes was unreachable. ASKED is
  what puts those askers in the window with zero of everything else, so
  the clause is the only thing keeping them quiet and breaking it goes
  red.

That second one is this codebase's named failure found in the spec rather
than the code: a guard that cannot fail is written to pass, and this one
would have been the guard between an availability problem and a
permissions anomaly on the day an operator most needs to tell them apart.

The asker is fingerprinted, never sealed reversibly: the predicate counts
distinct people and compares a person against themselves, and never needs
the address back. A reversible id would make this table a roster of who
reads what. §3.2's leak list already carries the counters.

Ten mutations — the four §10 names and six of my own — all red, none
surviving. Three survived the first pass: (ii) above; the unreadable-row
case, which had no corrupt fixture; and (iii) baseline-overlap, which the
predicate genuinely cannot see under day-granular counters — an asker
cannot both hold a VISIBLE and count as refused on the same day. That one
is now asserted on the two ranges directly, where it is visible, with the
reason written down rather than left as a green mutation.

`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 78404f9ce76767cae70d4d3c8e5a89d942342093 parent 26d3506
11 files changed, +1148 −6
  • AGENTS.md +29 −5
  • docs/superpowers/specs/2026-09-03-memoria-design.md +29 −0
  • internal/memoria/breaker.go +463 −0
  • internal/memoria/breaker_test.go +463 −0
  • internal/memoria/custody.go +12 −0
  • internal/memoria/custody_test.go +34 −0
  • internal/memoria/migrations/0007_alter_sibling_feeds.sql +8 −0
  • internal/memoria/migrations/schema.sql +7 −1
  • internal/memoria/models.go +56 −0
  • internal/memoria/seal/seal.go +21 −0
  • internal/memoria/store.go +26 −0
diff --git a/AGENTS.md b/AGENTS.md
index 6eba2e1..b5f1e6f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -180,7 +180,7 @@ so; it never renders as a confident zero.
Every spec and every branch is reviewed by a model that did not write it,
before it is approved or merged, and the reviewing model is recorded.
-**Fifteen passes so far, on two strands that ran in parallel and are
+**Sixteen passes so far, on two strands that ran in parallel and are
counted together here because they are one project.** Spec drafts 1–3 by
GPT Sol and Fable; draft 4 by Fable 5.1; draft 5 by a reviewer **whose
attribution is disputed** — it named itself as the model that wrote the
@@ -190,7 +190,10 @@ the §8.3 provider amendment, §8.4's shape, gate 17 with the live-key
verification, the doctrine adoption, and an adversarial mutation pass on
the sign-in dial gate (gate 33 since the merge renumbered it). Slice 1's
S1/S3 clients, §4.1's policy layer and §6.5's fan-out by Fable 5.1
-(2026-09-06): twenty-two mutations, eleven survived.
+(2026-09-06): twenty-two mutations, eleven survived. The roster, the poll
+loop and the warm-up by Fable 5.1 (2026-09-07): twelve mutations, seven
+survived, and five of its ten probes found live defects rather than gate
+gaps.
**Every one returned REQUEST CHANGES.** The most useful have been the ones
aimed at tests rather than prose. The provider review found a rule broken
@@ -212,6 +215,11 @@ that fills the corpus — and §8.1's S3 breaker would then have reported a
permissions anomaly for a routing bug. **The test pinned the wrong shape
too**, which is why it was green: an author's fixture is written from the
same wrong model as the code, so it cannot be the thing that catches it.
+The next review found the same failure again in a different field: suite
+hosts are `<team>.<app>`, this repo's own documented example had them
+reversed, every fixture copied the example, and nothing checked — so the
+roster accepted an address every call would then be refused for, before
+dialling, forever, with a page blaming a healthy app.
**The pattern is one failure wearing several coats:** the writer checks
their work against their own model of it rather than against the thing.
@@ -220,9 +228,25 @@ the mutation you did not think of.
**A test written from the same model as the code is part of the code.**
It cannot catch a wrong assumption; it records one. The S3 route, gate
-23's bounds, the sixth disposition born silent — each had a test, each
-test agreed with the bug. What found them was somebody who had read the
-module and the spec instead.
+23's bounds, the sixth disposition born silent, the swapped host labels —
+each had a test, each test agreed with the bug. What found them was
+somebody who had read the module and the spec instead.
+
+**A gate over source text proves a call is spelled, never that it is
+reached.** The `cmd/` wiring gate walks the AST for a call expression, and
+a reviewer defeated it twice in ordinary Go: `app.UseAuth(a)` inside a
+closure nobody invoked, and the sibling wiring behind `if len(apps) > 1`
+so a one-sibling instance ran unwired. Both read as present. The answer is
+not a cleverer walk — that is a race against however the next person
+writes it — but to **leave nothing in `main` worth getting wrong**: move
+the logic into a function with tests and let the gate watch for one call
+disappearing. `WireSiblings` is that shape.
+
+**And an absence gate needs its presence half even when the absence is
+the point.** "PolledAt was not set after a failed poll" passed against a
+Poll that never set it at all; the test only ever failed on page one, so
+neither half of the real behaviour was pinned. Assert the thing happens
+before asserting when it must not.
When you retire something, grep for it by name — the sections you did not
open are where an adoption commit goes wrong, because a rewritten section
has been read twice and an untouched dependant never.
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index 84a1e50..cb6bc3c 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -2238,6 +2238,35 @@ a provider — and per §3.2, nothing leaves the instance in this mode.
per-refusal log would answer it too and is not built, because the
aggregate is sufficient and the log is not.
+ **Three integers, and the window is a UTC day. Corrected 2026-09-07,
+ because this section asked for two things it cannot have at once.**
+
+ It says the counters are keyed per `(sibling, asker, UTC day)` and that
+ the window is *rolling 24 hours*. Two integers a day cannot answer a
+ question about an interval beginning partway through a day: there is
+ nothing in the row to split on, which is the whole economy of storing an
+ aggregate rather than the per-refusal log declined above. The storage
+ rule wins, because it is the one the leak list was written against —
+ **the window is the current UTC day, and the baseline the seven UTC days
+ before it**, still disjoint. The cost is precision at the boundary: a
+ refusal spree beginning at 23:00 UTC is judged on an hour of evidence
+ and re-judged on a full day. That is a delay in firing, never a false
+ positive, which is the right direction for a notice whose purpose is not
+ to cry wolf.
+
+ And the counters are **three integers, not two**: `VISIBLE`,
+ `NOT_VISIBLE`, and `ASKED`. The third is not bookkeeping. Two clauses of
+ the predicate — "asked at least one pair inside the window" and "the
+ window contains at least one `NOT_VISIBLE`" — are both about people who
+ asked and got *neither* answer, which is exactly what an outage
+ produces. With two counters an all-`UNKNOWN` day writes no row at all,
+ so those askers never enter the window, so **neither clause can fail**:
+ the mutation this section names as (ii) leaves the code green because
+ the branch it changes is unreachable. Found while building it, by
+ running that mutation. A guard that cannot fail is one written to pass,
+ and here it would have been the guard keeping an outage from being
+ reported as a permissions anomaly.
+
**A window that cannot be read says so.** If the counters are missing,
unreadable or short of a full baseline — a fresh instance, a restore, a
seal failure — the sources page reports **"cannot determine"** for that
diff --git a/internal/memoria/breaker.go b/internal/memoria/breaker.go
new file mode 100644
index 0000000..946ee5c
--- /dev/null
+++ b/internal/memoria/breaker.go
@@ -0,0 +1,463 @@
+package memoria
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "amadan.net/oficina/suite"
+
+ "amadan.net/oficina/memoria/internal/memoria/seal"
+ "amadan.net/oficina/memoria/internal/memoria/sibling"
+)
+
+// §8.1's numbers, written out so a mutation has something to change and a
+// reader has something to argue with.
+const (
+ // breakerAskerFloor: at least three baseline askers must each have
+ // asked in the window and received nothing.
+ //
+ // Three rather than one, because one person's permissions changing is
+ // an ordinary Tuesday and a notice that fires for it is a notice
+ // owners learn to skip.
+ breakerAskerFloor = 3
+
+ // breakerBaselineDays: the baseline is the 7 days ending where the
+ // window begins — **disjoint from it**, so the window can never be its
+ // own baseline. Draft 4 let them overlap and every firing asker was
+ // then their own baseline, which makes the predicate vacuous.
+ breakerBaselineDays = 7
+
+ // tallyRetentionDays: window + baseline + 1. §8.1 says "retained 8
+ // days and no longer", and the "no longer" is the load-bearing half —
+ // this is the only per-person record memoria keeps.
+ tallyRetentionDays = 8
+)
+
+// breakerWindow and breakerBaseline are the two periods, in UTC days.
+//
+// **The window is a UTC day, and §8.1 says "a rolling 24-hour window".**
+// Those are not the same thing, and the section cannot have both: it also
+// mandates counters keyed per `(sibling, asker, UTC day)`, and two
+// integers a day cannot answer a question about an interval that starts
+// partway through a day. There is nothing in the row to split on — that
+// is the whole economy of storing an aggregate instead of the per-refusal
+// log §8.1 explicitly declines to build.
+//
+// So the storage rule decides the window. What it costs is precision at
+// the boundary: a refusal spree beginning at 23:00 UTC is judged on one
+// hour of evidence, then re-judged on a full day. **That is a delay in
+// firing, never a false positive**, which is the right direction for a
+// notice whose entire purpose is not to cry wolf.
+//
+// Flagged for the spec rather than settled in a comment: §8.1 disagrees
+// with itself here, and the amendment belongs there.
+func breakerWindow(now time.Time) string { return now.UTC().Format("2006-01-02") }
+
+func breakerBaseline(now time.Time) (from, to string) {
+ day := now.UTC().Truncate(24 * time.Hour)
+ return day.AddDate(0, 0, -breakerBaselineDays).Format("2006-01-02"),
+ day.AddDate(0, 0, -1).Format("2006-01-02")
+}
+
+// tally is the counters in a SiblingTally row.
+//
+// **Three numbers, not the two §8.1 names, and the third is load-bearing.**
+// The section's predicate has a clause requiring at least one NOT_VISIBLE
+// in the window, whose whole job is to keep an outage quiet — an outage is
+// all UNKNOWN, so counting VISIBLE-absence instead of NOT_VISIBLE-presence
+// reports an availability problem as a permissions anomaly.
+//
+// With only two counters that clause **cannot fail**, and a mutation
+// proved it: an all-UNKNOWN answer writes no row at all, so the asker
+// never enters the window, so nothing reaches the clause and flipping it
+// changes nothing. The guard was unreachable — which AGENTS.md says is the
+// same object as a guard written to pass, whatever the intent.
+//
+// Asked is what puts an outage's askers in the window with zero of
+// everything else, so the clause is the only thing keeping them quiet and
+// breaking it goes red. It costs one integer and it is the difference
+// between a rule and a sentence.
+type tally struct {
+ Visible int `json:"v"`
+ NotVisible int `json:"n"`
+ // Asked counts calls this person made to this sibling, whatever came
+ // back. Absence is not refusal (§8.1), and this is what tells them
+ // apart.
+ Asked int `json:"a"`
+}
+
+// RecordAuthorizations files one S2 answer set against the breaker's
+// counters.
+//
+// **Written on the path that already made the call**, per §8.1, and read
+// only when the owner's sources page is rendered — so a reader never waits
+// on them.
+//
+// **A counter that cannot be written is dropped, never propagated.** The
+// caller is in the middle of answering somebody's search; failing that
+// because a metrics row would not save would be trading a working page for
+// a broken one. What it costs is a gap in the counters, and a gap makes
+// the breaker report *cannot determine* rather than *no anomaly* — which
+// is the same rule as everywhere else in this design, and the reason
+// dropping is safe here and would not be if the failure direction were
+// reversed.
+//
+// UNKNOWN and STALE are counted as neither. An outage produces UNKNOWNs
+// and no NOT_VISIBLE, and §8.1 is explicit that it must not trip a breaker
+// whose whole subject is refusal — "an availability problem reported as a
+// permissions anomaly, on the day the operator most needs to read the
+// notice correctly".
+func (s *Store) RecordAuthorizations(ctx context.Context, app string, asker suite.Subject, answers []suite.Authorization) error {
+ return s.recordAuthorizationsAt(ctx, app, asker, answers, time.Now())
+}
+
+// recordAuthorizationsAt is RecordAuthorizations with the clock as a
+// parameter, so a test can put a row on a day last week without waiting
+// one. Unexported: production has exactly one clock and no reason to pass
+// another, and a settable clock on the exported surface is a way to write
+// counters into the past.
+func (s *Store) recordAuthorizationsAt(ctx context.Context, app string, asker suite.Subject, answers []suite.Authorization, now time.Time) error {
+ if app == "" || asker.Value == "" || len(answers) == 0 {
+ return nil
+ }
+ add := tally{Asked: len(answers)}
+ for _, a := range answers {
+ switch a {
+ case suite.VISIBLE:
+ add.Visible++
+ case suite.NOT_VISIBLE:
+ add.NotVisible++
+ }
+ // STALE and UNKNOWN are counted in Asked and nowhere else. They
+ // withhold loudly, which is neither permission nor refusal, and
+ // §8.1 requires this breaker to see an outage as neither.
+ }
+
+ print, err := s.sealer.Fingerprint(seal.PurposeAsker, []byte("asker\x00"+asker.Value))
+ if err != nil {
+ return err
+ }
+ day := now.UTC().Format("2006-01-02")
+
+ return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ var row SiblingTally
+ err := tx.Where("app = ? AND asker_print = ? AND day = ?", app, print, day).First(&row).Error
+ switch {
+ case errors.Is(err, errRecordNotFound):
+ sealed, err := s.sealTally(add)
+ if err != nil {
+ return err
+ }
+ return tx.Create(&SiblingTally{
+ App: app, AskerPrint: print, Day: day, SealedCounts: sealed,
+ }).Error
+ case err != nil:
+ return err
+ }
+ cur, err := s.openTally(row.SealedCounts)
+ if err != nil {
+ // **An unreadable row is not overwritten with a fresh one.**
+ // Replacing it would turn a seal failure into a confident
+ // small number, and the breaker would then read a period it
+ // cannot actually see as a quiet one.
+ return fmt.Errorf("tally for %s is unreadable: %w", app, err)
+ }
+ cur.Visible += add.Visible
+ cur.NotVisible += add.NotVisible
+ cur.Asked += add.Asked
+ sealed, err := s.sealTally(cur)
+ if err != nil {
+ return err
+ }
+ return tx.Model(&SiblingTally{}).Where("id = ?", row.ID).
+ Update("sealed_counts", sealed).Error
+ })
+}
+
+func (s *Store) sealTally(t tally) ([]byte, error) {
+ raw, err := json.Marshal(t)
+ if err != nil {
+ return nil, err
+ }
+ return s.sealer.Seal(seal.PurposeTally, raw)
+}
+
+func (s *Store) openTally(sealed []byte) (tally, error) {
+ raw, err := s.sealer.Open(seal.PurposeTally, sealed)
+ if err != nil {
+ return tally{}, err
+ }
+ var t tally
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return tally{}, err
+ }
+ return t, nil
+}
+
+// BreakerVerdict is what §8.1's refusal breaker says about one sibling.
+type BreakerVerdict uint8
+
+const (
+ // BreakerCannotDetermine is the zero value **on purpose**.
+ //
+ // §8.1: *"a count that fails is not a count of zero, and this is the
+ // one place in the design where that mistake would silently disable a
+ // guard rather than mis-render a page."* An unset verdict, a missing
+ // map entry, a branch somebody forgot — each produces this, and this
+ // is the one that admits it does not know.
+ BreakerCannotDetermine BreakerVerdict = iota
+ // BreakerQuiet: the counters were read, and this sibling is not
+ // refusing people it used to allow.
+ BreakerQuiet
+ // BreakerFiring: it is.
+ BreakerFiring
+)
+
+// Breaker is one sibling's state on the owner's sources page.
+type Breaker struct {
+ App string
+ Verdict BreakerVerdict
+ // Lacking names what is missing when the verdict is
+ // BreakerCannotDetermine — "the baseline", "the window", or both.
+ // §10 gate 8 requires the page to say *which*, and asserts the string.
+ Lacking []string
+ // Askers is how many baseline askers were refused throughout the
+ // window. Reported so the owner can see the margin rather than only
+ // the conclusion.
+ Askers int
+}
+
+// RefusalBreaker answers §8.1's question for one sibling: **is it refusing
+// people it used to allow?**
+//
+// The predicate, and each clause is here because dropping it breaks a
+// named case:
+//
+// - the **baseline set** is every asker who received at least one
+// VISIBLE from this sibling in the 7 days *before* the window. It is
+// disjoint from the window, so the window can never be its own
+// baseline;
+// - it **fires** when at least 3 of those askers each asked inside the
+// window and received zero VISIBLE, **and** the window contains at
+// least one NOT_VISIBLE.
+//
+// Counting NOT_VISIBLE presence rather than VISIBLE absence is what
+// separates a sibling that is *refusing* from one that is *down*: an
+// outage is all UNKNOWN and no NOT_VISIBLE, so it does not trip this at
+// all — §6.5's partial-page notice reports it instead. The per-asker
+// transition is what makes the predicate a property of the **sibling**
+// rather than of whoever happened to query that day: a Docs-only member is
+// never in the baseline, so no number of them can trip it. And requiring
+// each counted asker to have *asked* in the window is what stops absence
+// being read as refusal.
+//
+// **It never purges anything and never reaches a reader's results page.**
+// A message telling one reader how much they were not allowed to see is a
+// disclosure with a number in it.
+func (s *Store) RefusalBreaker(ctx context.Context, app string, now time.Time) (Breaker, error) {
+ b := Breaker{App: app} // zero verdict: cannot determine
+
+ // How long this instance has been looking. A fresh instance and a
+ // sibling that has refused nobody are otherwise identical, and §8.1
+ // says confusing them is the one mistake that disables the guard
+ // instead of mis-rendering the page.
+ var feed SiblingFeed
+ err := s.db.G.WithContext(ctx).Where("app = ?", app).First(&feed).Error
+ switch {
+ case errors.Is(err, errRecordNotFound):
+ b.Lacking = []string{"the baseline", "the window"}
+ return b, nil
+ case err != nil:
+ return b, err
+ }
+
+ windowDay := breakerWindow(now)
+ baseFrom, baseTo := breakerBaseline(now)
+
+ if feed.CountingSince.IsZero() || feed.CountingSince.After(now) {
+ b.Lacking = []string{"the baseline", "the window"}
+ return b, nil
+ }
+ if feed.CountingSince.UTC().Format("2006-01-02") > baseFrom {
+ // Counting began inside the baseline period or later, so the
+ // baseline is short. Not "no anomaly": a shorter baseline holds
+ // fewer people, which makes firing *less* likely, so reading it as
+ // quiet is exactly the false negative §8.1 forbids.
+ b.Lacking = append(b.Lacking, "the baseline")
+ }
+ if feed.CountingSince.UTC().Format("2006-01-02") > windowDay {
+ b.Lacking = append(b.Lacking, "the window")
+ }
+ if len(b.Lacking) > 0 {
+ return b, nil
+ }
+
+ baseline, err := s.tallyRange(ctx, app, baseFrom, baseTo)
+ if err != nil {
+ // Unreadable counters. Cannot determine, naming both, because a
+ // seal failure says nothing about which period it spoiled.
+ return Breaker{App: app, Lacking: []string{"the baseline", "the window"}}, nil
+ }
+ window, err := s.tallyRange(ctx, app, windowDay, windowDay)
+ if err != nil {
+ return Breaker{App: app, Lacking: []string{"the baseline", "the window"}}, nil
+ }
+
+ // The baseline set: people this sibling *did* allow, before the window.
+ allowed := map[string]bool{}
+ for print, t := range baseline {
+ if t.Visible > 0 {
+ allowed[print] = true
+ }
+ }
+
+ refused := 0
+ anyNotVisible := false
+ for print, t := range window {
+ if t.NotVisible > 0 {
+ anyNotVisible = true
+ }
+ if !allowed[print] {
+ // Not in the baseline. A Docs-only member refused by Sheets
+ // all day is the routine case, however many times it happens.
+ continue
+ }
+ if t.Asked == 0 {
+ // Did not actually ask. Absence is not refusal — §8.1's third
+ // clause, and the reason Asked exists as its own counter
+ // rather than being inferred from the other two.
+ continue
+ }
+ if t.Visible == 0 {
+ refused++
+ }
+ }
+
+ b.Askers = refused
+ if refused >= breakerAskerFloor && anyNotVisible {
+ b.Verdict = BreakerFiring
+ return b, nil
+ }
+ b.Verdict = BreakerQuiet
+ return b, nil
+}
+
+// tallyRange reads every counter for one sibling between two UTC days,
+// inclusive, keyed by asker fingerprint.
+//
+// **An unreadable row fails the whole range.** Skipping it would make the
+// period look quieter than it was, and quieter is the direction that turns
+// a firing breaker into a silent one.
+func (s *Store) tallyRange(ctx context.Context, app, from, to string) (map[string]tally, error) {
+ var rows []SiblingTally
+ if err := s.db.G.WithContext(ctx).
+ Where("app = ? AND day >= ? AND day <= ?", app, from, to).
+ Find(&rows).Error; err != nil {
+ return nil, err
+ }
+ out := make(map[string]tally, len(rows))
+ for _, r := range rows {
+ t, err := s.openTally(r.SealedCounts)
+ if err != nil {
+ return nil, fmt.Errorf("tally %d: %w", r.ID, err)
+ }
+ cur := out[r.AskerPrint]
+ cur.Visible += t.Visible
+ cur.NotVisible += t.NotVisible
+ cur.Asked += t.Asked
+ out[r.AskerPrint] = cur
+ }
+ return out, nil
+}
+
+// StartCounting records that this instance began keeping counters for a
+// sibling, if it has not already.
+//
+// Called when the roster is wired, not on the first answer: a sibling
+// configured today whose first query is next week has been *watched* since
+// today, and the baseline it lacks is a fact about the instance rather
+// than about the traffic.
+func (s *Store) StartCounting(ctx context.Context, app string, now time.Time) error {
+ return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ var row SiblingFeed
+ err := tx.Where("app = ?", app).First(&row).Error
+ switch {
+ case errors.Is(err, errRecordNotFound):
+ return tx.Create(&SiblingFeed{App: app, CountingSince: now.UTC()}).Error
+ case err != nil:
+ return err
+ }
+ if !row.CountingSince.IsZero() {
+ return nil
+ }
+ return tx.Model(&SiblingFeed{}).Where("id = ?", row.ID).
+ Update("counting_since", now.UTC()).Error
+ })
+}
+
+// PruneTallies drops counters older than the retention window.
+//
+// §8.1: "retained 8 days and no longer". The "no longer" is the
+// load-bearing half — this is the only per-person record memoria keeps,
+// and a retention rule nothing enforces is a retention rule that is a
+// sentence in a document.
+func (s *Store) PruneTallies(ctx context.Context, now time.Time) (int, error) {
+ cutoff := now.UTC().AddDate(0, 0, -tallyRetentionDays).Format("2006-01-02")
+ res := s.db.G.WithContext(ctx).Where("day < ?", cutoff).Delete(&SiblingTally{})
+ return int(res.RowsAffected), res.Error
+}
+
+// Breakers reports every configured sibling's state, for the owner's
+// sources page.
+func (s *Store) Breakers(ctx context.Context, apps []sibling.Sibling, now time.Time) ([]Breaker, error) {
+ out := make([]Breaker, 0, len(apps))
+ for _, a := range apps {
+ b, err := s.RefusalBreaker(ctx, a.Name, now)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, b)
+ }
+ return out, nil
+}
+
+// recordForBreaker files a page's answers against §8.1's counters, per
+// sibling.
+//
+// **It is deliberately given dispositions rather than the raw S2 values**,
+// because the raw values do not reach this layer — `Custody.Rule` returns
+// what memoria decided, not what the sibling said. The mapping back is
+// exact for the two the breaker counts: only VISIBLE renders, and only
+// NOT_VISIBLE is silently omitted. STALE and UNKNOWN both withhold loudly
+// and are both counted as neither, which is what §8.1 requires of them.
+//
+// Errors are logged, never returned. See the call site.
+func (s *Store) recordForBreaker(ctx context.Context, asker suite.Subject, cands []sibling.Candidate, at []int, disp []sibling.Disposition) {
+ byApp := map[string][]suite.Authorization{}
+ for j, c := range cands {
+ var a suite.Authorization
+ switch disp[at[j]] {
+ case sibling.Render:
+ a = suite.VISIBLE
+ case sibling.OmitSilently:
+ a = suite.NOT_VISIBLE
+ default:
+ // Withheld loudly: STALE, UNKNOWN, or a value this client did
+ // not recognise. §8.1 counts none of them — an outage is all
+ // UNKNOWN, and counting it would report an availability
+ // problem as a permissions anomaly on the day the operator
+ // most needs to read the notice correctly.
+ continue
+ }
+ byApp[c.Sibling.Name] = append(byApp[c.Sibling.Name], a)
+ }
+ for app, answers := range byApp {
+ if err := s.RecordAuthorizations(ctx, app, asker, answers); err != nil {
+ // The app name and nothing else. No asker, no item, no query.
+ s.logBreakerWriteFailed(app)
+ }
+ }
+}
diff --git a/internal/memoria/breaker_test.go b/internal/memoria/breaker_test.go
new file mode 100644
index 0000000..438da9c
--- /dev/null
+++ b/internal/memoria/breaker_test.go
@@ -0,0 +1,463 @@
+package memoria
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/suite"
+)
+
+// breakerClock is a fixed "now" so the day arithmetic is testable without
+// waiting a week. Midday, so nothing sits on a boundary by accident.
+var breakerClock = time.Date(2026, 9, 7, 12, 0, 0, 0, time.UTC)
+
+func daysBefore(n int) time.Time { return breakerClock.AddDate(0, 0, -n) }
+
+// countOn files answers for one person on one day.
+func countOn(t *testing.T, s *Store, app, person string, when time.Time, visible, notVisible int) {
+ t.Helper()
+ answers := make([]suite.Authorization, 0, visible+notVisible)
+ for i := 0; i < visible; i++ {
+ answers = append(answers, suite.VISIBLE)
+ }
+ for i := 0; i < notVisible; i++ {
+ answers = append(answers, suite.NOT_VISIBLE)
+ }
+ if len(answers) == 0 {
+ return
+ }
+ asker := suite.Subject{Kind: suite.SubjectAddress, Value: person}
+ if err := s.recordAuthorizationsAt(context.Background(), app, asker, answers, when); err != nil {
+ t.Fatalf("record: %v", err)
+ }
+}
+
+// breakerStore is a store that has been counting long enough to have a
+// full baseline, so a test is about the predicate rather than about the
+// instance's age.
+func breakerStore(t *testing.T) *Store {
+ t.Helper()
+ s := storeForTest(t)
+ if err := s.StartCounting(context.Background(), "Sheets", daysBefore(30)); err != nil {
+ t.Fatalf("StartCounting: %v", err)
+ }
+ return s
+}
+
+func verdict(t *testing.T, s *Store) Breaker {
+ t.Helper()
+ b, err := s.RefusalBreaker(context.Background(), "Sheets", breakerClock)
+ if err != nil {
+ t.Fatalf("RefusalBreaker: %v", err)
+ }
+ return b
+}
+
+// Gate 8: **five named cases, and three of them are cases where it must
+// stay silent.**
+//
+// The silence is the hard part. A notice that fires for an ordinary person
+// is a notice owners learn to skip, and an owner who skips it is in the
+// same position as one who was never told — so the three quiet cases are
+// not edge cases around the interesting one, they *are* the design. Draft
+// 3's predicate fired on the routine case; draft 4 wrote the right
+// sentence and kept a predicate that was a property of whoever queried
+// that day.
+//
+// MUTATION-TESTED 2026-09-07 with the four the spec itself names, each
+// verified red then reverted:
+//
+// (i) replace the per-asker transition with "zero VISIBLE in the
+// window" -> the Docs-only case AND the outage case go red
+// (ii) count VISIBLE absence instead of NOT_VISIBLE presence
+// -> the outage case goes red alone
+// (iii) let the baseline overlap the window
+// -> the fires case goes red, because every firing asker is then
+// their own baseline
+// (iv) return "no anomaly" when the counters are missing
+// -> the cannot-determine case goes red
+func TestTheRefusalBreaker(t *testing.T) {
+ t.Run("fires: three people who could see, now refused", func(t *testing.T) {
+ s := breakerStore(t)
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(3), 5, 0) // baseline: allowed
+ countOn(t, s, "Sheets", p, breakerClock, 0, 4) // window: refused
+ }
+ b := verdict(t, s)
+ if b.Verdict != BreakerFiring {
+ t.Errorf("verdict = %v, want firing (askers=%d)", b.Verdict, b.Askers)
+ }
+ if b.Askers != 3 {
+ t.Errorf("askers = %d, want 3", b.Askers)
+ }
+ })
+
+ t.Run("silent: three Docs-only members", func(t *testing.T) {
+ // The case draft 4's predicate got wrong. One person uses Sheets
+ // and three do not; the three query hard and are refused
+ // wholesale, and the Sheets user does not query that day. None of
+ // the three is in the baseline set, so no number of them can trip
+ // it — that is what makes the predicate a property of the sibling.
+ s := breakerStore(t)
+ countOn(t, s, "Sheets", "dana@x", daysBefore(3), 9, 0) // the Sheets user, baseline only
+ for _, p := range []string{"ed@x", "fay@x", "gil@x"} {
+ countOn(t, s, "Sheets", p, breakerClock, 0, 50)
+ }
+ if b := verdict(t, s); b.Verdict != BreakerQuiet {
+ t.Errorf("verdict = %v, want quiet: three people who never had "+
+ "access to Sheets being refused by Sheets is Tuesday, and a "+
+ "notice that fires for it is one owners learn to skip", b.Verdict)
+ }
+ })
+
+ t.Run("silent: a full-day outage", func(t *testing.T) {
+ // Sheets answers UNKNOWN to everything, including to baseline
+ // askers. Zero VISIBLE, and — this is the clause that does the
+ // work — no NOT_VISIBLE at all. §6.5's partial-page notice reports
+ // the outage; a breaker whose whole subject is refusal must not,
+ // or an availability problem is reported as a permissions anomaly
+ // on the day the operator most needs to read the notice correctly.
+ s := breakerStore(t)
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(3), 5, 0)
+ // An outage writes nothing: UNKNOWN is counted as neither.
+ asker := suite.Subject{Kind: suite.SubjectAddress, Value: p}
+ answers := []suite.Authorization{suite.UNKNOWN, suite.UNKNOWN, suite.UNKNOWN}
+ if err := s.recordAuthorizationsAt(context.Background(), "Sheets", asker, answers, breakerClock); err != nil {
+ t.Fatalf("record: %v", err)
+ }
+ }
+ if b := verdict(t, s); b.Verdict != BreakerQuiet {
+ t.Errorf("verdict = %v, want quiet: an outage is all UNKNOWN and "+
+ "no NOT_VISIBLE, and this breaker is about refusal", b.Verdict)
+ }
+ })
+
+ t.Run("silent: below the asker floor", func(t *testing.T) {
+ s := breakerStore(t)
+ for _, p := range []string{"ana@x", "ben@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(3), 5, 0)
+ countOn(t, s, "Sheets", p, breakerClock, 0, 4)
+ }
+ b := verdict(t, s)
+ if b.Verdict != BreakerQuiet {
+ t.Errorf("verdict = %v, want quiet: two is below the floor", b.Verdict)
+ }
+ // And the margin is reported, so an owner can see it was two
+ // rather than none.
+ if b.Askers != 2 {
+ t.Errorf("askers = %d, want 2", b.Askers)
+ }
+ })
+
+ t.Run("silent: a baseline asker who did not ask", func(t *testing.T) {
+ // Absence is not refusal. Three people had access last week and
+ // simply did not query today; without the "asked in the window"
+ // clause, a quiet day reads as a wholesale refusal.
+ s := breakerStore(t)
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(3), 5, 0)
+ }
+ // Somebody else is refused, so a NOT_VISIBLE exists in the window
+ // and only the per-asker clause can keep this quiet.
+ countOn(t, s, "Sheets", "zoe@x", breakerClock, 0, 1)
+ if b := verdict(t, s); b.Verdict != BreakerQuiet {
+ t.Errorf("verdict = %v, want quiet: three people not querying is "+
+ "not three people being refused", b.Verdict)
+ }
+ })
+
+ t.Run("cannot determine: a fresh instance names what it lacks", func(t *testing.T) {
+ // §8.1: this is the one place where mistaking "I cannot tell" for
+ // "no anomaly" would silently disable a guard rather than
+ // mis-render a page.
+ s := storeForTest(t)
+ if err := s.StartCounting(context.Background(), "Sheets", daysBefore(2)); err != nil {
+ t.Fatalf("StartCounting: %v", err)
+ }
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(1), 5, 0)
+ countOn(t, s, "Sheets", p, breakerClock, 0, 4)
+ }
+ b := verdict(t, s)
+ if b.Verdict != BreakerCannotDetermine {
+ t.Fatalf("verdict = %v, want cannot-determine: two days of history "+
+ "cannot answer a question about seven", b.Verdict)
+ }
+ if !strings.Contains(strings.Join(b.Lacking, " "), "baseline") {
+ t.Errorf("lacking = %v, want it to name the baseline", b.Lacking)
+ }
+ })
+
+ t.Run("cannot determine: no counters at all", func(t *testing.T) {
+ s := storeForTest(t)
+ b := verdict(t, s)
+ if b.Verdict != BreakerCannotDetermine {
+ t.Errorf("verdict = %v: a sibling nothing has ever counted must "+
+ "not report 'no anomaly'", b.Verdict)
+ }
+ if len(b.Lacking) == 0 {
+ t.Error("cannot-determine named nothing; §10 gate 8 requires the " +
+ "page to say WHICH of window and baseline it lacks")
+ }
+ })
+}
+
+// The counters survive a restart, which is the assertion that they are
+// durable rather than in memory.
+//
+// memoria hibernates, so an in-memory window resets on every wake and a
+// breaker that can never accumulate seven days of baseline is a breaker
+// that is off — reading, on the owner's page, as "no anomaly". That is the
+// failure the whole storage choice exists to prevent.
+//
+// MUTATION-TESTED 2026-09-07: hold the tallies in a map on the Store and
+// this goes red on the second open. Reverted.
+func TestBreakerCountersSurviveARestart(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+
+ first := storeAt(t, dir)
+ if err := first.StartCounting(ctx, "Sheets", daysBefore(30)); err != nil {
+ t.Fatalf("StartCounting: %v", err)
+ }
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, first, "Sheets", p, daysBefore(3), 5, 0)
+ countOn(t, first, "Sheets", p, breakerClock, 0, 4)
+ }
+ if b := verdict(t, first); b.Verdict != BreakerFiring {
+ t.Fatalf("the fixture did not fire before the restart: %v", b.Verdict)
+ }
+
+ // Stop and start: a new Store over the same database file, with the
+ // same DEK, exactly as a wake does.
+ second := storeAt(t, dir)
+ b, err := second.RefusalBreaker(ctx, "Sheets", breakerClock)
+ if err != nil {
+ t.Fatalf("RefusalBreaker after restart: %v", err)
+ }
+ if b.Verdict != BreakerFiring {
+ t.Errorf("verdict after restart = %v, want firing: the baseline did "+
+ "not survive, so this instance can never accumulate one and the "+
+ "breaker is permanently off while reading as 'no anomaly'", b.Verdict)
+ }
+}
+
+// The counters are retained 8 days and no longer.
+//
+// §8.1's "no longer" is the load-bearing half: this is the only per-person
+// record memoria keeps, and a retention rule nothing enforces is a
+// sentence in a document.
+//
+// TWO-SIDED: the rows inside the window must survive, or "delete
+// everything" passes.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// prune nothing -> the old-rows assertion red
+// prune everything -> the kept-rows assertion red
+// off-by-one on the cutoff -> the boundary assertion red
+func TestTalliesAreRetainedEightDaysAndNoLonger(t *testing.T) {
+ ctx := context.Background()
+ s := breakerStore(t)
+ for _, d := range []int{0, 1, 7, 8, 9, 30} {
+ countOn(t, s, "Sheets", "ana@x", daysBefore(d), 1, 0)
+ }
+ if got := tallyDays(t, s); len(got) != 6 {
+ t.Fatalf("%d tally rows seeded, want 6", len(got))
+ }
+
+ n, err := s.PruneTallies(ctx, breakerClock)
+ if err != nil {
+ t.Fatalf("PruneTallies: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("pruned %d rows, want 2 (day 9 and day 30)", n)
+ }
+ got := tallyDays(t, s)
+ if len(got) != 4 {
+ t.Fatalf("%d rows left, want 4 (days 0, 1, 7 and 8)", len(got))
+ }
+ // The boundary itself stays: 8 days is retained, 9 is not.
+ if !got[daysBefore(8).Format("2006-01-02")] {
+ t.Error("the day-8 row was pruned; the rule is 'retained 8 days', " +
+ "and losing the oldest baseline day shortens every window")
+ }
+ if got[daysBefore(9).Format("2006-01-02")] {
+ t.Error("the day-9 row survived; this is the only per-person record " +
+ "memoria keeps and the retention rule is what bounds it")
+ }
+}
+
+func tallyDays(t *testing.T, s *Store) map[string]bool {
+ t.Helper()
+ var rows []SiblingTally
+ if err := s.db.G.Find(&rows).Error; err != nil {
+ t.Fatalf("read tallies: %v", err)
+ }
+ out := map[string]bool{}
+ for _, r := range rows {
+ out[r.Day] = true
+ }
+ return out
+}
+
+// The counters are sealed, and the asker is one-way.
+//
+// This is the only per-person record memoria keeps — "Ana was refused 40
+// times by Sheets on Tuesday" is a fact about Ana that nothing else in the
+// store carries. §3.2's leak list names it; this is what holds it to what
+// the list says.
+//
+// PRESENCE FIRST: the row really does hold those numbers and that person,
+// so the absence checks are assertions rather than descriptions of an
+// empty table.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// store the counts as plain columns -> the sealed check red
+// store the asker address verbatim -> the fingerprint check red
+// use Seal rather than Fingerprint for the asker
+// -> green on this test, and it is NOT a finding here: it would
+// still be unreadable in the bucket. It is refused in the code
+// for a different reason, stated at seal.PurposeAsker — a
+// reversible id makes this table a roster of who reads what, and
+// nothing in the predicate needs the address back.
+func TestBreakerCountersAreSealedAndTheAskerIsOneWay(t *testing.T) {
+ s := breakerStore(t)
+ countOn(t, s, "Sheets", "ana@example.com", breakerClock, 3, 41)
+
+ var rows []SiblingTally
+ if err := s.db.G.Find(&rows).Error; err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("%d rows, want 1", len(rows))
+ }
+ // Presence: the numbers really are in there.
+ got, err := s.openTally(rows[0].SealedCounts)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ if got.Visible != 3 || got.NotVisible != 41 {
+ t.Fatalf("tally = %+v, want {3 41}", got)
+ }
+
+ blob := string(rows[0].SealedCounts)
+ if strings.Contains(blob, "41") || strings.Contains(blob, `"n"`) {
+ t.Error("the counts are legible at rest: how much one person was " +
+ "refused is a fact about that person")
+ }
+ if strings.Contains(rows[0].AskerPrint, "ana@example.com") {
+ t.Error("the asker's address is in the row; this table would be a " +
+ "roster of who reads what")
+ }
+ // And the fingerprint is stable, or the same person counts as two and
+ // the per-asker transition can never see anybody twice.
+ countOn(t, s, "Sheets", "ana@example.com", breakerClock, 1, 0)
+ if err := s.db.G.Find(&rows).Error; err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Errorf("%d rows after a second call from the same person, want 1: "+
+ "an unstable fingerprint makes one person look like many, and "+
+ "the per-asker transition then never sees anyone twice", len(rows))
+ }
+}
+
+// The baseline and the window do not overlap, asserted on the boundary
+// itself rather than through the predicate.
+//
+// §10 gate 8's third mutation is "let the baseline overlap the window",
+// and it expects the fires case to go red because every firing asker is
+// then their own baseline. **That mutation survives the predicate here,
+// and the reason is worth stating rather than hiding**: with day-granular
+// counters an asker cannot both have a VISIBLE and count as refused on the
+// same day — the row is one aggregate, so `visible > 0` excludes them
+// whichever period the day belongs to. The spec's mutation is written
+// against a rolling window, where intra-day ordering exists and today's
+// own grant can serve as its own baseline.
+//
+// That is the same day-granularity tension the window comment describes,
+// surfacing as a mutation the predicate cannot see. So the property is
+// asserted where it IS visible: the two ranges, directly. Overlapping them
+// goes red here even though it does not there.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// baseTo becomes today -> red
+// baseFrom becomes today - 6 -> red (the baseline is 7 days)
+// the window becomes yesterday -> red
+func TestTheBaselineAndTheWindowDoNotOverlap(t *testing.T) {
+ window := breakerWindow(breakerClock)
+ from, to := breakerBaseline(breakerClock)
+
+ if to >= window {
+ t.Errorf("baseline ends %s and the window is %s: they overlap, so an "+
+ "asker can be their own baseline and the predicate is vacuous",
+ to, window)
+ }
+ if want := breakerClock.AddDate(0, 0, -1).Format("2006-01-02"); to != want {
+ t.Errorf("baseline ends %s, want %s — the day before the window, with "+
+ "no gap: a gap would silently shorten the baseline", to, want)
+ }
+ if want := breakerClock.AddDate(0, 0, -breakerBaselineDays).Format("2006-01-02"); from != want {
+ t.Errorf("baseline starts %s, want %s (%d days)", from, want, breakerBaselineDays)
+ }
+ if want := breakerClock.Format("2006-01-02"); window != want {
+ t.Errorf("window = %s, want %s", window, want)
+ }
+}
+
+// An unreadable counter makes the breaker say so, not go quiet.
+//
+// A seal failure, a partial restore, a truncated row: whatever the cause,
+// the period cannot be read, and a period that cannot be read is not a
+// period with nothing in it. Skipping the row instead would make the
+// window look quieter than it was, and quieter is the direction that turns
+// a firing breaker into a silent one — §8.1's "a count that fails is not a
+// count of zero", on the one guard where that mistake disables rather than
+// mis-renders.
+//
+// PRESENCE FIRST: the fixture fires before the row is corrupted, so this
+// is a statement about the corruption and not about an empty table.
+//
+// MUTATION-TESTED 2026-09-07, each verified red then reverted:
+//
+// tallyRange skips a row it cannot open -> red, the breaker goes quiet
+// RefusalBreaker reports Quiet on a range error -> red
+func TestAnUnreadableCounterIsNotAQuietOne(t *testing.T) {
+ s := breakerStore(t)
+ for _, p := range []string{"ana@x", "ben@x", "cleo@x"} {
+ countOn(t, s, "Sheets", p, daysBefore(3), 5, 0)
+ countOn(t, s, "Sheets", p, breakerClock, 0, 4)
+ }
+ if b := verdict(t, s); b.Verdict != BreakerFiring {
+ t.Fatalf("the fixture did not fire before corruption: %v", b.Verdict)
+ }
+
+ // Spoil one baseline row.
+ var row SiblingTally
+ if err := s.db.G.Where("day = ?", daysBefore(3).Format("2006-01-02")).First(&row).Error; err != nil {
+ t.Fatalf("find a row: %v", err)
+ }
+ if err := s.db.G.Model(&SiblingTally{}).Where("id = ?", row.ID).
+ Update("sealed_counts", []byte("not a sealed value")).Error; err != nil {
+ t.Fatalf("corrupt: %v", err)
+ }
+
+ b := verdict(t, s)
+ if b.Verdict == BreakerQuiet {
+ t.Error("a period the breaker cannot read was reported as 'no " +
+ "anomaly'; that is the one mistake §8.1 says disables the guard " +
+ "rather than mis-rendering the page")
+ }
+ if b.Verdict != BreakerCannotDetermine {
+ t.Errorf("verdict = %v, want cannot-determine", b.Verdict)
+ }
+ if len(b.Lacking) == 0 {
+ t.Error("cannot-determine named nothing")
+ }
+}
diff --git a/internal/memoria/custody.go b/internal/memoria/custody.go
index 1dc1c2a..91fdf99 100644
--- a/internal/memoria/custody.go
+++ b/internal/memoria/custody.go
@@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"sync"
+ "time"
"amadan.net/oficina/suite"
@@ -165,6 +166,17 @@ func WireSiblings(a *app, getenv func(string) string, dial suite.DialFunc) error
return err
}
a.UseSiblings(custody)
+ a.store.UseLogger(a.logger)
+ // §8.1's counters begin when the sibling is configured, not on its
+ // first answer: a sibling adopted today whose first query is next week
+ // has been WATCHED since today, and the baseline it lacks is a fact
+ // about this instance rather than about the traffic.
+ now := time.Now().UTC()
+ for _, app := range cfg.Apps {
+ if err := a.store.StartCounting(context.Background(), app.Name, now); err != nil {
+ return err
+ }
+ }
// Names, never the roster itself: the structs carry consumer tokens,
// and `carlos logs` is storage we cannot grep.
a.logger.Info("siblings configured", "team", cfg.Team, "apps", cfg.Names())
diff --git a/internal/memoria/custody_test.go b/internal/memoria/custody_test.go
index 6cdcd7a..9b35870 100644
--- a/internal/memoria/custody_test.go
+++ b/internal/memoria/custody_test.go
@@ -541,3 +541,37 @@ func TestTwoSiblingsWithholdingOnOnePage(t *testing.T) {
page.Notices, want)
}
}
+
+// storeAt is storeForTest over a directory the caller keeps, so a test can
+// close one Store and open another over the same database — which is what
+// a wake is, and the only way to assert that anything is durable rather
+// than in memory.
+func storeAt(t *testing.T, dir string) *Store {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(dir, "store.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+ // The SAME key both times. A fresh DEK would make every sealed row
+ // unreadable, and the test would then be asserting that a restart
+ // loses the counters rather than keeps them.
+ dek := make([]byte, 32)
+ for i := range dek {
+ dek[i] = byte(i*11 + 3)
+ }
+ sealer, err := seal.New(dek)
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ ix, err := index.Open()
+ if err != nil {
+ t.Fatalf("index.Open: %v", err)
+ }
+ t.Cleanup(func() { ix.Close() })
+ return NewStore(d, sealer, ix, nil)
+}
diff --git a/internal/memoria/migrations/0007_alter_sibling_feeds.sql b/internal/memoria/migrations/0007_alter_sibling_feeds.sql
new file mode 100644
index 0000000..994457a
--- /dev/null
+++ b/internal/memoria/migrations/0007_alter_sibling_feeds.sql
@@ -0,0 +1,8 @@
+ALTER TABLE `sibling_feeds` ADD `counting_since` datetime;
+
+CREATE TABLE `sibling_tallies` (`id` integer PRIMARY KEY AUTOINCREMENT,`app` text NOT NULL,`asker_print` text NOT NULL,`day` text NOT NULL,`sealed_counts` blob NOT NULL,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_sibling_tallies_day` ON `sibling_tallies`(`day`);
+
+CREATE UNIQUE INDEX `idx_tally` ON `sibling_tallies`(`app`,`asker_print`,`day`);
+
diff --git a/internal/memoria/migrations/schema.sql b/internal/memoria/migrations/schema.sql
index 0f3a6ac..4b3d860 100644
--- a/internal/memoria/migrations/schema.sql
+++ b/internal/memoria/migrations/schema.sql
@@ -7,7 +7,9 @@ CREATE TABLE `connectors` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text NO
CREATE TABLE `documents` (`id` integer PRIMARY KEY AUTOINCREMENT,`source` text NOT NULL,`external_id` text NOT NULL,`revision` integer NOT NULL DEFAULT 1,`sealed_title` blob NOT NULL,`sealed_body` blob NOT NULL,`bytes` integer,`created_at` datetime,`updated_at` datetime, `body_print` text NOT NULL DEFAULT "", `sealed_path` blob, `sibling_item_id` blob, `sibling_revision` integer NOT NULL DEFAULT 0);
-CREATE TABLE `sibling_feeds` (`id` integer PRIMARY KEY AUTOINCREMENT,`app` text NOT NULL,`sealed_cursor` blob,`polled_at` datetime,`reconciling` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `sibling_feeds` (`id` integer PRIMARY KEY AUTOINCREMENT,`app` text NOT NULL,`sealed_cursor` blob,`polled_at` datetime,`reconciling` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime, `counting_since` datetime);
+
+CREATE TABLE `sibling_tallies` (`id` integer PRIMARY KEY AUTOINCREMENT,`app` text NOT NULL,`asker_print` text NOT NULL,`day` text NOT NULL,`sealed_counts` blob NOT NULL,`created_at` datetime,`updated_at` datetime);
CREATE TABLE `source_pages` (`id` integer PRIMARY KEY AUTOINCREMENT,`connector_id` integer NOT NULL,`page_print` text NOT NULL,`sealed_page_id` blob NOT NULL,`sealed_path` blob,`path_print` text,`seen_edited` text,`ingested_edited` text,`ingested_path` text,`state` text NOT NULL DEFAULT "pending",`error_code` text,`last_seen` datetime,`document_id` integer,`created_at` datetime,`updated_at` datetime);
@@ -23,6 +25,8 @@ CREATE INDEX `idx_documents_source` ON `documents`(`source`);
CREATE UNIQUE INDEX `idx_sibling_feeds_app` ON `sibling_feeds`(`app`);
+CREATE INDEX `idx_sibling_tallies_day` ON `sibling_tallies`(`day`);
+
CREATE INDEX `idx_source_pages_connector_id` ON `source_pages`(`connector_id`);
CREATE INDEX `idx_source_pages_page_print` ON `source_pages`(`page_print`);
@@ -33,3 +37,5 @@ CREATE INDEX `idx_sync_runs_connector_id` ON `sync_runs`(`connector_id`);
CREATE INDEX `idx_sync_runs_state` ON `sync_runs`(`state`);
+CREATE UNIQUE INDEX `idx_tally` ON `sibling_tallies`(`app`,`asker_print`,`day`);
+
diff --git a/internal/memoria/models.go b/internal/memoria/models.go
index 013239d..83c2ae7 100644
--- a/internal/memoria/models.go
+++ b/internal/memoria/models.go
@@ -13,6 +13,7 @@ var Models = []any{
&SyncRun{},
&SourcePage{},
&SiblingFeed{},
+ &SiblingTally{},
}
// Document is one thing memoria holds.
@@ -156,6 +157,18 @@ type SiblingFeed struct {
// not move it. §8.1: "a failed or stale S1 → the page states the
// corpus is current as of a time, rather than implying freshness".
PolledAt time.Time
+ // CountingSince is when this instance began keeping breaker counters
+ // for this sibling.
+ //
+ // **It is what makes "cannot determine" distinguishable from "no
+ // anomaly".** Without it, a fresh instance and a sibling that has
+ // genuinely refused nobody look identical: both have no refusals in
+ // the window and nobody in the baseline. §8.1 is explicit that this
+ // is the one place where mistaking the two would silently disable a
+ // guard rather than mis-render a page, so the instance records when
+ // it started looking and the page says so when the answer is younger
+ // than the question.
+ CountingSince time.Time
// Reconciling marks a source whose sibling has been restored behind
// memoria (§4.1's post-restore rule 1): its chunks leave the ranking
// entirely until a re-announce completes, so the other sources fill
@@ -169,3 +182,46 @@ type SiblingFeed struct {
CreatedAt time.Time
UpdatedAt time.Time
}
+
+// SiblingTally is §8.1's breaker counter: for one sibling, one asker and
+// one UTC day, how many passages that person was allowed to see and how
+// many they were refused.
+//
+// **The only per-person record memoria keeps.** §3.2's leak list carries
+// it. Two integers a day is the smallest thing that answers the question
+// the breaker asks, and the section is explicit that a per-refusal log
+// would answer it too and is not built.
+//
+// Why durable rather than in memory: memoria hibernates (§3.5), so an
+// in-memory window resets on every wake, and a breaker that can never
+// accumulate seven days of baseline is a breaker that is *off* — reading,
+// on the owner's page, as "no anomaly". That is the failure this whole
+// section exists to prevent, arriving through the storage choice.
+type SiblingTally struct {
+ ID int64
+ // App, AskerPrint and Day are the key. Unique together, so "one row
+ // per person per sibling per day" is a property of the schema rather
+ // than of the code that writes it.
+ App string `gorm:"uniqueIndex:idx_tally;not null"`
+ // AskerPrint is a KEYED, one-way fingerprint of the asker
+ // (seal.PurposeAsker). The predicate counts distinct people and
+ // compares a person against themselves across two periods; it never
+ // needs the address back, and a reversible id here would make this
+ // table a roster of who reads what.
+ AskerPrint string `gorm:"uniqueIndex:idx_tally;not null"`
+ // Day is the UTC date, "2006-01-02". Plaintext: it is the retention
+ // key and the window key, and sealing it would make the table
+ // unqueryable while hiding nothing — the row's existence already says
+ // somebody asked that day.
+ Day string `gorm:"uniqueIndex:idx_tally;index;not null"`
+ // SealedCounts holds the two integers, sealed together under
+ // seal.PurposeTally.
+ //
+ // One blob rather than two columns because they are read and written
+ // as a pair and never independently, and because a sealed integer
+ // cannot be incremented in SQL either way — the write is a
+ // read-modify-write in a transaction whichever shape this takes.
+ SealedCounts []byte `gorm:"not null"`
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
diff --git a/internal/memoria/seal/seal.go b/internal/memoria/seal/seal.go
index 6749bd2..fe58040 100644
--- a/internal/memoria/seal/seal.go
+++ b/internal/memoria/seal/seal.go
@@ -78,6 +78,27 @@ const (
// something whose contents are unknown is how a coordinate turns out
// to have been a timestamp, a row id, or a count.
PurposeCursor = "sibling.cursor"
+ // PurposeTally seals §8.1's breaker counters — per (sibling, asker,
+ // UTC day), how many passages that person was allowed to see and how
+ // many they were refused.
+ //
+ // **This is the only per-person record memoria keeps**, and §3.2's
+ // leak list carries it rather than leaving it to be found. Two
+ // integers a day is the smallest thing that answers "is this sibling
+ // refusing people it used to allow"; a per-refusal log would answer it
+ // too and is deliberately not built.
+ //
+ // Sealed because the numbers are the sensitive part: "Ana was refused
+ // 40 times by Sheets on Tuesday" is a fact about Ana, inferable from
+ // nothing else in the store, and the bucket must not hold it in the
+ // clear.
+ PurposeTally = "sibling.tally"
+ // PurposeAsker keys the asker in those rows. **One-way**
+ // (Fingerprint), never reversible: the predicate needs only to count
+ // distinct people and compare a person against themselves across two
+ // periods, and it never needs the address back. A reversible id here
+ // would make the tally table a roster of who reads what.
+ PurposeAsker = "sibling.asker"
)
func sha256New() hash.Hash { return sha256.New() }
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 2624c6f..8107c69 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "log/slog"
"strings"
"github.com/carlosframework/rastrillo/db"
@@ -23,6 +24,11 @@ type Store struct {
db *db.DB
sealer *seal.Sealer
ix *index.Index
+ // logger is optional. A dropped breaker counter is not allowed to
+ // fail a search, but it is not allowed to be silent either: a
+ // breaker that quietly stops counting reads as "no anomaly" forever,
+ // which is the failure §8.1 spends a paragraph on.
+ logger *slog.Logger
// custody is who memoria asks about a sibling's passages.
//
// **nil is a configuration, not a bug**, and it is slice 0's: no
@@ -40,6 +46,18 @@ func NewStore(d *db.DB, sealer *seal.Sealer, ix *index.Index, custody Custody) *
return &Store{db: d, sealer: sealer, ix: ix, custody: custody}
}
+// UseLogger supplies the log the store reports dropped counters to.
+func (s *Store) UseLogger(l *slog.Logger) { s.logger = l }
+
+// logBreakerWriteFailed says a counter was lost, naming the app and
+// nothing else — no asker, no item, no query text (AGENTS.md).
+func (s *Store) logBreakerWriteFailed(app string) {
+ if s.logger == nil {
+ return
+ }
+ s.logger.Warn("breaker counter not written", "app", app)
+}
+
// Result is one search hit, with its citation resolved to plaintext.
type Result struct {
DocumentID int64
@@ -710,6 +728,14 @@ func (s *Store) rule(ctx context.Context, asker suite.Subject, all []Result) Pag
for j, d := range got {
disp[at[j]] = d
}
+ // §8.1's breaker counters, written on the path that already made
+ // the call. **Failing to write one must not fail the search**:
+ // the caller is answering somebody's query, and trading a working
+ // page for a metrics row is the wrong direction. What a gap costs
+ // is that the breaker reports *cannot determine* rather than *no
+ // anomaly*, which is the safe way round and the reason this may
+ // be dropped at all.
+ s.recordForBreaker(ctx, asker, cands, at, disp)
}
// **Notices are aggregated once, here, in result order.** The first