carlos / platform Public

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>
Paul Campbell f5f28fa83ce4ab765996b144e0de6a63d136a755 parent ce80436
4 files changed, +289 −11
  • apimode.go +54 −4
  • apimode_test.go +119 −1
  • internal/console/apicli.go +45 −6
  • internal/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)
+ }
+}