oficina / suite Public

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>
Paul Campbell pushed by paul@keymail.dev 10c6995a6c9a2eaf14a274f0c5347366908487b9 parent 1bbd2e1
4 files changed, +228 −0
  • .gitignore +7 −0
  • go.mod +3 −0
  • s2.go +97 −0
  • s2_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])
+ }
+}