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 "errors"
6 "fmt"
7 "log/slog"
8 "sort"
9 "sync"
10 "time"
11
12 "amadan.net/oficina/suite"
13
14 "amadan.net/oficina/memoria/internal/memoria/sibling"
15)
16
17// Custody is the app's route to the one rule:
18//
19// memoria never speaks a passage without asking whoever owns it whether
20// this person may see it, right now.
21//
22// An interface rather than the concrete client for one reason that is not
23// testing convenience: **a nil Custody must fail closed, and a type that
24// can be nil makes that a case somebody has to write down.** Slice 0 has
25// no siblings configured and every document is memoria-owned, so the S2
26// path would otherwise be dead code that comes alive on the day the first
27// sibling is configured — which is the worst possible day for it to run
28// for the first time.
29type Custody interface {
30 // Sibling resolves a Document.Source to a configured app of record.
31 //
32 // **A source that does not resolve is not a source that renders.** A
33 // sibling removed from configuration leaves its documents in the
34 // store, and there is then nobody to ask about them — which is
35 // UNKNOWN, which withholds loudly. It is emphatically not "no
36 // restriction applies".
37 Sibling(source string) (sibling.Sibling, bool)
38
39 // Rule answers for every candidate, in the order given. It does not
40 // fail: §4.1's four values already cover every outcome.
41 //
42 // Dispositions only. The notices are the caller's to aggregate,
43 // because they belong in the order the reader meets the results —
44 // see Store.rule.
45 Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition
46}
47
48// Siblings is the production Custody: a roster of configured apps of
49// record and the client that asks them.
50type Siblings struct {
51 client *sibling.Client
52 byName map[string]sibling.Sibling
53}
54
55// NewSiblings builds the roster. `team` binds the transport to one team's
56// hosts; §6.4 is what that enforces and it is the module's to enforce.
57//
58// **A duplicate name is refused**, because the name is the key: it is what
59// a Document.Source resolves through and what a withholding notice prints.
60// Two apps sharing one would silently send one's item ids to the other,
61// and a conforming sibling answers NOT_VISIBLE for ids it has no row for
62// (§4.1's no-row rule) — which is the value that may be silent. So the
63// misconfiguration would present as passages quietly missing from every
64// page, with no notice and nothing in the logs. Refused at construction,
65// where it is a startup error somebody reads.
66func NewSiblings(team string, dial suite.DialFunc, apps []sibling.Sibling) (*Siblings, error) {
67 byName := make(map[string]sibling.Sibling, len(apps))
68 for _, s := range apps {
69 if s.Name == "" {
70 return nil, errors.New("siblings: an app of record with no name cannot be resolved from a document's source")
71 }
72 if _, dup := byName[s.Name]; dup {
73 return nil, fmt.Errorf("siblings: two apps configured as %q; the name is the key a document's source resolves through", s.Name)
74 }
75 byName[s.Name] = s
76 }
77 return &Siblings{client: sibling.New(team, dial), byName: byName}, nil
78}
79
80func (s *Siblings) Sibling(source string) (sibling.Sibling, bool) {
81 got, ok := s.byName[source]
82 return got, ok
83}
84
85func (s *Siblings) Rule(ctx context.Context, asker suite.Subject, cands []sibling.Candidate) []sibling.Disposition {
86 return s.client.Rule(ctx, asker, cands)
87}
88
89// Warm polls every configured sibling once, concurrently, and returns
90// when they have all finished or failed.
91//
92// §6.5: **a cold sibling is a wake, and a wake is slower than a
93// deadline.** Without this, the first search after a quiet period
94// reliably withholds — the reader pays for the sibling's cold start, and
95// the notice they get is technically honest and completely useless. So
96// memoria spends that latency on its own wake, where nobody is waiting.
97//
98// **S1, not S2 or S7.** Draft 3 said the warm-up fired those, and neither
99// call can be made here: both take a person, and there is nobody on a
100// wake. S1 is the call that already has no asker, it is the one memoria
101// makes anyway, and warming a sibling is warming a sibling whichever
102// endpoint does it. §6.5 records this correction; gate 24 is why it could
103// not have been caught otherwise, since a warm-up naming a person is one
104// no fixture could ever have received.
105//
106// Errors are logged and not returned. One unreachable sibling must not
107// stop the others being warmed, and there is nobody to return an error to
108// on a wake — the reader learns about it from the withholding notice on
109// the page, which is where §6.5 puts it.
110func (s *Siblings) Warm(ctx context.Context, store *Store, logger *slog.Logger) {
111 var wg sync.WaitGroup
112 for _, app := range s.byName {
113 wg.Add(1)
114 go func(sib sibling.Sibling) {
115 defer wg.Done()
116 res, err := store.Poll(ctx, s.client, sib)
117 if err != nil {
118 // The app's name and nothing else. A poll error can carry
119 // a sibling's response body, and a body is content
120 // (AGENTS.md) — so the error is not logged, only that
121 // there was one.
122 logger.Warn("sibling poll failed", "app", sib.Name)
123 return
124 }
125 // Counts only: no item id, no title, no body.
126 logger.Info("sibling polled", "app", sib.Name,
127 "stored", res.Stored, "deleted", res.Deleted,
128 "missing", res.Missing, "more", res.More)
129 }(app)
130 }
131 wg.Wait()
132}
133
134// WireSiblings reads the roster and attaches it to the app, warm-up and
135// all. **One call, so main has one line and this has a test.**
136//
137// It exists because the gate over `cmd/` can only prove a call is
138// *spelled*, never that it is reached: a review wrapped `app.UseAuth(a)`
139// in a closure nobody invoked and the gate stayed green, and put the
140// sibling wiring behind `if len(sibs.Apps) > 1` so a one-sibling instance
141// ran with nil custody — also green. An AST walk over a file cannot see
142// control flow, and chasing that with a smarter walk is a race against
143// however the next person writes it.
144//
145// So the logic leaves main. What is left there is a call this function's
146// own tests cover, which is the rule main.go already states for
147// LoadConfig: *the refusals are a safety property and a safety property
148// needs a test.*
149//
150// **Every failure is fatal to boot, and an empty roster is not a
151// failure.** Slice 0 holds only memoria-owned content (§4.2), which has
152// no app of record to ask.
153func WireSiblings(a *app, getenv func(string) string, dial suite.DialFunc) error {
154 cfg, err := LoadSiblings(getenv)
155 if err != nil {
156 return err
157 }
158 if len(cfg.Apps) == 0 {
159 // Nothing configured. Custody stays nil, which withholds every
160 // sibling passage — and there are none, because nothing was
161 // ingested from a sibling either.
162 a.logger.Info("no siblings configured")
163 return nil
164 }
165 custody, err := NewSiblings(cfg.Team, dial, cfg.Apps)
166 if err != nil {
167 return err
168 }
169 a.UseSiblings(custody)
170 a.store.UseLogger(a.logger)
171 // §8.1's counters begin when the sibling is configured, not on its
172 // first answer: a sibling adopted today whose first query is next week
173 // has been WATCHED since today, and the baseline it lacks is a fact
174 // about this instance rather than about the traffic.
175 now := time.Now().UTC()
176 for _, app := range cfg.Apps {
177 if err := a.store.StartCounting(context.Background(), app.Name, now); err != nil {
178 return err
179 }
180 }
181 // **Retention, enforced on every wake.** §8.1 says the counters are
182 // kept 8 days "and no longer", and §3.2 says why the second half is
183 // the load-bearing one: an unbounded version is a year-long history
184 // of who was locked out of what — the only per-person record memoria
185 // keeps, growing without limit.
186 //
187 // `PruneTallies` existed, with a comment saying a retention rule
188 // nothing enforces is a sentence in a document, and nothing called
189 // it. A review found the sentence describing its own state.
190 //
191 // A wake is the right moment: memoria hibernates, so every instance
192 // passes through here regularly, and pruning costs nothing beside the
193 // index rebuild already happening. A failure is logged and not
194 // fatal — an over-long retention is a disclosure risk to fix, not a
195 // reason to refuse to serve.
196 if n, err := a.store.PruneTallies(context.Background(), now); err != nil {
197 a.logger.Warn("breaker counters not pruned")
198 } else if n > 0 {
199 a.logger.Info("breaker counters pruned", "rows", n)
200 }
201 // Names, never the roster itself: the structs carry consumer tokens,
202 // and `carlos logs` is storage we cannot grep.
203 a.logger.Info("siblings configured", "team", cfg.Team, "apps", cfg.Names())
204 // §6.5's wake poll, started and not awaited: the point is to spend a
205 // cold sibling's latency where nobody is waiting, and waiting for it
206 // would put that latency back on the first reader.
207 a.WarmSiblings(context.Background())
208 return nil
209}
210
211// ordered lists the roster by name, so the sources page does not reorder
212// itself between reloads.
213func (s *Siblings) ordered() []sibling.Sibling {
214 out := make([]sibling.Sibling, 0, len(s.byName))
215 for _, a := range s.byName {
216 out = append(out, a)
217 }
218 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
219 return out
220}
221