oficina / suite Public

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>
Paul Campbell pushed by paul@keymail.dev 7c7600a32e97c85534d5eaff1226a60b23f9f02a parent dc0d7df
4 files changed, +230 −0
  • s1.go +53 −0
  • s1_test.go +75 −0
  • s3.go +45 −0
  • s3_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")
+ }
+}