oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sibling
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10
11 "amadan.net/oficina/suite"
12)
13
14// maxFeedResponse and maxContentResponse cap what a sibling can make
15// memoria hold in memory on one call.
16//
17// A feed page is four small fields a row, so 4 MiB is thousands of rows and
18// far past any sane page. A body is the same 2 MiB an upload is capped at,
19// plus slack for transport framing — a sibling serving more than that has
20// gone wrong, and reading it all to discover that is a second failure.
21// Ceilings on damage rather than measurements, said out loud because
22// AGENTS.md requires a cap to carry its reason.
23const (
24 maxFeedResponse = 4 << 20
25 maxContentResponse = (2 << 20) + (64 << 10)
26)
27
28// Feed polls one sibling's change feed from a stored cursor.
29//
30// **A failure is an error, never an empty feed**, and that distinction is
31// the whole reason this returns one. §5.1 and gate 7: an enumeration diff
32// must tell an authoritative empty result from a failure *before* deleting
33// anything, because a timed-out, rate-limited or half-paginated poll
34// returns no information rather than no documents — and treating the second
35// as the first purges the corpus silently. The module refuses to decode a
36// non-2xx for that reason; this is the half above it, where a call that
37// never got a status at all lives.
38//
39// The cursor is opaque and the sibling's. A caller stores the one that
40// comes back **only on success**: advancing past a page nobody read is how
41// a consumer skips changes it will never be offered again.
42func (c *Client) Feed(ctx context.Context, s Sibling, cursor string) (suite.Feed, error) {
43 ctx, cancel := context.WithTimeout(ctx, Deadline)
44 defer cancel()
45
46 u := s.BaseURL + suite.RouteS1
47 if cursor != "" {
48 u += "?cursor=" + url.QueryEscape(cursor)
49 }
50 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
51 if err != nil {
52 return suite.Feed{}, fmt.Errorf("sibling %s: build S1 request: %w", s.Name, err)
53 }
54 req.Header.Set("Authorization", "Bearer "+s.Token)
55
56 res, err := c.http.Do(req)
57 if err != nil {
58 return suite.Feed{}, fmt.Errorf("sibling %s: S1 unreachable: %w", s.Name, bare(err))
59 }
60 defer res.Body.Close()
61
62 raw, err := io.ReadAll(io.LimitReader(res.Body, maxFeedResponse))
63 if err != nil {
64 return suite.Feed{}, fmt.Errorf("sibling %s: reading S1: %w", s.Name, err)
65 }
66 feed, err := suite.DecodeS1(res.StatusCode, raw)
67 if err != nil {
68 return suite.Feed{}, fmt.Errorf("sibling %s: %w", s.Name, err)
69 }
70 return feed, nil
71}
72
73// Content fetches one item's body. **`(id, revision)`, and no asker.**
74//
75// §6.1: the ingest call is custody-scoped and person-less, and the sibling
76// answers only for items that are team-visible. A per-person S3 was draft
77// 2's error and could not have been made — there is nobody there on an
78// ingest — and §1.3 records that the fix was deleting the false claim
79// rather than adding a person to the call.
80//
81// A missing item is **404, never 403**, and that rule binds the SIBLING:
82// its 404 for an item it will not serve is the same 404 as for an item it
83// does not have, so nobody learns which ids exist. DecodeS3 turns that
84// 404 into ErrNotAvailable and this returns it unchanged.
85//
86// A 403 is a different thing and stays distinguishable on purpose — the
87// module answers ErrContractBreach — because a sibling answering 403 is
88// leaking, and folding that into ErrNotAvailable would hide the leak from
89// the only person who could fix it.
90func (c *Client) Content(ctx context.Context, s Sibling, itemID string, revision int64) ([]byte, error) {
91 ctx, cancel := context.WithTimeout(ctx, Deadline)
92 defer cancel()
93
94 // **The item id is a PATH segment, not a query parameter.** The
95 // module says so where it declares the route — `RouteS3 = "/suite/s3"
96 // // GET, /suite/s3/{item_id}?revision=` — and its conformance
97 // harness, which every sibling is held to, fires exactly that shape.
98 //
99 // This was written as `?item_id=` and a review caught it. Against a
100 // conforming sibling every fetch would have 404'd, memoria would have
101 // read each as "not team-visible, or gone", and §8.1's S3 breaker
102 // (twenty fetches, all 404) would have reported a permissions anomaly
103 // for a routing bug — the plausible empty list on the one call that
104 // fills the corpus. The test pinned the wrong shape too, which is why
105 // it went green: checked against my own model of the route rather
106 // than against the route.
107 //
108 // PathEscape, not QueryEscape: a space is %20 in a path segment and
109 // "+" in a query, and a sibling reading "+" back as a space finds no
110 // such item.
111 u := fmt.Sprintf("%s%s/%s?revision=%d",
112 s.BaseURL, suite.RouteS3, url.PathEscape(itemID), revision)
113 req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
114 if err != nil {
115 return nil, fmt.Errorf("sibling %s: build S3 request: %w", s.Name, err)
116 }
117 req.Header.Set("Authorization", "Bearer "+s.Token)
118
119 res, err := c.http.Do(req)
120 if err != nil {
121 return nil, fmt.Errorf("sibling %s: S3 unreachable: %w", s.Name, bare(err))
122 }
123 defer res.Body.Close()
124
125 // Read one byte past the cap, so going over it is **detectable**
126 // rather than silent. S1 and S2 get this for free — a truncated JSON
127 // does not parse — but an S3 body is plaintext, and a 2 MiB prefix of
128 // a larger document indexes perfectly happily as the whole thing,
129 // with its tail unfindable and nothing said. That is a document
130 // quietly shortened, which is the same shape as a page quietly
131 // shortened and no better for being about bytes.
132 raw, err := io.ReadAll(io.LimitReader(res.Body, maxContentResponse+1))
133 if err != nil {
134 return nil, fmt.Errorf("sibling %s: reading S3: %w", s.Name, err)
135 }
136 if len(raw) > maxContentResponse {
137 return nil, fmt.Errorf("sibling %s: S3 body over %d bytes", s.Name, maxContentResponse)
138 }
139 return suite.DecodeS3(res.StatusCode, raw)
140}
141
142// bare strips the URL out of a transport error.
143//
144// `http.Client.Do` returns a `*url.Error`, and its message is
145// `Get "<the whole URL>": <cause>`. On S1 that URL carries the cursor; on
146// S3 it carries the item id. **Both are sealed at rest** — the cursor
147// under seal.PurposeCursor, the item id under seal.PurposeItemID — and
148// AGENTS.md's rule about content in logs covers error strings, not just
149// log calls.
150//
151// Nothing logs these errors today, which is exactly why this is worth
152// fixing now: the next caller to log one would put a plaintext coordinate
153// into `carlos logs`, which is storage we cannot delete, and would have
154// no reason to suspect it. A review found it by unplugging a sibling and
155// reading the error.
156//
157// The cause survives — "connection refused", "context deadline exceeded"
158// — because that is the part an operator needs.
159func bare(err error) error {
160 var ue *url.Error
161 if errors.As(err, &ue) && ue.Err != nil {
162 return ue.Err
163 }
164 return err
165}
166