S2's total decoder: every pair gets exactly one of the four values
Doctrine gate 2. The decoder is total by construction: whatever a sibling sends, the caller gets one Authorization per pair it asked about, in the order it asked. Five ways a call fails to answer, and what each costs: - A non-2xx status, or a body that does not parse: whole-call UNKNOWN. A body arriving with a 500 is not an answer however well it parses, and a body that does not parse is not an answer however it arrived. - An item missing from the response: UNKNOWN for that item, the others unaffected. memoria §4.1 — a sibling that never answers cannot return anything, so the caller synthesises it. - An item answered twice: UNKNOWN for that item. The last-wins map this replaced picked NOT_VISIBLE out of a contradictory pair, which is the one value §4.3 lets a consumer act on silently. Two answers are not an answer, and choosing between them would be the client making a decision the app of record did not make. - A value outside the four: UNKNOWN. The vocabulary is closed, so a fifth word is a sibling this client does not understand, and passing it through hands the caller something it cannot switch on. Each was written as a failing test first and watched fail. The duplicate case earned its keep: it failed showing NOT_VISIBLE where UNKNOWN belonged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+228
−0
.gitignore+7 −0go.mod+3 −0s2.go+97 −0s2_test.go+121 −0
diff --git a/.gitignore b/.gitignore| index 1181530..a38480e 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -16,3 +16,10 @@ |
| .claude/launch.json |
| .claude/settings.local.json |
| .superpowers/ |
| + |
| +# Go build cache, kept in-tree because the sandbox will not write to |
| +# ~/.cache/go-build. |
| +.gocache/ |
| + |
| +# Scratch for `amadan branch describe -body -`. |
| +.branch-desc.txt |
diff --git a/go.mod b/go.mod| new file mode 100644 |
| index 0000000..d538f9f |
| --- /dev/null |
| +++ b/go.mod |
| @@ -0,0 +1,3 @@ |
| +module amadan.net/oficina/suite |
| + |
| +go 1.26 |
diff --git a/s2.go b/s2.go| new file mode 100644 |
| index 0000000..e9cf5d9 |
| --- /dev/null |
| +++ b/s2.go |
| @@ -0,0 +1,97 @@ |
| +package suite |
| + |
| +import "encoding/json" |
| + |
| +// Authorization is what S2 answers for one item, for one named asker. |
| +// |
| +// Doctrine §4.3: four values, none collapsible into another. Only |
| +// NOT_VISIBLE may be silent; everything else that is not VISIBLE withholds |
| +// loudly and names the app. |
| +type Authorization string |
| + |
| +const ( |
| + // VISIBLE: this person may read this revision. |
| + VISIBLE Authorization = "VISIBLE" |
| + // NOT_VISIBLE: this person may not read it. The only value a consumer |
| + // may act on silently. |
| + NOT_VISIBLE Authorization = "NOT_VISIBLE" |
| + // STALE: the item is readable, but the app of record has moved past the |
| + // revision asked about. A synchronisation condition, never a denial. |
| + STALE Authorization = "STALE" |
| + // UNKNOWN: the app of record did not answer usefully, or cannot vouch |
| + // for the revision asked about. Withheld loudly. |
| + UNKNOWN Authorization = "UNKNOWN" |
| +) |
| + |
| +// ItemRevision is one pair in an S2 question: which item, at which revision. |
| +type ItemRevision struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| +} |
| + |
| +type s2Result struct { |
| + ItemID string `json:"item_id"` |
| + Revision int64 `json:"revision"` |
| + Result Authorization `json:"result"` |
| +} |
| + |
| +type s2Body struct { |
| + Results []s2Result `json:"results"` |
| +} |
| + |
| +// DecodeS2 turns an S2 answer into exactly one Authorization per pair asked, |
| +// in the order they were asked. |
| +func DecodeS2(asked []ItemRevision, status int, body []byte) []Authorization { |
| + // Whole-call failures. A body that arrives with a non-2xx status is not |
| + // an answer however well it parses, and a body that does not parse is |
| + // not an answer however it arrived. |
| + if status < 200 || status > 299 { |
| + return allUnknown(len(asked)) |
| + } |
| + var parsed s2Body |
| + if err := json.Unmarshal(body, &parsed); err != nil { |
| + return allUnknown(len(asked)) |
| + } |
| + |
| + byPair := make(map[ItemRevision]Authorization, len(parsed.Results)) |
| + for _, r := range parsed.Results { |
| + pair := ItemRevision{ItemID: r.ItemID, Revision: r.Revision} |
| + if _, already := byPair[pair]; already { |
| + // Answered twice. Two answers are not an answer, and choosing |
| + // between them would be this client making a decision the app of |
| + // record did not make. |
| + byPair[pair] = UNKNOWN |
| + continue |
| + } |
| + byPair[pair] = known(r.Result) |
| + } |
| + |
| + out := make([]Authorization, len(asked)) |
| + for i, pair := range asked { |
| + answer, answered := byPair[pair] |
| + if !answered { |
| + answer = UNKNOWN |
| + } |
| + out[i] = answer |
| + } |
| + return out |
| +} |
| + |
| +// known maps anything outside the closed four-value vocabulary to UNKNOWN. |
| +// A fifth word is a sibling this client does not understand; passing it |
| +// through would hand a caller a value it cannot switch on. |
| +func known(a Authorization) Authorization { |
| + switch a { |
| + case VISIBLE, NOT_VISIBLE, STALE, UNKNOWN: |
| + return a |
| + } |
| + return UNKNOWN |
| +} |
| + |
| +func allUnknown(n int) []Authorization { |
| + out := make([]Authorization, n) |
| + for i := range out { |
| + out[i] = UNKNOWN |
| + } |
| + return out |
| +} |
diff --git a/s2_test.go b/s2_test.go| new file mode 100644 |
| index 0000000..11f8adb |
| --- /dev/null |
| +++ b/s2_test.go |
| @@ -0,0 +1,121 @@ |
| +package suite |
| + |
| +import "testing" |
| + |
| +// Gate 2, first case. A well-formed answer yields exactly one result per |
| +// requested pair, in request order. |
| +// |
| +// Doctrine §4.3: four values, none collapsible into another. §6: cap 256 |
| +// pairs, total decoder. |
| +func TestS2DecodeWellFormed(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"NOT_VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + want := []Authorization{VISIBLE, NOT_VISIBLE} |
| + if len(got) != len(want) { |
| + t.Fatalf("got %d results, want %d", len(got), len(want)) |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Errorf("result %d: got %v, want %v", i, got[i], want[i]) |
| + } |
| + } |
| +} |
| + |
| +// An item the app of record did not answer for is UNKNOWN — never the zero |
| +// value, and never NOT_VISIBLE. memoria §4.1: a non-answer is synthesised by |
| +// the caller, because a sibling that never answers cannot return anything. |
| +func TestS2MissingItemIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"VISIBLE"}]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[1] != UNKNOWN { |
| + t.Errorf("missing item: got %q, want UNKNOWN", got[1]) |
| + } |
| + if got[0] != VISIBLE { |
| + t.Errorf("answered item: got %q, want VISIBLE", got[0]) |
| + } |
| +} |
| + |
| +// A non-2xx status is a whole-call failure: every pair is UNKNOWN, whatever |
| +// the body happens to contain. A body arriving with a 500 is not an answer. |
| +func TestS2NonSuccessStatusIsWholeCallUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 500, body) |
| + |
| + for i, a := range got { |
| + if a != UNKNOWN { |
| + t.Errorf("pair %d: got %q, want UNKNOWN", i, a) |
| + } |
| + } |
| +} |
| + |
| +// A body that does not parse is a whole-call failure too. |
| +func TestS2MalformedBodyIsWholeCallUnknown(t *testing.T) { |
| + asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| + |
| + got := DecodeS2(asked, 200, []byte(`{"results":[{"item_id":`)) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("got %q, want UNKNOWN", got[0]) |
| + } |
| +} |
| + |
| +// An item answered twice is UNKNOWN for that item. Two answers are not an |
| +// answer, and picking either one would be the caller inventing a decision the |
| +// app of record did not make. The other pairs are unaffected. |
| +func TestS2DuplicateItemIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{ |
| + {ItemID: "doc-1", Revision: 7}, |
| + {ItemID: "doc-2", Revision: 3}, |
| + } |
| + body := []byte(`{"results":[ |
| + {"item_id":"doc-1","revision":7,"result":"VISIBLE"}, |
| + {"item_id":"doc-1","revision":7,"result":"NOT_VISIBLE"}, |
| + {"item_id":"doc-2","revision":3,"result":"VISIBLE"} |
| + ]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("duplicated item: got %q, want UNKNOWN", got[0]) |
| + } |
| + if got[1] != VISIBLE { |
| + t.Errorf("other item: got %q, want VISIBLE", got[1]) |
| + } |
| +} |
| + |
| +// A value outside the four is UNKNOWN. The vocabulary is closed (§4.3), so an |
| +// unrecognised word is a sibling this client does not understand, not a fifth |
| +// meaning to pass through to a caller that will switch on it. |
| +func TestS2UnrecognisedValueIsUnknown(t *testing.T) { |
| + asked := []ItemRevision{{ItemID: "doc-1", Revision: 7}} |
| + body := []byte(`{"results":[{"item_id":"doc-1","revision":7,"result":"MAYBE"}]}`) |
| + |
| + got := DecodeS2(asked, 200, body) |
| + |
| + if got[0] != UNKNOWN { |
| + t.Errorf("got %q, want UNKNOWN", got[0]) |
| + } |
| +} |