oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

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.
16package sibling
17
18import (
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.
34const 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.
41type 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.
54type 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).
67func 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.
84func 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."
104func (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.
159const 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.
163func 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