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 "encoding/json"
6 "errors"
7 "fmt"
8 "time"
9
10 "amadan.net/oficina/suite"
11
12 "amadan.net/oficina/memoria/internal/memoria/seal"
13 "amadan.net/oficina/memoria/internal/memoria/sibling"
14)
15
16// §8.1's numbers, written out so a mutation has something to change and a
17// reader has something to argue with.
18const (
19 // breakerAskerFloor: at least three baseline askers must each have
20 // asked in the window and received nothing.
21 //
22 // Three rather than one, because one person's permissions changing is
23 // an ordinary Tuesday and a notice that fires for it is a notice
24 // owners learn to skip.
25 breakerAskerFloor = 3
26
27 // breakerBaselineDays: the baseline is the 7 days ending where the
28 // window begins — **disjoint from it**, so the window can never be its
29 // own baseline. Draft 4 let them overlap and every firing asker was
30 // then their own baseline, which makes the predicate vacuous.
31 breakerBaselineDays = 7
32
33 // tallyRetentionDays: window + baseline + 1. §8.1 says "retained 8
34 // days and no longer", and the "no longer" is the load-bearing half —
35 // this is the only per-person record memoria keeps.
36 tallyRetentionDays = 8
37)
38
39// breakerWindow and breakerBaseline are the two periods, in UTC days.
40//
41// **The window is a UTC day, and §8.1 says "a rolling 24-hour window".**
42// Those are not the same thing, and the section cannot have both: it also
43// mandates counters keyed per `(sibling, asker, UTC day)`, and two
44// integers a day cannot answer a question about an interval that starts
45// partway through a day. There is nothing in the row to split on — that
46// is the whole economy of storing an aggregate instead of the per-refusal
47// log §8.1 explicitly declines to build.
48//
49// So the storage rule decides the window. What it costs is precision at
50// the boundary: a refusal spree beginning at 23:00 UTC is judged on one
51// hour of evidence, then re-judged on a full day. **That is a delay in
52// firing, never a false positive**, which is the right direction for a
53// notice whose entire purpose is not to cry wolf.
54//
55// Flagged for the spec rather than settled in a comment: §8.1 disagrees
56// with itself here, and the amendment belongs there.
57func breakerWindow(now time.Time) string { return now.UTC().Format("2006-01-02") }
58
59func breakerBaseline(now time.Time) (from, to string) {
60 day := now.UTC().Truncate(24 * time.Hour)
61 return day.AddDate(0, 0, -breakerBaselineDays).Format("2006-01-02"),
62 day.AddDate(0, 0, -1).Format("2006-01-02")
63}
64
65// tally is the pair of integers in a SiblingTally row. §8.1's two, and
66// two is enough.
67//
68// **A third — ASKED — was added here and then removed, and the round trip
69// is worth recording.** The argument for it was that §8.1's clause
70// "the window contains at least one NOT_VISIBLE" could not fail: an
71// all-UNKNOWN answer writes no row, so an outage's askers never enter the
72// window and flipping the clause changes nothing. That observation was
73// right and the remedy was wrong.
74//
75// The spec's own §13 already carried the real finding, and this amended
76// §8.1 without opening it — the failure AGENTS.md calls characteristic,
77// committed in the act of fixing another one. §13: *"the breaker's
78// NOT_VISIBLE clause is window-global, not per counted asker, so one
79// routine query from a Docs-only member plus a 24-hour outage still fires
80// the refusal breaker … Both are fixed by requiring each counted asker to
81// have VISIBLE = 0 and NOT_VISIBLE >= 1 in the window."*
82//
83// That fix is strictly better. It closes a live hole a third counter does
84// not touch; "asked in the window" becomes computable from the two
85// integers, because a NOT_VISIBLE is itself proof the person asked; and it
86// leaves the only per-person record memoria keeps as small as §3.2 says
87// it is, instead of quietly adding a per-person "how often did they
88// query" nobody had declared.
89type tally struct {
90 Visible int `json:"v"`
91 NotVisible int `json:"n"`
92}
93
94// RecordAuthorizations files one S2 answer set against the breaker's
95// counters.
96//
97// **Written on the path that already made the call**, per §8.1, and read
98// only when the owner's sources page is rendered — so a reader never waits
99// on them.
100//
101// **A counter that cannot be written is dropped, never propagated.** The
102// caller is in the middle of answering somebody's search; failing that
103// because a metrics row would not save would be trading a working page for
104// a broken one.
105//
106// What it costs is stated plainly rather than flattered: the period reads
107// slightly quieter than it was. Nothing records the gap, so this is a loss
108// of fidelity and not a "cannot determine" — an earlier version of this
109// comment claimed the latter, which would have been the safe direction and
110// was not true. It is tolerable because the breaker needs three separate
111// askers to fire and one dropped write moves one of them, and because the
112// loss is logged. If that stops being enough the fix is a per-(app, day)
113// gap marker that yields cannot-determine, not a stronger sentence here.
114//
115// UNKNOWN and STALE are counted as neither. An outage produces UNKNOWNs
116// and no NOT_VISIBLE, and §8.1 is explicit that it must not trip a breaker
117// whose whole subject is refusal — "an availability problem reported as a
118// permissions anomaly, on the day the operator most needs to read the
119// notice correctly".
120func (s *Store) RecordAuthorizations(ctx context.Context, app string, asker suite.Subject, answers []suite.Authorization) error {
121 return s.recordAuthorizationsAt(ctx, app, asker, answers, time.Now())
122}
123
124// recordAuthorizationsAt is RecordAuthorizations with the clock as a
125// parameter, so a test can put a row on a day last week without waiting
126// one. Unexported: production has exactly one clock and no reason to pass
127// another, and a settable clock on the exported surface is a way to write
128// counters into the past.
129func (s *Store) recordAuthorizationsAt(ctx context.Context, app string, asker suite.Subject, answers []suite.Authorization, now time.Time) error {
130 if app == "" || asker.Value == "" || len(answers) == 0 {
131 return nil
132 }
133 var add tally
134 for _, a := range answers {
135 switch a {
136 case suite.VISIBLE:
137 add.Visible++
138 case suite.NOT_VISIBLE:
139 add.NotVisible++
140 }
141 // STALE and UNKNOWN are counted nowhere. They withhold loudly,
142 // which is neither permission nor refusal, and §8.1 requires this
143 // breaker to see an outage as neither.
144 }
145 if add.Visible == 0 && add.NotVisible == 0 {
146 // Nothing this breaker counts, so no row. An all-UNKNOWN answer
147 // set is an outage, and the predicate below reads an absent row
148 // and a zero row identically — a person is counted as refused
149 // only by a NOT_VISIBLE, never by the absence of a VISIBLE.
150 return nil
151 }
152
153 print, err := s.sealer.Fingerprint(seal.PurposeAsker, []byte("asker\x00"+asker.Value))
154 if err != nil {
155 return err
156 }
157 day := now.UTC().Format("2006-01-02")
158
159 return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
160 var row SiblingTally
161 err := tx.Where("app = ? AND asker_print = ? AND day = ?", app, print, day).First(&row).Error
162 switch {
163 case errors.Is(err, errRecordNotFound):
164 sealed, err := s.sealTally(add)
165 if err != nil {
166 return err
167 }
168 return tx.Create(&SiblingTally{
169 App: app, AskerPrint: print, Day: day, SealedCounts: sealed,
170 }).Error
171 case err != nil:
172 return err
173 }
174 cur, err := s.openTally(row.SealedCounts)
175 if err != nil {
176 // **An unreadable row is not overwritten with a fresh one.**
177 // Replacing it would turn a seal failure into a confident
178 // small number, and the breaker would then read a period it
179 // cannot actually see as a quiet one.
180 return fmt.Errorf("tally for %s is unreadable: %w", app, err)
181 }
182 cur.Visible += add.Visible
183 cur.NotVisible += add.NotVisible
184 sealed, err := s.sealTally(cur)
185 if err != nil {
186 return err
187 }
188 return tx.Model(&SiblingTally{}).Where("id = ?", row.ID).
189 Update("sealed_counts", sealed).Error
190 })
191}
192
193func (s *Store) sealTally(t tally) ([]byte, error) {
194 raw, err := json.Marshal(t)
195 if err != nil {
196 return nil, err
197 }
198 return s.sealer.Seal(seal.PurposeTally, raw)
199}
200
201func (s *Store) openTally(sealed []byte) (tally, error) {
202 raw, err := s.sealer.Open(seal.PurposeTally, sealed)
203 if err != nil {
204 return tally{}, err
205 }
206 var t tally
207 if err := json.Unmarshal(raw, &t); err != nil {
208 return tally{}, err
209 }
210 return t, nil
211}
212
213// BreakerVerdict is what §8.1's refusal breaker says about one sibling.
214type BreakerVerdict uint8
215
216const (
217 // BreakerCannotDetermine is the zero value **on purpose**.
218 //
219 // §8.1: *"a count that fails is not a count of zero, and this is the
220 // one place in the design where that mistake would silently disable a
221 // guard rather than mis-render a page."* An unset verdict, a missing
222 // map entry, a branch somebody forgot — each produces this, and this
223 // is the one that admits it does not know.
224 BreakerCannotDetermine BreakerVerdict = iota
225 // BreakerQuiet: the counters were read, and this sibling is not
226 // refusing people it used to allow.
227 BreakerQuiet
228 // BreakerFiring: it is.
229 BreakerFiring
230)
231
232// Breaker is one sibling's state on the owner's sources page.
233type Breaker struct {
234 App string
235 Verdict BreakerVerdict
236 // Lacking names what is missing when the verdict is
237 // BreakerCannotDetermine — "the baseline", "the window", or both.
238 // §10 gate 8 requires the page to say *which*, and asserts the string.
239 Lacking []string
240 // Askers is how many baseline askers were refused throughout the
241 // window. Reported so the owner can see the margin rather than only
242 // the conclusion.
243 Askers int
244}
245
246// RefusalBreaker answers §8.1's question for one sibling: **is it refusing
247// people it used to allow?**
248//
249// The predicate, and each clause is here because dropping it breaks a
250// named case:
251//
252// - the **baseline set** is every asker who received at least one
253// VISIBLE from this sibling in the 7 days *before* the window. It is
254// disjoint from the window, so the window can never be its own
255// baseline;
256// - it **fires** when at least 3 of those askers each asked inside the
257// window and received zero VISIBLE, **and** the window contains at
258// least one NOT_VISIBLE.
259//
260// Counting NOT_VISIBLE presence rather than VISIBLE absence is what
261// separates a sibling that is *refusing* from one that is *down*: an
262// outage is all UNKNOWN and no NOT_VISIBLE, so it does not trip this at
263// all — §6.5's partial-page notice reports it instead. The per-asker
264// transition is what makes the predicate a property of the **sibling**
265// rather than of whoever happened to query that day: a Docs-only member is
266// never in the baseline, so no number of them can trip it. And requiring
267// each counted asker to have *asked* in the window is what stops absence
268// being read as refusal.
269//
270// **It never purges anything and never reaches a reader's results page.**
271// A message telling one reader how much they were not allowed to see is a
272// disclosure with a number in it.
273func (s *Store) RefusalBreaker(ctx context.Context, app string, now time.Time) (Breaker, error) {
274 b := Breaker{App: app} // zero verdict: cannot determine
275
276 // How long this instance has been looking. A fresh instance and a
277 // sibling that has refused nobody are otherwise identical, and §8.1
278 // says confusing them is the one mistake that disables the guard
279 // instead of mis-rendering the page.
280 var feed SiblingFeed
281 err := s.db.G.WithContext(ctx).Where("app = ?", app).First(&feed).Error
282 switch {
283 case errors.Is(err, errRecordNotFound):
284 b.Lacking = []string{"the baseline", "the window"}
285 return b, nil
286 case err != nil:
287 return b, err
288 }
289
290 windowDay := breakerWindow(now)
291 baseFrom, baseTo := breakerBaseline(now)
292
293 if feed.CountingSince.IsZero() || feed.CountingSince.After(now) {
294 b.Lacking = []string{"the baseline", "the window"}
295 return b, nil
296 }
297 if feed.CountingSince.UTC().Format("2006-01-02") > baseFrom {
298 // Counting began inside the baseline period or later, so the
299 // baseline is short. Not "no anomaly": a shorter baseline holds
300 // fewer people, which makes firing *less* likely, so reading it as
301 // quiet is exactly the false negative §8.1 forbids.
302 b.Lacking = append(b.Lacking, "the baseline")
303 }
304 if feed.CountingSince.UTC().Format("2006-01-02") > windowDay {
305 b.Lacking = append(b.Lacking, "the window")
306 }
307 if len(b.Lacking) > 0 {
308 return b, nil
309 }
310
311 baseline, err := s.tallyRange(ctx, app, baseFrom, baseTo)
312 if err != nil {
313 // Unreadable counters. Cannot determine, naming both, because a
314 // seal failure says nothing about which period it spoiled.
315 return Breaker{App: app, Lacking: []string{"the baseline", "the window"}}, nil
316 }
317 window, err := s.tallyRange(ctx, app, windowDay, windowDay)
318 if err != nil {
319 return Breaker{App: app, Lacking: []string{"the baseline", "the window"}}, nil
320 }
321
322 // The baseline set: people this sibling *did* allow, before the window.
323 allowed := map[string]bool{}
324 for print, t := range baseline {
325 if t.Visible > 0 {
326 allowed[print] = true
327 }
328 }
329
330 // **Per counted asker: `VISIBLE == 0` AND `NOT_VISIBLE >= 1`.**
331 //
332 // §8.1 words the second half as a property of the window — "the window
333 // contains at least one NOT_VISIBLE" — and §13 records why that is
334 // wrong: window-global, it is satisfied by one routine refusal of a
335 // Docs-only member, so a 24-hour outage plus that one query fires the
336 // breaker. The exact case the prose says is excluded.
337 //
338 // Per asker, all three of §8.1's clauses fall out of one condition.
339 // An outage gives its askers no NOT_VISIBLE, so none is counted.
340 // Somebody who did not query has no row, and no row is no
341 // NOT_VISIBLE — "absence is not refusal" needs no separate counter,
342 // because a refusal is the only thing that counts as having asked.
343 refused := 0
344 for print, t := range window {
345 if !allowed[print] {
346 // Not in the baseline. A Docs-only member refused by Sheets
347 // all day is the routine case, however many times it happens.
348 continue
349 }
350 if t.Visible == 0 && t.NotVisible >= 1 {
351 refused++
352 }
353 }
354
355 b.Askers = refused
356 if refused >= breakerAskerFloor {
357 b.Verdict = BreakerFiring
358 return b, nil
359 }
360 b.Verdict = BreakerQuiet
361 return b, nil
362}
363
364// tallyRange reads every counter for one sibling between two UTC days,
365// inclusive, keyed by asker fingerprint.
366//
367// **An unreadable row fails the whole range.** Skipping it would make the
368// period look quieter than it was, and quieter is the direction that turns
369// a firing breaker into a silent one.
370func (s *Store) tallyRange(ctx context.Context, app, from, to string) (map[string]tally, error) {
371 var rows []SiblingTally
372 if err := s.db.G.WithContext(ctx).
373 Where("app = ? AND day >= ? AND day <= ?", app, from, to).
374 Find(&rows).Error; err != nil {
375 return nil, err
376 }
377 out := make(map[string]tally, len(rows))
378 for _, r := range rows {
379 t, err := s.openTally(r.SealedCounts)
380 if err != nil {
381 return nil, fmt.Errorf("tally %d: %w", r.ID, err)
382 }
383 cur := out[r.AskerPrint]
384 cur.Visible += t.Visible
385 cur.NotVisible += t.NotVisible
386 out[r.AskerPrint] = cur
387 }
388 return out, nil
389}
390
391// StartCounting records that this instance began keeping counters for a
392// sibling, if it has not already.
393//
394// Called when the roster is wired, not on the first answer: a sibling
395// configured today whose first query is next week has been *watched* since
396// today, and the baseline it lacks is a fact about the instance rather
397// than about the traffic.
398func (s *Store) StartCounting(ctx context.Context, app string, now time.Time) error {
399 return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
400 var row SiblingFeed
401 err := tx.Where("app = ?", app).First(&row).Error
402 switch {
403 case errors.Is(err, errRecordNotFound):
404 return tx.Create(&SiblingFeed{App: app, CountingSince: now.UTC()}).Error
405 case err != nil:
406 return err
407 }
408 if !row.CountingSince.IsZero() {
409 return nil
410 }
411 return tx.Model(&SiblingFeed{}).Where("id = ?", row.ID).
412 Update("counting_since", now.UTC()).Error
413 })
414}
415
416// PruneTallies drops counters older than the retention window.
417//
418// §8.1: "retained 8 days and no longer". The "no longer" is the
419// load-bearing half — this is the only per-person record memoria keeps,
420// and a retention rule nothing enforces is a retention rule that is a
421// sentence in a document.
422func (s *Store) PruneTallies(ctx context.Context, now time.Time) (int, error) {
423 cutoff := now.UTC().AddDate(0, 0, -tallyRetentionDays).Format("2006-01-02")
424 res := s.db.G.WithContext(ctx).Where("day < ?", cutoff).Delete(&SiblingTally{})
425 return int(res.RowsAffected), res.Error
426}
427
428// Breakers reports every configured sibling's state, for the owner's
429// sources page.
430func (s *Store) Breakers(ctx context.Context, apps []sibling.Sibling, now time.Time) ([]Breaker, error) {
431 out := make([]Breaker, 0, len(apps))
432 for _, a := range apps {
433 b, err := s.RefusalBreaker(ctx, a.Name, now)
434 if err != nil {
435 return nil, err
436 }
437 out = append(out, b)
438 }
439 return out, nil
440}
441
442// recordForBreaker files a page's answers against §8.1's counters, per
443// sibling.
444//
445// **It is deliberately given dispositions rather than the raw S2 values**,
446// because the raw values do not reach this layer — `Custody.Rule` returns
447// what memoria decided, not what the sibling said. The mapping back is
448// exact for the two the breaker counts: only VISIBLE renders, and only
449// NOT_VISIBLE is silently omitted. STALE and UNKNOWN both withhold loudly
450// and are both counted as neither, which is what §8.1 requires of them.
451//
452// Errors are logged, never returned. See the call site.
453func (s *Store) recordForBreaker(ctx context.Context, asker suite.Subject, cands []sibling.Candidate, at []int, disp []sibling.Disposition) {
454 byApp := map[string][]suite.Authorization{}
455 for j, c := range cands {
456 var a suite.Authorization
457 switch disp[at[j]] {
458 case sibling.Render:
459 a = suite.VISIBLE
460 case sibling.OmitSilently:
461 a = suite.NOT_VISIBLE
462 default:
463 // Withheld loudly: STALE, UNKNOWN, or a value this client did
464 // not recognise. §8.1 counts none of them — an outage is all
465 // UNKNOWN, and counting it would report an availability
466 // problem as a permissions anomaly on the day the operator
467 // most needs to read the notice correctly.
468 continue
469 }
470 byApp[c.Sibling.Name] = append(byApp[c.Sibling.Name], a)
471 }
472 for app, answers := range byApp {
473 if err := s.RecordAuthorizations(ctx, app, asker, answers); err != nil {
474 // The app name and nothing else. No asker, no item, no query.
475 s.logBreakerWriteFailed(app)
476 }
477 }
478}
479
480// SeedTallyForDrive writes a counter directly, for tools/drive only.
481//
482// **A seam, named so it cannot be reached by accident**, and held by the
483// same `cmd/` gate as AppForTest: it writes into the past, which is
484// exactly what a production caller must never do — the breaker's whole
485// claim is that the counters record what actually happened when it
486// happened.
487//
488// drive needs it because the alternative is seeding a week of real S2
489// traffic against three fake siblings to look at one page.
490func (s *Store) SeedTallyForDrive(ctx context.Context, app, who string, when time.Time, visible, notVisible int) {
491 answers := make([]suite.Authorization, 0, visible+notVisible)
492 for i := 0; i < visible; i++ {
493 answers = append(answers, suite.VISIBLE)
494 }
495 for i := 0; i < notVisible; i++ {
496 answers = append(answers, suite.NOT_VISIBLE)
497 }
498 if len(answers) == 0 {
499 return
500 }
501 _ = s.recordAuthorizationsAt(ctx, app,
502 suite.Subject{Kind: suite.SubjectAddress, Value: who}, answers, when)
503}
504