| 1 | // Package sibling is memoria's client half of the suite contract. |
| 2 | // |
| 3 | // It is a client and only a client. Decision 7: every sibling call is a |
| 4 | // pull, and memoria exposes no sibling-facing surface — so there is no |
| 5 | // server half here, and the module's conformance harness (which points at |
| 6 | // an app's server routes) does not apply to memoria. That is worth saying |
| 7 | // once, because the module's README describes mounting three routes and |
| 8 | // running the harness, and an app that reads it without decision 7 in mind |
| 9 | // would build the surface §9.1 forbids. |
| 10 | // |
| 11 | // The wire shapes, the four authorization values and the total decoder are |
| 12 | // the module's: `amadan.net/oficina/suite`, suite ruling 7 — every app |
| 13 | // imports the contract, and no app restates it. What lives here is the |
| 14 | // part that is memoria's: which sibling was asked, what a call that never |
| 15 | // answered means, and the deadline it is asked under. |
| 16 | package sibling |
| 17 | |
| 18 | import ( |
| 19 | "bytes" |
| 20 | "context" |
| 21 | "encoding/json" |
| 22 | "io" |
| 23 | "net/http" |
| 24 | "time" |
| 25 | |
| 26 | "amadan.net/oficina/suite" |
| 27 | ) |
| 28 | |
| 29 | // Deadline is §6.5's per-sibling budget. |
| 30 | // |
| 31 | // 800 ms, and the spec is honest that it is a starting assumption rather |
| 32 | // than a measurement. Siblings are called concurrently and a page is one |
| 33 | // round, so the floor is the slowest sibling and not their sum. |
| 34 | const Deadline = 800 * time.Millisecond |
| 35 | |
| 36 | // A Sibling is one same-team app memoria holds content from. |
| 37 | // |
| 38 | // Name is printed to a reader when this sibling's passages are withheld — |
| 39 | // §6.5's "Sheets did not answer; 7 results withheld" — so it is the app's |
| 40 | // name as a person would say it, not a hostname. |
| 41 | type Sibling struct { |
| 42 | Name string |
| 43 | BaseURL string |
| 44 | Token string |
| 45 | } |
| 46 | |
| 47 | // Client asks siblings about content memoria already holds. |
| 48 | // |
| 49 | // The HTTP client is the module's team-bound one: it refuses a host outside |
| 50 | // memoria's own team label and refuses to follow redirects, both checked |
| 51 | // before the transport dials so a refused host costs no connection. memoria |
| 52 | // does not build its own — that check guards more than this package, and a |
| 53 | // second implementation of it is the drift ruling 7 exists to prevent. |
| 54 | type Client struct { |
| 55 | http *http.Client |
| 56 | } |
| 57 | |
| 58 | // New returns a Client bound to one team. |
| 59 | // |
| 60 | // dial is the hook the team-bound client dials through: **nil in |
| 61 | // production**, a recorder in a test. It is a parameter rather than a |
| 62 | // package variable because the gate that pays for this package's egress |
| 63 | // exemption has to exercise the real constructor — an exemption whose |
| 64 | // payment is a test that builds its own plain client is not a payment at |
| 65 | // all, which is what a review found here (§10 gate 15, and AGENTS.md's |
| 66 | // rule about naming a test that is not there). |
| 67 | func New(team string, dial suite.DialFunc) *Client { |
| 68 | return &Client{http: suite.NewSiblingClient(team, dial)} |
| 69 | } |
| 70 | |
| 71 | // NewForTest returns a Client with a PLAIN HTTP CLIENT and no team |
| 72 | // binding, so a test can point it at a loopback server. |
| 73 | // |
| 74 | // **Named so it cannot be reached by accident**, exactly as `AppForTest` |
| 75 | // is, and held by the same gate: the production-seam scan over `cmd/` |
| 76 | // lists it, so wiring it into main fails the build's tests rather than |
| 77 | // quietly removing §6.4's cross-team refusal from a running instance. |
| 78 | // |
| 79 | // It exists because the real constructor's transport refuses any host |
| 80 | // outside the team's own label — correctly, and before it dials — and a |
| 81 | // httptest server is on 127.0.0.1. That refusal is the module's to test |
| 82 | // and has its own fixture; what the tests using this seam are about is |
| 83 | // what memoria does with an answer, or the absence of one. |
| 84 | func NewForTest() *Client { |
| 85 | return &Client{http: &http.Client{}} |
| 86 | } |
| 87 | |
| 88 | // Authorize asks one sibling whether this person may read these items, at |
| 89 | // these revisions, right now. |
| 90 | // |
| 91 | // It returns exactly one Authorization per pair asked, in the order asked, |
| 92 | // always — there is no error return, and that is deliberate. §4.1: every |
| 93 | // non-answer is UNKNOWN, and a caller that could receive an error would |
| 94 | // have a fifth thing to handle and somewhere to forget to handle it. The |
| 95 | // four values already cover every outcome, and UNKNOWN withholds loudly. |
| 96 | // |
| 97 | // **The module decodes; this function supplies what the module cannot |
| 98 | // see.** DecodeS2 takes a status and a body, so it maps a bad status, an |
| 99 | // unparseable body, a duplicate row and a missing pair to UNKNOWN. A call |
| 100 | // that timed out, was refused by the team-bound transport, or never |
| 101 | // reached a server has no status and no body at all — so mapping *that* to |
| 102 | // UNKNOWN is the caller's obligation, and it is the one §4.1 names: |
| 103 | // "UNKNOWN is synthesised by the caller, not only returned by the sibling." |
| 104 | func (c *Client) Authorize(ctx context.Context, s Sibling, asker suite.Subject, pairs []suite.ItemRevision) []suite.Authorization { |
| 105 | if len(pairs) == 0 { |
| 106 | return nil |
| 107 | } |
| 108 | // Refuse an oversized batch rather than truncating it. A truncated |
| 109 | // question answers about fewer items than were asked, and the pairs |
| 110 | // that fell off the end would come back as "no answer" — which is |
| 111 | // UNKNOWN, which withholds, which looks like a sibling problem when it |
| 112 | // is ours. Cap and reason are the module's (MaxS2Pairs). |
| 113 | if err := suite.CheckS2Batch(pairs); err != nil { |
| 114 | return allUnknown(len(pairs)) |
| 115 | } |
| 116 | |
| 117 | body, err := json.Marshal(suite.S2Request{Asker: asker, Pairs: pairs}) |
| 118 | if err != nil { |
| 119 | return allUnknown(len(pairs)) |
| 120 | } |
| 121 | |
| 122 | ctx, cancel := context.WithTimeout(ctx, Deadline) |
| 123 | defer cancel() |
| 124 | |
| 125 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, |
| 126 | s.BaseURL+suite.RouteS2, bytes.NewReader(body)) |
| 127 | if err != nil { |
| 128 | return allUnknown(len(pairs)) |
| 129 | } |
| 130 | req.Header.Set("Content-Type", "application/json") |
| 131 | // The consumer token rides the transport, never the body (§6.2). |
| 132 | req.Header.Set("Authorization", "Bearer "+s.Token) |
| 133 | |
| 134 | res, err := c.http.Do(req) |
| 135 | if err != nil { |
| 136 | // Timeout, refused host, refused redirect, connection failure. No |
| 137 | // status, no body, nothing for the module to decode. |
| 138 | return allUnknown(len(pairs)) |
| 139 | } |
| 140 | defer res.Body.Close() |
| 141 | |
| 142 | // Cap the read. A sibling that answers with a gigabyte is a sibling |
| 143 | // that has gone wrong, and reading it all to discover that is a second |
| 144 | // failure. The cap is generous against MaxS2Pairs rows of a fixed |
| 145 | // shape; a truncated body does not parse, which is UNKNOWN. |
| 146 | raw, err := io.ReadAll(io.LimitReader(res.Body, maxS2Response)) |
| 147 | if err != nil { |
| 148 | return allUnknown(len(pairs)) |
| 149 | } |
| 150 | return suite.DecodeS2(pairs, res.StatusCode, raw) |
| 151 | } |
| 152 | |
| 153 | // maxS2Response caps an S2 answer. |
| 154 | // |
| 155 | // 256 pairs (the module's MaxS2Pairs) of {item_id, revision, result} with |
| 156 | // generous ids is well under 256 KiB. The number is a ceiling on damage, |
| 157 | // not a measurement, and it is stated rather than left implicit because |
| 158 | // AGENTS.md requires a cap to carry its reason. |
| 159 | const maxS2Response = 256 << 10 |
| 160 | |
| 161 | // allUnknown is the total-decoder rule at the call boundary: every pair |
| 162 | // asked gets exactly one answer, even when nothing answered. |
| 163 | func allUnknown(n int) []suite.Authorization { |
| 164 | out := make([]suite.Authorization, n) |
| 165 | for i := range out { |
| 166 | out[i] = suite.UNKNOWN |
| 167 | } |
| 168 | return out |
| 169 | } |
| 170 | |
| 171 | // Withheld describes what a reader is told when a sibling's passages |
| 172 | // cannot be shown. |
| 173 | // |
| 174 | // §6.5 and §8.1: withholding is loud and names the shape of the loss, and |
| 175 | // only NOT_VISIBLE is silent. A count of hidden results with no cause is |
| 176 | // the notice a reader learns to ignore. |
| 177 | // |
| 178 | // **The type that used to live here is gone.** `Withheld` rendered these |
| 179 | // notices too, in different words — "has newer versions of 7 results" |
| 180 | // against Outcome's "has moved on from 7 results" — so the page's copy |
| 181 | // depended on which of two renderers a caller happened to reach. A review |
| 182 | // found the pair. Outcome (policy.go) is the one renderer: it is the one |
| 183 | // the page uses, it groups per (app, kind) rather than being constructed |
| 184 | // per notice, and it is where the silence of NOT_VISIBLE is enforced by |
| 185 | // never recording it rather than by rendering it as "". |
| 186 | // |
| 187 | // Not built, and recorded here rather than discovered later: |
| 188 | // |
| 189 | // - §8.1's durable per-(sibling, asker, day) VISIBLE/NOT_VISIBLE counters. |
| 190 | // The spec says they are written "on the path that already made the |
| 191 | // call" — this is that path, and Authorize is where they belong. |
| 192 | |