| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 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. |
| 29 | type 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. |
| 50 | type 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. |
| 66 | func 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 | |
| 80 | func (s *Siblings) Sibling(source string) (sibling.Sibling, bool) { |
| 81 | got, ok := s.byName[source] |
| 82 | return got, ok |
| 83 | } |
| 84 | |
| 85 | func (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. |
| 110 | func (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. |
| 153 | func 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. |
| 213 | func (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 | |