oficina / suite Public

Merge branch 'worktree-contract-module' into main

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev af877335919f4afeacfeccd3087660b4f0181d44 parent 1bbd2e1parent 1cc739e
19 files changed, +1397 −5
  • .gitignore +7 −0
  • README.md +37 −5
  • conformance/conformance.go +140 −0
  • conformance/conformance_test.go +177 −0
  • fixtures.go +75 −0
  • fixtures/custody.json +105 −0
  • fixtures/hostile_hosts.json +36 −0
  • fixtures_test.go +47 −0
  • go.mod +3 −0
  • s1.go +53 −0
  • s1_test.go +75 −0
  • s2.go +97 −0
  • s2_test.go +121 −0
  • s3.go +45 −0
  • s3_test.go +57 −0
  • subject.go +67 −0
  • subject_test.go +71 −0
  • transport.go +91 −0
  • transport_test.go +93 −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/README.md b/README.md
index 3b5297a..c7bcad6 100644
--- a/README.md
+++ b/README.md
@@ -13,15 +13,47 @@ owns the rule that binds the others.
- `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`: the
doctrine. The team is the boundary. Inside it the apps are one thing;
across it, sharing is membership and nothing moves. Read §1 first.
-- The contract module, `amadan.net/oficina/suite`: types, golden
- fixtures and a thin client for the calls apps make to each other and to
- the home. Not written yet; the doctrine's §12 says when.
+- The contract module, `amadan.net/oficina/suite`: types, golden fixtures
+ and a thin client for the calls apps make to each other. It has no
+ dependencies, so the pin chain from an app is one deep.
+- `fixtures/`: the golden set. `custody.json` is one case per rung of the
+ sharing ladder; `hostile_hosts.json` is the list of hostnames a sibling
+ client must refuse. Both are shared so every app runs the same gate
+ rather than inventing its own.
+- `conformance/`: the harness an app points at its own server half.
## How an app uses it
An app spec cites the doctrine and lists the rulings it consumes. It never
-restates one. An app imports the module, serves its server half, and runs
-the conformance suite in CI against the version it pins.
+restates one.
+
+In code, an app imports the module, mounts the three sibling routes on a
+handler, and runs the harness against that handler in CI:
+
+```go
+func TestSuiteConformance(t *testing.T) {
+ conformance.Run(t, myapp.SuiteRoutes())
+}
+```
+
+For the calls it makes, it takes a client that cannot leave its own team:
+
+```go
+c := suite.NewSiblingClient("acme", nil) // refuses another team's label,
+ // and never follows a redirect
+```
+
+## What is built, and what is not
+
+Built: S1 the change feed, S2 authorization with its total decoder, S3
+content, the typed asker subject, the team-bound client, and gates 1 to 4.
+That is the doctrine's §12 step 2, and it does not wait on the home.
+
+Not built, deliberately: the server half — that arrives with the first app
+that serves one, and the fixtures get corrected by the second, which is the
+point of §10. Nor call 7, call 8, the roster or anything needing an
+assertion; those are §12 steps 4 and 5 and they wait on the home's identity
+layer.
## Status
diff --git a/conformance/conformance.go b/conformance/conformance.go
new file mode 100644
index 0000000..d2ce631
--- /dev/null
+++ b/conformance/conformance.go
@@ -0,0 +1,140 @@
+// Package conformance drives an app's server half against the doctrine's
+// golden fixtures.
+//
+// An app mounts its suite routes on a handler and hands that handler here.
+// The harness fires the fixture's requests at it and reports what the contract
+// says should have happened and did not. Doctrine §10: two specs are guesses,
+// two implementations are facts — this is where the second implementation
+// corrects the first.
+package conformance
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "amadan.net/oficina/suite"
+)
+
+// Report is what a run found. An empty Failures is a pass.
+type Report struct {
+ Checked int
+ Failures []string
+}
+
+func (r *Report) fail(format string, args ...any) {
+ r.Failures = append(r.Failures, fmt.Sprintf(format, args...))
+}
+
+// Run drives the handler and fails the test with anything the contract
+// forbids. This is what an app calls from its own CI.
+func Run(t *testing.T, h http.Handler) {
+ t.Helper()
+ report := Check(h)
+ for _, f := range report.Failures {
+ t.Error(f)
+ }
+ if report.Checked == 0 {
+ t.Error("conformance ran no checks; a gate that asserts nothing passes everything")
+ }
+}
+
+// Check drives the handler and returns what it found, without touching a
+// *testing.T. It exists so the harness itself can be tested: a harness that
+// passes every server is worse than no harness, and the only way to know is to
+// point it at servers that should fail.
+func Check(h http.Handler) Report {
+ var r Report
+
+ fixture, err := suite.LoadCustodyFixture()
+ if err != nil {
+ r.fail("loading the custody fixture: %v", err)
+ return r
+ }
+
+ checkS3(h, fixture, &r)
+ checkS2(h, fixture, &r)
+ checkS1(h, &r)
+ return r
+}
+
+// Gate 3, the content half. Every rung but `team` answers 404, and it answers
+// the same 404 as a deleted item — a consumer that could tell "not
+// team-visible" from "does not exist" would learn that a private item exists.
+func checkS3(h http.Handler, f suite.CustodyFixture, r *Report) {
+ for _, c := range f.Cases {
+ url := suite.RouteS3 + "/" + c.ItemID + "?revision=" + strconv.FormatInt(c.Revision, 10)
+ rec := do(h, "GET", url, nil)
+ r.Checked++
+
+ if rec.Code != c.S3Status {
+ r.fail("S3 %s (%s, rung %s): got %d, want %d", c.ItemID, c.Name, c.Rung, rec.Code, c.S3Status)
+ }
+ if rec.Code == 403 {
+ r.fail("S3 %s: answered 403, and the contract is 404 never 403 — a 403 says the item exists, which is the fact custody hides", c.ItemID)
+ }
+ }
+}
+
+// Gate 3, the authorization half. The fixture's asker deliberately holds a
+// live grant on the private and granted items and a former grant on the
+// deleted one: the custody intersection (§4.3) has to beat the grant, or a
+// consumer holding an ingested id learns the item is still there.
+func checkS2(h http.Handler, f suite.CustodyFixture, r *Report) {
+ pairs := make([]suite.ItemRevision, 0, len(f.Cases))
+ want := make(map[string]suite.Authorization, len(f.Cases))
+ for _, c := range f.Cases {
+ pairs = append(pairs, suite.ItemRevision{ItemID: c.ItemID, Revision: c.Revision})
+ want[c.ItemID] = c.S2Result
+ }
+
+ if err := suite.CheckS2Batch(pairs); err != nil {
+ r.fail("custody fixture is over the S2 cap: %v", err)
+ return
+ }
+
+ body, err := json.Marshal(suite.S2Request{Asker: f.Asker, Pairs: pairs})
+ if err != nil {
+ r.fail("encoding the S2 question: %v", err)
+ return
+ }
+
+ rec := do(h, "POST", suite.RouteS2, body)
+ got := suite.DecodeS2(pairs, rec.Code, rec.Body.Bytes())
+
+ for i, p := range pairs {
+ r.Checked++
+ if got[i] != want[p.ItemID] {
+ r.fail("S2 %s: got %s, want %s", p.ItemID, got[i], want[p.ItemID])
+ }
+ }
+}
+
+// Gate 1 for the feed, and the leak memoria's draft 1 shipped. The decoder
+// refuses unknown fields, so a row carrying a title, a body or a visibility
+// flag fails here rather than in a store somebody greps a year later.
+func checkS1(h http.Handler, r *Report) {
+ rec := do(h, "GET", suite.RouteS1+"?cursor=", nil)
+ r.Checked++
+
+ if _, err := suite.DecodeS1(rec.Code, rec.Body.Bytes()); err != nil {
+ r.fail("S1: %v", err)
+ }
+}
+
+func do(h http.Handler, method, url string, body []byte) *httptest.ResponseRecorder {
+ var rdr *bytes.Reader
+ if body == nil {
+ rdr = bytes.NewReader(nil)
+ } else {
+ rdr = bytes.NewReader(body)
+ }
+ req := httptest.NewRequest(method, url, rdr)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
diff --git a/conformance/conformance_test.go b/conformance/conformance_test.go
new file mode 100644
index 0000000..ddd0ccb
--- /dev/null
+++ b/conformance/conformance_test.go
@@ -0,0 +1,177 @@
+package conformance_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/suite"
+ "amadan.net/oficina/suite/conformance"
+)
+
+// referenceServer is a correct server half, built straight from the fixture.
+// It exists so the harness can be tested against something that should pass —
+// and, with its behaviour bent, against things that should fail.
+type referenceServer struct {
+ fixture suite.CustodyFixture
+
+ // The bugs each broken variant introduces.
+ forbidInsteadOfNotFound bool // answers 403 where the contract says 404
+ honourGrantOverCustody bool // answers VISIBLE for a granted item
+ leakVisibilityInFeed bool // adds a field the feed row may not carry
+}
+
+func (s *referenceServer) find(itemID string) (suite.CustodyCase, bool) {
+ for _, c := range s.fixture.Cases {
+ if c.ItemID == itemID {
+ return c, true
+ }
+ }
+ return suite.CustodyCase{}, false
+}
+
+func (s *referenceServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasPrefix(r.URL.Path, suite.RouteS3+"/"):
+ s.serveS3(w, r)
+ case r.URL.Path == suite.RouteS2:
+ s.serveS2(w, r)
+ case r.URL.Path == suite.RouteS1:
+ s.serveS1(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+}
+
+func (s *referenceServer) serveS3(w http.ResponseWriter, r *http.Request) {
+ id := strings.TrimPrefix(r.URL.Path, suite.RouteS3+"/")
+ c, ok := s.find(id)
+ if !ok {
+ w.WriteHeader(404)
+ return
+ }
+ if c.Rung == suite.RungTeam {
+ w.WriteHeader(200)
+ w.Write([]byte("body of " + id))
+ return
+ }
+ if s.forbidInsteadOfNotFound {
+ w.WriteHeader(403)
+ return
+ }
+ w.WriteHeader(404)
+}
+
+func (s *referenceServer) serveS2(w http.ResponseWriter, r *http.Request) {
+ var req suite.S2Request
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ w.WriteHeader(400)
+ return
+ }
+
+ type result struct {
+ ItemID string `json:"item_id"`
+ Revision int64 `json:"revision"`
+ Result suite.Authorization `json:"result"`
+ }
+ out := struct {
+ Results []result `json:"results"`
+ }{}
+
+ for _, p := range req.Pairs {
+ c, ok := s.find(p.ItemID)
+ answer := suite.NOT_VISIBLE
+ switch {
+ case !ok:
+ answer = suite.NOT_VISIBLE
+ case c.Rung == suite.RungTeam:
+ answer = suite.VISIBLE
+ case s.honourGrantOverCustody && c.Rung == suite.RungGranted:
+ // The bug the intersection exists to prevent: answering from the
+ // grants join alone, so a grantee learns a non-team item exists.
+ answer = suite.VISIBLE
+ }
+ out.Results = append(out.Results, result{ItemID: p.ItemID, Revision: p.Revision, Result: answer})
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(out)
+}
+
+func (s *referenceServer) serveS1(w http.ResponseWriter, r *http.Request) {
+ rows := []map[string]any{}
+ for _, c := range s.fixture.Cases {
+ row := map[string]any{
+ "item_id": c.ItemID,
+ "revision": c.Revision,
+ "gone": c.Rung != suite.RungTeam,
+ "touched_at": "2026-09-05T09:00:00Z",
+ }
+ if s.leakVisibilityInFeed {
+ row["visible_to_team"] = c.Rung == suite.RungTeam
+ }
+ rows = append(rows, row)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{"cursor": "c-" + strconv.Itoa(len(rows)), "rows": rows})
+}
+
+func newReference(t *testing.T) *referenceServer {
+ t.Helper()
+ f, err := suite.LoadCustodyFixture()
+ if err != nil {
+ t.Fatalf("loading fixture: %v", err)
+ }
+ return &referenceServer{fixture: f}
+}
+
+// A correct server half passes. Without this the harness could fail
+// everything and still catch every bug below.
+func TestHarnessPassesACorrectServer(t *testing.T) {
+ report := conformance.Check(newReference(t))
+
+ if len(report.Failures) != 0 {
+ t.Errorf("a correct server failed conformance: %v", report.Failures)
+ }
+}
+
+// Gate 3's real target. A server answering 403 for a non-team item tells the
+// caller the item exists, which is what "404, never 403" hides.
+func TestHarnessCatches403ForANonTeamItem(t *testing.T) {
+ s := newReference(t)
+ s.forbidInsteadOfNotFound = true
+
+ report := conformance.Check(s)
+
+ if len(report.Failures) == 0 {
+ t.Error("harness passed a server answering 403 where 404 is required")
+ }
+}
+
+// §4.3's custody intersection. A server answering from its grants join alone
+// says VISIBLE for an individually granted item, and a consumer holding that
+// item's id learns it still exists.
+func TestHarnessCatchesGrantBeatingCustody(t *testing.T) {
+ s := newReference(t)
+ s.honourGrantOverCustody = true
+
+ report := conformance.Check(s)
+
+ if len(report.Failures) == 0 {
+ t.Error("harness passed a server whose S2 ignored the custody intersection")
+ }
+}
+
+// The leak memoria's draft 1 shipped: a visibility flag on a feed row.
+func TestHarnessCatchesAFeedRowCarryingVisibility(t *testing.T) {
+ s := newReference(t)
+ s.leakVisibilityInFeed = true
+
+ report := conformance.Check(s)
+
+ if len(report.Failures) == 0 {
+ t.Error("harness passed a feed row carrying a visibility flag")
+ }
+}
diff --git a/fixtures.go b/fixtures.go
new file mode 100644
index 0000000..4541ab9
--- /dev/null
+++ b/fixtures.go
@@ -0,0 +1,75 @@
+package suite
+
+import (
+ "embed"
+ "encoding/json"
+ "fmt"
+)
+
+//go:embed fixtures/*.json
+var fixtureFS embed.FS
+
+// Route shapes the server half mounts beside an app's own router. They are
+// part of the contract: the conformance harness fires exactly these, so an app
+// that mounts them elsewhere fails gate 1 rather than failing in production.
+const (
+ RouteS1 = "/suite/s1" // GET, ?cursor=
+ RouteS2 = "/suite/s2" // POST, body is S2Request
+ RouteS3 = "/suite/s3" // GET, /suite/s3/{item_id}?revision=
+)
+
+// S2Request is the wire shape of an authorization question: a typed asker
+// (§3) and up to MaxS2Pairs items. The consumer token rides the transport,
+// not the body.
+type S2Request struct {
+ Asker Subject `json:"asker"`
+ Pairs []ItemRevision `json:"pairs"`
+}
+
+// Rung is where an item sits on the sharing ladder (§4.2, ruling 15). A link
+// is not a rung; it is a read-only capability that any rung may carry, and
+// custody keys on the rung alone.
+type Rung string
+
+const (
+ RungPrivate Rung = "private"
+ RungGranted Rung = "granted"
+ RungTeam Rung = "team"
+ RungDeleted Rung = "deleted"
+)
+
+// CustodyCase is one row of the custody fixture: an item, where it sits, and
+// what both calls must answer for the fixture's asker.
+type CustodyCase struct {
+ Name string `json:"name"`
+ ItemID string `json:"item_id"`
+ Revision int64 `json:"revision"`
+ Rung Rung `json:"rung"`
+ Link string `json:"link"`
+ S3Status int `json:"s3_status"`
+ S2Result Authorization `json:"s2_result"`
+}
+
+// CustodyFixture is gate 3's world: one case per rung, plus team-plus-link and
+// the after-ingest transitions the reviews asked for.
+type CustodyFixture struct {
+ Team string `json:"team"`
+ Asker Subject `json:"asker"`
+ Cases []CustodyCase `json:"cases"`
+}
+
+// LoadCustodyFixture returns the golden custody cases every app runs.
+func LoadCustodyFixture() (CustodyFixture, error) {
+ raw, err := fixtureFS.ReadFile("fixtures/custody.json")
+ if err != nil {
+ return CustodyFixture{}, fmt.Errorf("suite: reading custody fixture: %w", err)
+ }
+ var f CustodyFixture
+ if err := json.Unmarshal(raw, &f); err != nil {
+ return CustodyFixture{}, fmt.Errorf("suite: parsing custody fixture: %w", err)
+ }
+ if len(f.Cases) == 0 {
+ return CustodyFixture{}, fmt.Errorf("suite: custody fixture has no cases")
+ }
+ return f, nil
+}
diff --git a/fixtures/custody.json b/fixtures/custody.json
new file mode 100644
index 0000000..6d5865e
--- /dev/null
+++ b/fixtures/custody.json
@@ -0,0 +1,105 @@
+{
+ "comment": [
+ "Gate 3. One fixture per rung of the sharing ladder (doctrine §4.2,",
+ "ruling 15), plus the two cases the reviews asked for: an item on the",
+ "team rung that also carries a link, and an item whose link changed",
+ "after it was ingested.",
+ "",
+ "The asker is the same person for every case, and the item id is quoted",
+ "in every case, so a server cannot pass by answering differently for",
+ "different ids. What must differ is the rung; what must not differ is",
+ "the answer for everything that is not `team`.",
+ "",
+ "s3_status is what a content fetch answers. s2_result is what an",
+ "authorization call answers for `asker`, who deliberately holds a live",
+ "grant on doc-private and doc-granted and a former grant on doc-deleted:",
+ "the intersection with custody (§4.3) must beat the grant every time."
+ ],
+
+ "team": "acme",
+ "asker": { "kind": "person_ref", "value": "pers_ana" },
+
+ "cases": [
+ {
+ "name": "team rung is the only thing custody admits",
+ "item_id": "doc-team",
+ "revision": 7,
+ "rung": "team",
+ "link": null,
+ "s3_status": 200,
+ "s2_result": "VISIBLE"
+ },
+ {
+ "name": "private, and the asker owns it",
+ "item_id": "doc-private",
+ "revision": 4,
+ "rung": "private",
+ "link": null,
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "granted to the asker individually",
+ "item_id": "doc-granted",
+ "revision": 9,
+ "rung": "granted",
+ "link": null,
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "granted to a guest only",
+ "item_id": "doc-guest",
+ "revision": 2,
+ "rung": "granted",
+ "link": null,
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "link-shared but not team-shared: a link is not a rung",
+ "item_id": "doc-link",
+ "revision": 5,
+ "rung": "private",
+ "link": "read",
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "team-shared and link-shared: team-visible because of the rung",
+ "item_id": "doc-team-link",
+ "revision": 3,
+ "rung": "team",
+ "link": "read",
+ "s3_status": 200,
+ "s2_result": "VISIBLE"
+ },
+ {
+ "name": "deleted, with a former grant held by the asker",
+ "item_id": "doc-deleted",
+ "revision": 6,
+ "rung": "deleted",
+ "link": null,
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "ingested on the team rung, since moved to private",
+ "item_id": "doc-was-team",
+ "revision": 8,
+ "rung": "private",
+ "link": null,
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ },
+ {
+ "name": "a link added after ingest changes nothing either way",
+ "item_id": "doc-link-added-late",
+ "revision": 11,
+ "rung": "private",
+ "link": "read",
+ "s3_status": 404,
+ "s2_result": "NOT_VISIBLE"
+ }
+ ]
+}
diff --git a/fixtures/hostile_hosts.json b/fixtures/hostile_hosts.json
new file mode 100644
index 0000000..ded880c
--- /dev/null
+++ b/fixtures/hostile_hosts.json
@@ -0,0 +1,36 @@
+{
+ "comment": [
+ "Gate 4. Hosts a sibling client belonging to team `acme` must refuse,",
+ "and the one it must reach. Doctrine §4.1.",
+ "",
+ "The gate counts connections, not errors: a refused host must cost zero",
+ "connection attempts. The redirect case is the exception the doctrine",
+ "calls out — a client has to reach an allowed host to be told to",
+ "redirect, so what is forbidden there is the follow-up hop."
+ ],
+
+ "team": "acme",
+
+ "refuse": [
+ { "host": "acme-evil.docs.oficina.works", "why": "a different team label that merely starts with ours" },
+ { "host": "acmex.docs.oficina.works", "why": "a longer label with ours as a prefix" },
+ { "host": "acme.docs.oficina.works.evil", "why": "in evil, not in oficina.works, despite reading like it" },
+ { "host": "evil.com", "why": "outside the suite domain entirely" },
+ { "host": "docs.oficina.works", "why": "no team label at all" },
+ { "host": "acme.team.docs.oficina.works", "why": "an extra label between team and app" },
+ { "host": "ACME-EVIL.docs.oficina.works", "why": "case must not smuggle a different label through" }
+ ],
+
+ "reach": [
+ { "host": "acme.docs.oficina.works", "why": "our own team's Docs instance" },
+ { "host": "acme.sheets.oficina.works", "why": "our own team's Sheets instance" },
+ { "host": "acme.docs.oficina.works.", "why": "a fully-qualified trailing dot is the same host, and normalising it is the check's job — refusing it would break a legitimate caller" },
+ { "host": "ACME.docs.oficina.works", "why": "hostnames are case-insensitive; our own host shouted is still ours" }
+ ],
+
+ "redirect": {
+ "from": "acme.docs.oficina.works",
+ "to": "acme-evil.docs.oficina.works",
+ "why": "the first hop is legitimate; following the redirect is not"
+ }
+}
diff --git a/fixtures_test.go b/fixtures_test.go
new file mode 100644
index 0000000..db69f59
--- /dev/null
+++ b/fixtures_test.go
@@ -0,0 +1,47 @@
+package suite
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+type hostCase struct {
+ Host string `json:"host"`
+ Why string `json:"why"`
+}
+
+type hostileHosts struct {
+ Team string `json:"team"`
+ Refuse []hostCase `json:"refuse"`
+ Reach []hostCase `json:"reach"`
+ Comment []string `json:"comment"`
+}
+
+// The hostile-hostname fixture is the shared list, so every app runs gate 4
+// against the same hosts rather than each inventing its own. This test is the
+// module holding itself to the fixture it publishes.
+func TestHostileHostFixture(t *testing.T) {
+ raw, err := os.ReadFile("fixtures/hostile_hosts.json")
+ if err != nil {
+ t.Fatalf("reading fixture: %v", err)
+ }
+ var f hostileHosts
+ if err := json.Unmarshal(raw, &f); err != nil {
+ t.Fatalf("parsing fixture: %v", err)
+ }
+ if len(f.Refuse) == 0 || len(f.Reach) == 0 {
+ t.Fatal("fixture has no cases; a gate that asserts nothing passes everything")
+ }
+
+ for _, c := range f.Refuse {
+ if err := CheckSiblingHost(f.Team, c.Host); err == nil {
+ t.Errorf("reached %q, want it refused — %s", c.Host, c.Why)
+ }
+ }
+ for _, c := range f.Reach {
+ if err := CheckSiblingHost(f.Team, c.Host); err != nil {
+ t.Errorf("refused %q (%v), want it reached — %s", c.Host, err, c.Why)
+ }
+ }
+}
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/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/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])
+ }
+}
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")
+ }
+}
diff --git a/subject.go b/subject.go
new file mode 100644
index 0000000..a3d9bca
--- /dev/null
+++ b/subject.go
@@ -0,0 +1,67 @@
+package suite
+
+import (
+ "fmt"
+ "strings"
+)
+
+// SubjectKind says how to read an asker subject's value. Doctrine §3: the
+// meaning is shape-dependent and nothing else on the wire says which shape
+// the pair is in, so a receiver that does not key on the kind it is sent
+// answers UNKNOWN — loudly — rather than failing to match and answering
+// NOT_VISIBLE, which callers are entitled to act on silently.
+type SubjectKind string
+
+const (
+ // SubjectPersonRef is the assertion's opaque `sub` (home §5.3). Suite
+ // shape, where the home is the identity authority.
+ SubjectPersonRef SubjectKind = "person_ref"
+ // SubjectAddress is a lowercased address. Lone shape, where the instance
+ // keys its own members on the address it signed them in with.
+ SubjectAddress SubjectKind = "address"
+)
+
+// Subject is the named asker an S2 or S6 call carries beside its consumer
+// token. S3 carries none, by design (§6).
+type Subject struct {
+ Kind SubjectKind `json:"kind"`
+ Value string `json:"value"`
+}
+
+// NewSubject builds a subject, refusing anything a receiver could not key on
+// and normalising what it can.
+func NewSubject(kind SubjectKind, value string) (Subject, error) {
+ switch kind {
+ case SubjectPersonRef:
+ // Opaque. Case may be significant, so it is passed through untouched.
+ case SubjectAddress:
+ // Lowercased, because that is what a lone instance stores. Sending
+ // mixed case to a receiver holding the lowercased form misses, and a
+ // miss answers NOT_VISIBLE — the silent value — for what is really a
+ // normalisation bug.
+ value = strings.ToLower(value)
+ default:
+ return Subject{}, fmt.Errorf("suite: unknown asker subject kind %q", kind)
+ }
+
+ if value == "" {
+ return Subject{}, fmt.Errorf("suite: empty asker subject value for kind %q", kind)
+ }
+ return Subject{Kind: kind, Value: value}, nil
+}
+
+// MaxS2Pairs caps one S2 question. 256 is memoria §6.1's number, carried here
+// with the contract: it bounds the work a sibling can be made to do in one
+// call, and it bounds the size of a response the total decoder has to keep in
+// memory before it can answer.
+const MaxS2Pairs = 256
+
+// CheckS2Batch refuses an over-cap question rather than truncating it. A
+// silently shortened question produces a confident answer about the items
+// that survived; the ones dropped decode as UNKNOWN and nobody learns why.
+func CheckS2Batch(pairs []ItemRevision) error {
+ if len(pairs) > MaxS2Pairs {
+ return fmt.Errorf("suite: %d pairs exceeds the S2 cap of %d", len(pairs), MaxS2Pairs)
+ }
+ return nil
+}
diff --git a/subject_test.go b/subject_test.go
new file mode 100644
index 0000000..5f4092d
--- /dev/null
+++ b/subject_test.go
@@ -0,0 +1,71 @@
+package suite
+
+import "testing"
+
+// Doctrine §3: the asker subject is typed on the wire, because its meaning is
+// shape-dependent — a person ref in suite shape, a lowercased address in lone
+// shape — and nothing else in an S2 or S6 call says which shape the pair is
+// in. A kind the receiver does not key on must reach the caller as UNKNOWN,
+// never as NOT_VISIBLE, which is the one value it may act on silently.
+func TestSubjectRejectsUnknownKind(t *testing.T) {
+ _, err := NewSubject("employee_number", "4471")
+
+ if err == nil {
+ t.Fatal("got nil error for an unknown subject kind, want a refusal")
+ }
+}
+
+func TestSubjectRejectsEmptyValue(t *testing.T) {
+ _, err := NewSubject(SubjectAddress, "")
+
+ if err == nil {
+ t.Fatal("got nil error for an empty subject value, want a refusal")
+ }
+}
+
+// An address subject is lowercased, because that is what a lone instance keys
+// its members on (§3). Sending "Ana@Example.com" to a receiver storing
+// "ana@example.com" misses, and a miss answers NOT_VISIBLE — silently.
+func TestSubjectLowercasesAddress(t *testing.T) {
+ s, err := NewSubject(SubjectAddress, "Ana@Example.COM")
+ if err != nil {
+ t.Fatalf("unexpected refusal: %v", err)
+ }
+
+ if s.Value != "ana@example.com" {
+ t.Errorf("got %q, want %q", s.Value, "ana@example.com")
+ }
+}
+
+// A person ref is opaque and is not touched. It is the assertion's `sub`
+// (home §5.3), and case may be significant in it.
+func TestSubjectLeavesPersonRefAlone(t *testing.T) {
+ s, err := NewSubject(SubjectPersonRef, "PeRs_7Fa2")
+ if err != nil {
+ t.Fatalf("unexpected refusal: %v", err)
+ }
+
+ if s.Value != "PeRs_7Fa2" {
+ t.Errorf("got %q, want the person ref unchanged", s.Value)
+ }
+}
+
+// §6 caps an S2 question at 256 pairs. Over the cap the batch is refused, not
+// truncated: a silently shortened question produces a confident answer about
+// items nobody asked about, and the missing ones would decode as UNKNOWN
+// without anyone learning why.
+func TestS2BatchOverCapIsRefused(t *testing.T) {
+ pairs := make([]ItemRevision, MaxS2Pairs+1)
+
+ if err := CheckS2Batch(pairs); err == nil {
+ t.Fatalf("got nil error for %d pairs, want a refusal", len(pairs))
+ }
+}
+
+func TestS2BatchAtCapIsAccepted(t *testing.T) {
+ pairs := make([]ItemRevision, MaxS2Pairs)
+
+ if err := CheckS2Batch(pairs); err != nil {
+ t.Errorf("got %v for %d pairs, want it accepted", err, len(pairs))
+ }
+}
diff --git a/transport.go b/transport.go
new file mode 100644
index 0000000..b5e43f6
--- /dev/null
+++ b/transport.go
@@ -0,0 +1,91 @@
+package suite
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+)
+
+// SuiteDomain is the registrable domain every suite host sits under. A sibling
+// host is `<team>.<app>.oficina.works` (doctrine §4.1).
+const SuiteDomain = "oficina.works"
+
+// DialFunc is the hook the sibling client dials through. Tests pass a
+// recorder; production passes nil and gets the standard dialer.
+type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
+
+// NewSiblingClient returns an HTTP client that will only ever reach hosts
+// inside its own team, and will never follow a redirect.
+//
+// Doctrine §4.1: every sibling client refuses a host whose `<team>` label
+// differs from its own, and refuses to follow redirects. Neither is a barrier
+// against code execution on an instance — they stop mis-provisioning and
+// accidental cross-team dialling, and that is all they are claimed as.
+func NewSiblingClient(team string, dial DialFunc) *http.Client {
+ base := &http.Transport{}
+ if dial != nil {
+ base.DialContext = dial
+ }
+
+ return &http.Client{
+ Transport: &teamBoundTransport{team: team, next: base},
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ // An allowed host has to be reached to tell us to redirect; that
+ // hop is legitimate. Following it is not: a sibling could walk
+ // this client across the boundary the guard just enforced.
+ return fmt.Errorf("suite: refusing to follow a redirect to %q", req.URL.Host)
+ },
+ }
+}
+
+type teamBoundTransport struct {
+ team string
+ next http.RoundTripper
+}
+
+func (t *teamBoundTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ if err := CheckSiblingHost(t.team, req.URL.Hostname()); err != nil {
+ // Before the transport dials, so a refused host costs no connection.
+ return nil, err
+ }
+ return t.next.RoundTrip(req)
+}
+
+// CheckSiblingHost answers whether `host` is a sibling of `team`'s instances.
+//
+// It is exported because it guards more than the HTTP client: §7.1's hand-off
+// validates a form's destination with it before the form is rendered, since an
+// HTML form action is not this client and a body posted to another team's host
+// has already crossed the boundary by the time that host could refuse it.
+func CheckSiblingHost(team, host string) error {
+ if team == "" {
+ return fmt.Errorf("suite: no team configured for the sibling client")
+ }
+
+ host = strings.TrimSuffix(strings.ToLower(host), ".")
+ suffix := "." + SuiteDomain
+
+ // The host must sit inside the suite's registrable domain.
+ if !strings.HasSuffix(host, suffix) {
+ return fmt.Errorf("suite: host %q is not in %s", host, SuiteDomain)
+ }
+
+ // Exactly `<team>.<app>` remains once the domain is removed. **This is
+ // the check that does the work**, and the one to be careful editing.
+ // A mutation test weakening the HasSuffix above to a Contains left every
+ // hostile-host fixture still refused, because `acme.docs.oficina.works.evil`
+ // does not end with the suffix, so nothing is trimmed and it arrives here
+ // with five labels rather than two. The suffix check is defence in depth
+ // and a clearer error message; it is not what stops the suffix attack.
+ labels := strings.Split(strings.TrimSuffix(host, suffix), ".")
+ if len(labels) != 2 {
+ return fmt.Errorf("suite: host %q is not <team>.<app>.%s", host, SuiteDomain)
+ }
+
+ if labels[0] != strings.ToLower(team) {
+ return fmt.Errorf("suite: host %q is team %q, not %q", host, labels[0], team)
+ }
+ return nil
+}
diff --git a/transport_test.go b/transport_test.go
new file mode 100644
index 0000000..58d163b
--- /dev/null
+++ b/transport_test.go
@@ -0,0 +1,93 @@
+package suite
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// recordingDialer stands in for the network. It records every address a
+// client tried to reach and refuses to connect, so a test can assert on the
+// attempt rather than on the outcome. Gate 4 is about connections not made.
+type recordingDialer struct{ dialed []string }
+
+func (d *recordingDialer) dial(ctx context.Context, network, addr string) (net.Conn, error) {
+ d.dialed = append(d.dialed, addr)
+ return nil, errRefusedByTest
+}
+
+var errRefusedByTest = &net.OpError{Op: "dial", Err: errTest("no network in tests")}
+
+type errTest string
+
+func (e errTest) Error() string { return string(e) }
+
+// Gate 4. A host whose <team> label differs from the caller's own is refused
+// before any connection is attempted. §4.1: the barrier stops mis-provisioning
+// and accidental cross-team dialling, and it is claimed as nothing more.
+func TestSiblingClientRefusesForeignTeamLabel(t *testing.T) {
+ d := &recordingDialer{}
+ c := NewSiblingClient("acme", d.dial)
+
+ _, err := c.Get("https://acme-evil.docs.oficina.works/s3/doc-1")
+
+ if err == nil {
+ t.Fatal("got nil error dialling another team's label, want a refusal")
+ }
+ if len(d.dialed) != 0 {
+ t.Errorf("dialled %v, want no connection attempted at all", d.dialed)
+ }
+}
+
+// The suffix attack: a host that merely ends with something resembling the
+// suite's domain. `acme.docs.oficina.works.evil` is not in oficina.works at
+// all, and a naive prefix check on the first label would wave it through.
+func TestSiblingClientRefusesSuffixAttack(t *testing.T) {
+ d := &recordingDialer{}
+ c := NewSiblingClient("acme", d.dial)
+
+ _, err := c.Get("https://acme.docs.oficina.works.evil/s3/doc-1")
+
+ if err == nil {
+ t.Fatal("got nil error for a host outside the suite domain, want a refusal")
+ }
+ if len(d.dialed) != 0 {
+ t.Errorf("dialled %v, want no connection attempted at all", d.dialed)
+ }
+}
+
+// A same-team sibling is reached. Without this the guard could refuse
+// everything and still pass the two tests above.
+func TestSiblingClientReachesSameTeamHost(t *testing.T) {
+ d := &recordingDialer{}
+ c := NewSiblingClient("acme", d.dial)
+
+ _, _ = c.Get("https://acme.docs.oficina.works/s3/doc-1")
+
+ if len(d.dialed) != 1 {
+ t.Fatalf("dialled %v, want exactly one connection to the sibling", d.dialed)
+ }
+ if !strings.HasPrefix(d.dialed[0], "acme.docs.oficina.works:") {
+ t.Errorf("dialled %q, want the same-team host", d.dialed[0])
+ }
+}
+
+// Redirects are never followed. An allowed host must be reached to be told to
+// redirect — that connection is legitimate and gate 4 counts follow-ups, not
+// first hops — but following one would let a sibling walk the client across
+// the team boundary the first check just enforced.
+func TestSiblingClientDoesNotFollowRedirects(t *testing.T) {
+ c := NewSiblingClient("acme", nil)
+
+ req, err := http.NewRequest("GET", "https://acme.docs.oficina.works/s3/doc-1", nil)
+ if err != nil {
+ t.Fatalf("building request: %v", err)
+ }
+ via := []*http.Request{req}
+
+ if err := c.CheckRedirect(req, via); err == nil {
+ t.Error("got nil from CheckRedirect, want every redirect refused")
+ }
+}