carlos / platform Public

test(console): the console↔reconciler seam, end to end; record the adopt-latency gap

Spec §13 asked for this test and it was never written: the two halves of
instance provisioning share no code path and no process, only key shapes
and an account sqid, and that is precisely the agreement that fails
silently — a console writing apps/<sqidA>/… and an agent parsing
apps/<sqidB>/… both work perfectly, and instances simply never appear.

The test drives a real httptest console over a dir store: POST an instance,
run provision.Reconcile against a temp registry in the matching
region/pool, and assert the whole minted route (socket, db path, hibernate,
provisioned, seed, channel, account) — then DELETE through the API,
reconcile again, and assert the route is gone while a hand-added
neighbouring route in the same scope is byte-identical. Every literal that
could drift is computed from the code under test (accountSqid,
provision.RecordKey, DefaultSocket/DefaultDBPath), never typed. It runs on
a non-ops account deliberately: ops's sqid is hardcoded-known on the agent
side, so an ops-only test would pass with the sqid agreement entirely
broken.

Also records the gap that test made obvious (§12): provisioning is seconds,
but being WAKEABLE is not. Minting writes a route and nothing stages
live/<host> — that is the hourly adopt pass's job, and ExecBackend's
Materialize hook is nil in production, so a brand new instance answers 503
"instance failed to wake" for up to ~1h + jitter. Follow-up is to wire
Materialize to stage the channel head on demand (retiring "no live binary"
as a wake-failure class) or to kick a targeted adopt for newly minted
hosts; until then titogo's signup UX has to say "preparing" rather than
link straight at a host that will 503.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell be1420732b58b572a3ff1c1992ca9b219111a7b1 parent fe0069d
2 files changed, +141 −0
  • docs/superpowers/specs/2026-08-04-instance-provisioning-design.md +16 −0
  • internal/console/apiinstances_seam_test.go +125 −0
diff --git a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
index c19c5c7e0..cc612e744 100644
--- a/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
+++ b/docs/superpowers/specs/2026-08-04-instance-provisioning-design.md
@@ -330,6 +330,22 @@ follow-up, not this task): signup validates its own reserved slugs, calls
systemd-per-instance and generated litestream config retire per tito spec
§4 as instances migrate to activator management.
+**Known gap (Task 8 review): a minted route has no binary until the next
+adopt pass.** Provisioning is seconds; being *wakeable* is not. Minting
+writes a route, and nothing stages `live/<host>` — that is the hourly
+adoption pass's job (`adoptLoop`, hourly + jitter), and
+`ExecBackend.Materialize` is nil in production, so the wake path has no
+on-demand fallback: it fails with "no live binary for <host>" and the edge
+answers a 503 "instance failed to wake". First-wake latency for a brand new
+instance is therefore up to ~1h + jitter today, and the instance looks
+broken to its first visitor for that whole window. Follow-up, one of: wire
+`Materialize` to stage the route's channel head on demand (which retires "no
+live binary" as a wake-failure class entirely, for hand-added routes too), or
+kick a targeted adopt for newly minted hosts from the provision pass.
+Until one lands, titogo's signup UX has to account for it — tell the user
+their instance is being prepared rather than linking them straight at a host
+that will 503.
+
**Follow-up (Task 7 review): app-token authority does not re-derive from
membership.** An app token authorizes the instance-create/list/delete verbs
for its own app regardless of who currently belongs to the account: a
diff --git a/internal/console/apiinstances_seam_test.go b/internal/console/apiinstances_seam_test.go
new file mode 100644
index 000000000..7d3c08d66
--- /dev/null
+++ b/internal/console/apiinstances_seam_test.go
@@ -0,0 +1,125 @@
+package console
+
+// The seam between the two halves of instance provisioning: the console
+// verbs that write records to the bucket (apiinstances.go) and the agent
+// pass that turns them into routes (internal/provision). They share no code
+// path and no process — only key shapes and an account sqid — which is
+// exactly the kind of agreement that breaks silently.
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// TestAPIInstanceReconcileSeam is the seam spec §13 names: the console's
+// verbs and the agent's reconcile pass, joined by nothing but the bucket.
+// Every literal that could drift between them is computed, never typed —
+// the account segment comes from console's own accountSqid (domains.Sqid),
+// the record key from provision.RecordKey, the route's paths from the
+// provisioner's shared helpers — because the failure this pins is silent:
+// a console writing apps/<sqidA>/... and an agent parsing apps/<sqidB>/...
+// both "work", and instances simply never appear.
+//
+// It runs on a NON-ops account deliberately: ops's sqid is hardcoded known
+// on the agent side (domains.OpsSqid), so an ops-only test would pass with
+// the sqid agreement completely broken.
+func TestAPIInstanceReconcileSeam(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "alice@keymail.dev", "code-inst-seam")
+ now := time.Now()
+ const acctID int64 = 4242
+ sqid := accountSqid(acctID)
+ if err := fx.st.CreateAccount(acctID, sqid, "Tito", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(acctID, "titogo", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ // The account manifest is how the agent learns this sqid exists at all
+ // (ParseRecordKey refuses keys under an unknown account).
+ if err := WriteAccountManifest(context.Background(), fx.rel, acctID, sqid, "Tito", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := provision.PutManifest(context.Background(), fx.rel, sqid, "titogo", titoManifest()); err != nil {
+ t.Fatal(err)
+ }
+
+ u := instancesURL(fx, sqid, "titogo")
+ const host = "acme.go.tito.io"
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"`+host+`","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ // A hand-added neighbour in the SAME scope: not provisioned, so the
+ // removal phase has no authority over it however the rest of the pass
+ // goes.
+ dataDir := t.TempDir()
+ neighbour := registry.Route{
+ Host: "hand.go.tito.io", Slug: "hand", Kind: "instance",
+ Socket: provision.DefaultSocket("hand.go.tito.io"),
+ DBPath: provision.DefaultDBPath(dataDir, "hand.go.tito.io"),
+ App: "titogo", Account: sqid, Pool: "tito", Region: "ie",
+ Channel: "stable", Status: "ready",
+ }
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ if err := reg.Upsert(neighbour); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Get(neighbour.Host)
+ if err != nil || before == nil {
+ t.Fatalf("neighbour route: %v", err)
+ }
+
+ cfg := provision.ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: dataDir}
+ if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil {
+ t.Fatalf("reconcile: %v", err)
+ }
+ got, err := reg.Get(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil {
+ t.Fatalf("the console's record never materialized as a route — the sqid or key shapes disagree")
+ }
+ want := registry.Route{
+ Host: host, Slug: "acme", Kind: "instance",
+ Socket: provision.DefaultSocket(host), DBPath: provision.DefaultDBPath(dataDir, host),
+ App: "titogo", Account: sqid, Pool: "tito", Region: "ie", Channel: "stable",
+ Hibernate: true, Provisioned: true, Seed: `["seed"]`, Status: "ready",
+ CreatedAt: got.CreatedAt,
+ }
+ if *got != want {
+ t.Fatalf("minted route =\n %+v\nwant\n %+v", *got, want)
+ }
+
+ // And back the other way: the DELETE verb is what retires the route.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/"+host, token, ""); status != http.StatusOK {
+ t.Fatalf("delete = %d %s", status, out)
+ }
+ if err := provision.Reconcile(context.Background(), fx.rel, reg, cfg); err != nil {
+ t.Fatalf("reconcile after delete: %v", err)
+ }
+ if gone, err := reg.Get(host); err != nil {
+ t.Fatal(err)
+ } else if gone != nil {
+ t.Fatalf("the route survived its record's deletion: %+v", gone)
+ }
+ after, err := reg.Get(neighbour.Host)
+ if err != nil || after == nil {
+ t.Fatalf("the hand-added neighbour was removed by a pass that never minted it: %v", err)
+ }
+ if *after != *before {
+ t.Fatalf("the hand-added neighbour changed under reconciliation:\n got %+v\nwant %+v", *after, *before)
+ }
+}