S1 and S3: the feed refuses extra fields, and 403 is a reported breach
S1 decodes with unknown fields disallowed. The contract says a row is (item_id, revision, gone, touched_at) and nothing else, and memoria's draft 1 shows why that is worth enforcing on the client: its feed carried a visible-to-team flag, and "visible=false, gone=false" told a consumer that a guest-only item existed, at what revision, and when it was touched — the exact fact S3's "404, never 403" exists to hide. A sibling that starts sending a title, or a reason beside `gone`, is either leaking or running a different contract, and ignoring the field would keep it invisible until somebody grepped a store. A non-2xx is an error rather than an empty feed, since an empty feed means "nothing changed" and would advance a cursor past changes nobody saw. S3 has three outcomes, deliberately not two. 404 is ErrNotAvailable, and deleted, never-team-visible and departed-from-team-visible all land on it — a consumer that could tell them apart would learn a private item exists. 403 is ErrContractBreach, kept separate: 403 means "this exists and you may not have it", which is the one fact custody hides, so a sibling answering it is leaking and folding that into a refusal hides the leak. An outage is neither. No asker appears anywhere in S3. It is custody-scoped and person-less; memoria's draft 3 added a person and broke the ingest path, which has no asker at all, while pulling in individually granted rows its store may not hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+230
−0
s1.go+53 −0s1_test.go+75 −0s3.go+45 −0s3_test.go+57 −0
diff --git a/s1.go b/s1.go| new file mode 100644 |
| index 0000000..702bd89 |
| --- /dev/null |
| +++ b/s1.go |
| @@ -0,0 +1,53 @@ |
| +package suite |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// FeedRow is one row of the S1 change feed, and it is the whole row. |
| +// |
| +// Doctrine §6: (item_id, revision, gone, touched_at) — no title, no content, |
| +// no visibility flag. memoria's draft 1 carried a visible-to-team field and it |
| +// was a leak: `visible=false, gone=false` told a consumer that a guest-only |
| +// item existed, at what revision and when it was touched, which is what S3's |
| +// "404, never 403" is there to hide. §4.2: departure from team-visibility is |
| +// `gone`, indistinguishable from deletion. |
| +type FeedRow struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Gone bool `json:"gone"` |
| + TouchedAt time.Time `json:"touched_at"` |
| +} |
| + |
| +// Feed is one page of the change feed. The cursor is opaque: a position in the |
| +// sibling's own event log, issued by the sibling and stored per consumer. |
| +type Feed struct { |
| + Cursor string `json:"cursor"` |
| + Rows []FeedRow `json:"rows"` |
| +} |
| + |
| +// DecodeS1 reads a change-feed page, refusing anything the contract does not |
| +// allow on the wire. |
| +func DecodeS1(status int, body []byte) (Feed, error) { |
| + if status < 200 || status > 299 { |
| + // Not an empty feed. An empty feed means "nothing changed", and |
| + // returning that for a failed call advances a consumer's cursor past |
| + // changes it never saw. |
| + return Feed{}, fmt.Errorf("suite: S1 answered %d", status) |
| + } |
| + |
| + dec := json.NewDecoder(bytes.NewReader(body)) |
| + // A row carrying more than the four fields is either a sibling leaking or |
| + // a sibling running a different contract. Ignoring the extra field would |
| + // let either go unnoticed until somebody grepped the store. |
| + dec.DisallowUnknownFields() |
| + |
| + var feed Feed |
| + if err := dec.Decode(&feed); err != nil { |
| + return Feed{}, fmt.Errorf("suite: S1 body outside the contract: %w", err) |
| + } |
| + return feed, nil |
| +} |
diff --git a/s1_test.go b/s1_test.go| new file mode 100644 |
| index 0000000..28d381b |
| --- /dev/null |
| +++ b/s1_test.go |
| @@ -0,0 +1,75 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// S1 rows are (item_id, revision, gone, touched_at) and nothing else |
| +// (doctrine §6). Draft 1 of memoria's feed carried a visible-to-team field and |
| +// that was a leak: `visible=false, gone=false` tells a consumer that a |
| +// guest-only item exists, at what revision and when it was touched — exactly |
| +// what S3's "404, never 403" exists to hide. |
| +func TestS1DecodeRows(t *testing.T) { |
| + body := []byte(`{"cursor":"c-92","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z"}, |
| + {"item_id":"doc-2","revision":2,"gone":true,"touched_at":"2026-09-05T09:01:00Z"} |
| + ]}`) |
| + |
| + feed, err := DecodeS1(200, body) |
| + if err != nil { |
| + t.Fatalf("unexpected error: %v", err) |
| + } |
| + |
| + if feed.Cursor != "c-92" { |
| + t.Errorf("cursor: got %q, want %q", feed.Cursor, "c-92") |
| + } |
| + if len(feed.Rows) != 2 { |
| + t.Fatalf("got %d rows, want 2", len(feed.Rows)) |
| + } |
| + if feed.Rows[0].ItemID != "doc-1" || feed.Rows[0].Revision != 7 || feed.Rows[0].Gone { |
| + t.Errorf("row 0: got %+v", feed.Rows[0]) |
| + } |
| + if !feed.Rows[1].Gone { |
| + t.Errorf("row 1: got gone=false, want the tombstone") |
| + } |
| +} |
| + |
| +// A feed row carrying anything beyond the four fields is refused, loudly. The |
| +// contract says a row has no title, no content and no visibility flag; a |
| +// sibling sending one is either leaking or running a different contract, and |
| +// silently ignoring the field would let either go unnoticed for as long as |
| +// nobody grepped the store. |
| +func TestS1RejectsRowCarryingContent(t *testing.T) { |
| + body := []byte(`{"cursor":"c-92","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":false,"touched_at":"2026-09-05T09:00:00Z","title":"Q3 plan"} |
| + ]}`) |
| + |
| + _, err := DecodeS1(200, body) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a row carrying a title, want a refusal") |
| + } |
| +} |
| + |
| +// Departure from team-visibility and deletion are the same row, and the |
| +// client must not offer a caller any way to tell them apart (§4.2). |
| +func TestS1GoneCarriesNoReason(t *testing.T) { |
| + body := []byte(`{"cursor":"c-1","rows":[ |
| + {"item_id":"doc-1","revision":7,"gone":true,"touched_at":"2026-09-05T09:00:00Z","reason":"went_private"} |
| + ]}`) |
| + |
| + _, err := DecodeS1(200, body) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a gone row carrying a reason, want a refusal") |
| + } |
| +} |
| + |
| +// A non-2xx feed answer is an error, not an empty feed. An empty feed means |
| +// "nothing changed"; returning that for a failed call would advance a |
| +// consumer's cursor past changes it never saw. |
| +func TestS1NonSuccessIsAnError(t *testing.T) { |
| + _, err := DecodeS1(503, []byte(`{"cursor":"c-1","rows":[]}`)) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 503, want a refusal") |
| + } |
| +} |
diff --git a/s3.go b/s3.go| new file mode 100644 |
| index 0000000..31dbdd2 |
| --- /dev/null |
| +++ b/s3.go |
| @@ -0,0 +1,45 @@ |
| +package suite |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| +) |
| + |
| +// ErrNotAvailable is the whole vocabulary of an S3 refusal. |
| +// |
| +// Doctrine §4.2: a content fetch for anything outside the custody predicate |
| +// answers 404, never 403, and the status is identical for "not team-visible" |
| +// and "does not exist". Deleted, never team-visible, and departed from |
| +// team-visibility are one answer here on purpose — a consumer that could tell |
| +// them apart would learn that a private item exists. |
| +var ErrNotAvailable = errors.New("suite: item not available") |
| + |
| +// ErrContractBreach marks a sibling answering in a way the contract forbids. |
| +// It is separate from a refusal and from an outage because it is neither: it |
| +// is a bug in the sibling that a consumer should surface rather than absorb. |
| +var ErrContractBreach = errors.New("suite: sibling broke the contract") |
| + |
| +// DecodeS3 reads a content fetch. There is no asker anywhere in this call: |
| +// S3 is custody-scoped and person-less, and memoria's draft 3 added a person |
| +// to it, which both broke the ingest path — the warm-up, the rebuild and |
| +// derived-artefact regeneration have no asker — and pulled individually |
| +// granted rows into a store that may not hold them. |
| +func DecodeS3(status int, body []byte) ([]byte, error) { |
| + switch { |
| + case status >= 200 && status <= 299: |
| + return body, nil |
| + |
| + case status == 404: |
| + return nil, ErrNotAvailable |
| + |
| + case status == 403: |
| + // "404, never 403" is the rule, because 403 means "this exists and |
| + // you may not have it" — the one fact custody hides. A sibling |
| + // answering 403 is leaking, and folding it into ErrNotAvailable would |
| + // keep that leak invisible to everyone downstream. |
| + return nil, fmt.Errorf("%w: S3 answered 403 where the contract requires 404", ErrContractBreach) |
| + |
| + default: |
| + return nil, fmt.Errorf("suite: S3 answered %d", status) |
| + } |
| +} |
diff --git a/s3_test.go b/s3_test.go| new file mode 100644 |
| index 0000000..accf823 |
| --- /dev/null |
| +++ b/s3_test.go |
| @@ -0,0 +1,57 @@ |
| +package suite |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| +) |
| + |
| +// S3 is the ingest call: (id, revision), no person, team-visible only. |
| +func TestS3ReturnsBody(t *testing.T) { |
| + got, err := DecodeS3(200, []byte("the body")) |
| + if err != nil { |
| + t.Fatalf("unexpected error: %v", err) |
| + } |
| + |
| + if string(got) != "the body" { |
| + t.Errorf("got %q, want %q", got, "the body") |
| + } |
| +} |
| + |
| +// 404 is the whole vocabulary of refusal. Deleted, never team-visible, or |
| +// departed from team-visibility all answer the same way, so a consumer cannot |
| +// learn from a refusal that the item exists (§4.2). |
| +func TestS3NotFoundIsIndistinguishable(t *testing.T) { |
| + _, err := DecodeS3(404, nil) |
| + |
| + if !errors.Is(err, ErrNotAvailable) { |
| + t.Errorf("got %v, want ErrNotAvailable", err) |
| + } |
| +} |
| + |
| +// A 403 breaks the contract, and the client says so rather than folding it in |
| +// with 404. "404, never 403" is the rule (§4.2, §6) because 403 means "this |
| +// exists and you may not have it" — which is precisely the fact custody hides. |
| +// A sibling answering 403 has a bug that leaks, and quietly treating it as a |
| +// refusal would keep that bug invisible. |
| +func TestS3ForbiddenIsAContractBreach(t *testing.T) { |
| + _, err := DecodeS3(403, nil) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 403, want a contract breach reported") |
| + } |
| + if errors.Is(err, ErrNotAvailable) { |
| + t.Error("403 was folded into ErrNotAvailable; it must be distinguishable") |
| + } |
| +} |
| + |
| +// Any other non-2xx is a failed call, not a refusal. |
| +func TestS3ServerErrorIsNotARefusal(t *testing.T) { |
| + _, err := DecodeS3(503, nil) |
| + |
| + if err == nil { |
| + t.Fatal("got nil error for a 503, want a failure") |
| + } |
| + if errors.Is(err, ErrNotAvailable) { |
| + t.Error("503 was reported as a refusal; an outage is not an answer") |
| + } |
| +} |