| 1 | package sibling |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | const ( |
| 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. |
| 42 | func (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. |
| 90 | func (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. |
| 159 | func 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 | |