console: the ship door echoes the capability block it stored
`capabilities` is an unknown JSON key to a console built before it existed, and Go's decoder ignores unknown keys — so `carlos ship -capabilities` against an un-upgraded console succeeded, stored a manifest declaring NOTHING, and answered 201. On a box that manifest is not "no change": a manifest that resolves and declares nothing is the REMOVAL instruction, so the converge pass deletes the drop-ins and :25 comes off a running mail server because a binary somewhere was old. handleAPIShip now echoes the canonical JSON of the stored block on both success paths (201 and the identical-reship 200), absent when the version declares none — additive, so a capability-free ship's response bytes are unchanged. apiSession.ship fails hard when it SENT a block and the echo is missing or different, naming the fix. Deploy order (§3b) is what prevents this; the echo is what makes breaking it loud at the terminal, before a promote, instead of quiet on a box an hour later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+289
−11
apimode.go+54 −4apimode_test.go+119 −1internal/console/apicli.go+45 −6internal/console/apicli_test.go+71 −0
diff --git a/apimode.go b/apimode.go| index 41e063f6a..e116fea96 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -28,6 +28,7 @@ import ( |
| "os" |
| "path/filepath" |
| "sort" |
| + "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/caps" |
| @@ -434,15 +435,64 @@ func (s *apiSession) ship(ctx context.Context, account, app, version, kind strin |
| // for bytes actually written, 200 when the request matched an identical |
| // version already on record and nothing new was stored. |
| switch status { |
| - case http.StatusCreated: |
| - return apiShipResult{NoOp: false}, nil |
| - case http.StatusOK: |
| - return apiShipResult{NoOp: true}, nil |
| + case http.StatusCreated, http.StatusOK: |
| + if err := checkCapabilitiesEcho(capsSet, body); err != nil { |
| + return apiShipResult{}, err |
| + } |
| + return apiShipResult{NoOp: status == http.StatusOK}, nil |
| default: |
| return apiShipResult{}, apiErrorFromBody(status, body) |
| } |
| } |
| +// checkCapabilitiesEcho is the client half of the removal signal (2026-08-03 |
| +// review, I3), and it is checked on a SUCCESSFUL response because that is the |
| +// dangerous case. |
| +// |
| +// `capabilities` is an unknown JSON key to any console built before the field |
| +// existed, and Go's decoder ignores unknown keys — so shipping a |
| +// capability-bearing release through an un-upgraded console succeeds, stores a |
| +// manifest that declares NOTHING, and says 201. That manifest is not inert on |
| +// a box: a manifest that resolves and declares nothing is the REMOVAL |
| +// instruction, so the next converge pass deletes the drop-ins and the app |
| +// loses its port or its host role. Deploy order (spec §3b: platform binaries — |
| +// the CONSOLE binary among them — before capability-bearing releases) is what |
| +// prevents it; this is what makes breaking that order fail loudly at the |
| +// terminal, before a promote, instead of quietly on a box an hour later. |
| +// |
| +// Only when this client SENT a block. A ship with no -capabilities expects no |
| +// echo, and demanding one would refuse every ordinary ship against a console |
| +// that has nothing to echo — the exact opposite of failing in the direction |
| +// that changes nothing. |
| +// |
| +// The comparison is between CANONICAL re-marshals, not raw bytes: what matters |
| +// is whether the console understood the declaration and stored the same one, |
| +// not how it spaced its JSON. |
| +func checkCapabilitiesEcho(sent *caps.Set, body []byte) error { |
| + if sent == nil { |
| + return nil |
| + } |
| + const updateFirst = "this console does not understand capability declarations — update the console binary first, or ship without -capabilities" |
| + var echo struct { |
| + Capabilities json.RawMessage `json:"capabilities"` |
| + } |
| + if err := json.Unmarshal(body, &echo); err != nil || len(echo.Capabilities) == 0 { |
| + return fmt.Errorf("ship: the console accepted this release but its response did not echo the capability block back: %s", updateFirst) |
| + } |
| + var got *caps.Set |
| + if err := json.Unmarshal(echo.Capabilities, &got); err != nil || got == nil { |
| + return fmt.Errorf("ship: the console echoed a capability block this binary cannot read (%s): %s", strings.TrimSpace(string(echo.Capabilities)), updateFirst) |
| + } |
| + want, werr := json.Marshal(sent) |
| + have, herr := json.Marshal(got) |
| + if werr != nil || herr != nil || !bytes.Equal(want, have) { |
| + return fmt.Errorf("ship: the console stored a capability block that differs from the one this command sent — sent %s, stored %s. "+ |
| + "The stored block is what boxes converge onto, so the release must not be promoted; update the console binary and ship again under a new version", |
| + want, have) |
| + } |
| + return nil |
| +} |
| + |
| // writeFilePart streams one local file into a multipart part named name, so |
| // a 30MiB artifact is read a chunk at a time rather than buffered whole |
| // before it starts crossing the wire. |
diff --git a/apimode_test.go b/apimode_test.go| index 0160f24ef..f5f5b36e0 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -1274,7 +1274,10 @@ func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) { |
| } |
| } |
| w.WriteHeader(http.StatusCreated) |
| - w.Write([]byte(`{"result":"shipped"}`)) |
| + // A CURRENT console echoes the stored block back (2026-08-03 review, |
| + // I3); this verb refuses a success that does not, so the fake has to |
| + // behave like the console it stands in for. |
| + w.Write([]byte(`{"result":"shipped","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`)) |
| })) |
| defer ts.Close() |
| @@ -1299,3 +1302,118 @@ func TestAPISessionShipCarriesCapabilitiesInMeta(t *testing.T) { |
| t.Fatalf("meta part = %q, want %q", metaBody, want) |
| } |
| } |
| + |
| +// shipEchoServer is a console stand-in for the capability-echo tests: it |
| +// accepts any multipart ship and answers 201 with the body given. |
| +func shipEchoServer(t *testing.T, body string) *httptest.Server { |
| + t.Helper() |
| + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Errorf("request was not multipart: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + for { |
| + p, err := mr.NextPart() |
| + if err != nil { |
| + break |
| + } |
| + io.Copy(io.Discard, p) |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + w.Write([]byte(body)) |
| + })) |
| +} |
| + |
| +// shipTestArtifact writes one throwaway artifact file and returns the files |
| +// map ship takes. |
| +func shipTestArtifact(t *testing.T) map[string]string { |
| + t.Helper() |
| + file := filepath.Join(t.TempDir(), "a") |
| + if err := os.WriteFile(file, []byte("A"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return map[string]string{"alpha-amd64": file} |
| +} |
| + |
| +// shipTestCaps is the declaration the echo tests send. |
| +func shipTestCaps(t *testing.T) *caps.Set { |
| + t.Helper() |
| + var set *caps.Set |
| + if err := json.Unmarshal([]byte(`{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}`), &set); err != nil { |
| + t.Fatal(err) |
| + } |
| + return set |
| +} |
| + |
| +// TestAPISessionShipAcceptsAMatchingCapabilitiesEcho is the ordinary path: a |
| +// current console stores what was sent and echoes it, so the ship succeeds. |
| +// The echo check must not turn every capability-bearing ship into a refusal. |
| +func TestAPISessionShipAcceptsAMatchingCapabilitiesEcho(t *testing.T) { |
| + ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}]}}`) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t)) |
| + if err != nil { |
| + t.Fatalf("ship with a matching echo: %v", err) |
| + } |
| + if res.NoOp { |
| + t.Fatal("a 201 ship must not report NoOp") |
| + } |
| +} |
| + |
| +// TestAPISessionShipRefusesAConsoleThatDropsCapabilities is the REMOVAL |
| +// signal, simulated exactly as an un-upgraded console produces it: the server |
| +// ignores the unknown "capabilities" key (Go's decoder does this by default), |
| +// stores a manifest declaring nothing, and answers 201 with no echo. That |
| +// manifest is the instruction to DELETE the drop-ins on every box — so a 201 |
| +// here has to be a hard failure at the terminal, naming the fix, rather than a |
| +// success the operator then promotes. |
| +func TestAPISessionShipRefusesAConsoleThatDropsCapabilities(t *testing.T) { |
| + ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t)) |
| + if err == nil { |
| + t.Fatal("a console that echoed no capability block was accepted — that is an old console silently dropping the declaration") |
| + } |
| + if !strings.Contains(err.Error(), "update the console binary") { |
| + t.Errorf("refusal does not name the fix: %v", err) |
| + } |
| +} |
| + |
| +// TestAPISessionShipRefusesADifferentCapabilitiesEcho: a console that |
| +// understood the key but stored something else. The STORED block is what boxes |
| +// converge onto, so a mismatch is refused too, and the message shows both — |
| +// there is no version of this where the CLI's copy is the one that matters. |
| +func TestAPISessionShipRefusesADifferentCapabilitiesEcho(t *testing.T) { |
| + ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","capabilities":{"v":1,"daemon":true}}`) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", shipTestArtifact(t), shipTestCaps(t)) |
| + if err == nil { |
| + t.Fatal("a console that stored a DIFFERENT capability block was accepted") |
| + } |
| + for _, want := range []string{"differs", "port", "must not be promoted"} { |
| + if !strings.Contains(err.Error(), want) { |
| + t.Errorf("refusal %q does not mention %q", err, want) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISessionShipWithoutCapabilitiesNeedsNoEcho: the fleet's ordinary ship |
| +// sends no block and must not start demanding one back. An echo check that |
| +// forgot to be conditional would refuse every ship the fleet does today. |
| +func TestAPISessionShipWithoutCapabilitiesNeedsNoEcho(t *testing.T) { |
| + ts := shipEchoServer(t, `{"result":"shipped","version":"1.2.3","artifacts":{}}`) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + if _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", shipTestArtifact(t), nil); err != nil { |
| + t.Fatalf("ship without -capabilities must not require an echo: %v", err) |
| + } |
| +} |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 0634686ee..149188da8 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -740,9 +740,12 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| for name, a := range existing.Artifacts { |
| out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{ |
| - "result": "shipped", "version": ver, "artifacts": out, |
| - }) |
| + body := map[string]any{"result": "shipped", "version": ver, "artifacts": out} |
| + // The stored block, not `declared` — they are equal by the check just |
| + // above, and echoing what is ON RECORD is the honest answer to "what |
| + // did this version end up declaring". |
| + addCapabilitiesEcho(body, existing.Capabilities) |
| + writeJSON(w, http.StatusOK, body) |
| return |
| } |
| @@ -771,7 +774,43 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| for name, a := range artifacts { |
| out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} |
| } |
| - writeJSON(w, http.StatusCreated, map[string]any{ |
| - "result": "shipped", "version": ver, "artifacts": out, |
| - }) |
| + body := map[string]any{"result": "shipped", "version": ver, "artifacts": out} |
| + addCapabilitiesEcho(body, m.Capabilities) |
| + writeJSON(w, http.StatusCreated, body) |
| +} |
| + |
| +// addCapabilitiesEcho adds the "capabilities" key to a ship response: the |
| +// canonical JSON of the block that was actually STORED, or nothing at all when |
| +// the version declares none. |
| +// |
| +// It exists to close the removal signal (2026-08-03 review, I3). `capabilities` |
| +// is an unknown JSON key to any console built before it existed, and Go's |
| +// decoder ignores unknown keys — so an un-upgraded console meeting a `carlos |
| +// ship -capabilities` request drops the block on the floor, writes a manifest |
| +// that declares NOTHING, and answers 201. On the box that manifest is not |
| +// "nothing new"; it is a manifest that resolves and declares no capabilities, |
| +// which is the REMOVAL instruction — converge deletes the drop-ins, and :25 |
| +// comes off a running mail server because a binary somewhere was old. The |
| +// deploy order in §3b exists to prevent exactly this, and an echo is what |
| +// makes violating it loud instead of silent: a client that sent a block and |
| +// gets no echo back knows it is talking to a console that did not understand |
| +// it, before the release is ever promoted. |
| +// |
| +// Additive in the direction that costs nothing: a client that does not read |
| +// the field is unaffected, and a version with no capabilities carries no key — |
| +// so the response bytes for every ship the fleet does today are unchanged. |
| +func addCapabilitiesEcho(body map[string]any, set *caps.Set) { |
| + if set == nil { |
| + return |
| + } |
| + b, err := json.Marshal(set) |
| + if err != nil { |
| + // Unreachable — caps.Set has no custom marshaller — and omitting the |
| + // key is the fail-closed direction: a client that sent capabilities |
| + // refuses on a missing echo, which is the same verdict it would reach |
| + // if this console were the old one. |
| + log.Printf("console: api ship: marshal capabilities echo: %v", err) |
| + return |
| + } |
| + body["capabilities"] = json.RawMessage(b) |
| } |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index d22bef67b..4fe61b7fe 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -1427,3 +1427,74 @@ func TestShipCapabilitiesMatchComparesEncodingsNotStructs(t *testing.T) { |
| t.Fatal("nil and a declared block must not match — adding or removing a block changes the version") |
| } |
| } |
| + |
| +// TestAPIShipEchoesTheStoredCapabilityBlock is the server half of the removal |
| +// signal (2026-08-03 review, I3). A console built before `capabilities` |
| +// existed ignores the unknown key — Go's decoder does that by default — stores |
| +// a manifest that declares NOTHING, and answers 201; on a box, a manifest that |
| +// resolves and declares nothing is the REMOVAL instruction, so the drop-ins go |
| +// and :25 comes off a running mail server. The echo is what lets a client tell |
| +// the two consoles apart before it promotes anything: this console says back |
| +// what it stored. |
| +// |
| +// Both success paths carry it — the 201 that wrote bytes and the 200 that |
| +// matched an identical version already on record — because a re-ship is |
| +// exactly as capable of being answered by an old console as a first ship. |
| +func TestAPIShipEchoesTheStoredCapabilityBlock(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "keymail", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-echo") |
| + payload := randomBytes(t, 128) |
| + const block = `{"v":1,"daemon":true,"ports":[{"port":25,"name":"mx"}],"host_role":true}` |
| + |
| + parts := func() []shipPart { |
| + return []shipPart{metaPartCaps(t, "echo0001", "binary", block), {name: "linux-arm64", body: payload}} |
| + } |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts()) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship with capabilities = %d %v", status, out) |
| + } |
| + want := map[string]any{ |
| + "v": float64(1), "daemon": true, "host_role": true, |
| + "ports": []any{map[string]any{"port": float64(25), "name": "mx"}}, |
| + } |
| + got, _ := out["capabilities"].(map[string]any) |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("201 ship echoed capabilities %v, want %v", out["capabilities"], want) |
| + } |
| + |
| + // The identical re-ship: 200, nothing written, and the block STILL echoed — |
| + // read back from the stored manifest this time. |
| + status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/keymail/releases", token, parts()) |
| + if status != http.StatusOK { |
| + t.Fatalf("identical re-ship = %d %v, want 200", status, out) |
| + } |
| + got, _ = out["capabilities"].(map[string]any) |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("200 re-ship echoed capabilities %v, want %v", out["capabilities"], want) |
| + } |
| +} |
| + |
| +// TestAPIShipOmitsTheEchoWhenNothingIsDeclared: a version with no capability |
| +// block carries no "capabilities" key at all, so the response bytes for every |
| +// ship the fleet does today are byte-for-byte what they were. Absence is also |
| +// the honest answer — there is no stored block to echo — and it is what the |
| +// client keys its refusal off, so a key present-but-null here would defeat the |
| +// whole check. |
| +func TestAPIShipOmitsTheEchoWhenNothingIsDeclared(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-noecho") |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "echo0002", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship = %d %v", status, out) |
| + } |
| + if _, ok := out["capabilities"]; ok { |
| + t.Fatalf("a capability-free ship echoed a capabilities key: %v", out) |
| + } |
| +} |