registry: accounts carry their sqid — the bucket assigns, the registry materializes
Account identity must survive a registry rebuild and be identical on every box. The old shape derived it from an AUTOINCREMENT row id assigned in bucket-listing order, so a rebuild silently re-homed every platform domain. The accounts table now takes its id from the caller (PutAccount, an upsert BY id), carries the frozen sqid alongside, and drops UNIQUE(name) — names are deliberately not unique, identity is the sqid. The legacy table is dropped and rebuilt rather than ALTERed: it is a pure materialization of bucket manifests plus the pinned ops row, so there is nothing to lose, and its rows are exactly the assigned ids this change exists to stop trusting. EnsureAccount/AccountID are gone (they assigned). internal/domains keeps compiling on a name-keyed lookup that refuses unknown accounts rather than minting under a guessed identity; task 4 replaces it with the bucket's own accounts/<sqid>/account.json manifests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+248
−58
e2e_test.go+9 −5internal/domains/reconcile.go+21 −8internal/domains/reconcile_test.go+15 −7internal/registry/registry.go+125 −24internal/registry/registry_test.go+78 −14
diff --git a/e2e_test.go b/e2e_test.go| index 9976151a1..c7d8094f2 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -385,10 +385,14 @@ func TestEndToEndSmoke(t *testing.T) { |
| // (5) Domains: an account manifest in the bucket + one reconcile pass |
| // mints the app's platform domain, and the edge serves it by Host header |
| - // over the same echo backend — every app is born addressable. The |
| - // manifest names the owner's address as the account (the console's claim |
| - // shape); its id lands after the built-in ops (id 1), so the sqid is the |
| - // frozen Sqid(2) = "bac". |
| + // over the same echo backend — every app is born addressable. The account |
| + // id is MATERIALIZED, never assigned (multi-account spec §1): the id comes |
| + // from outside the registry, and its sqid is the frozen encoder over it. |
| + const acctID = int64(793481920157) |
| + acctSqid := domains.Sqid(acctID) |
| + if err := reg.PutAccount(acctID, acctSqid, "paul@keymail.dev"); err != nil { |
| + t.Fatalf("materialize account: %v", err) |
| + } |
| if _, err := store.Put(ctx, "apps/echoapp/account.json", |
| []byte(`{"account":"paul@keymail.dev","owner":"paul@keymail.dev"}`), ""); err != nil { |
| t.Fatalf("write account manifest: %v", err) |
| @@ -401,7 +405,7 @@ func TestEndToEndSmoke(t *testing.T) { |
| }); err != nil { |
| t.Fatalf("domains.Reconcile: %v", err) |
| } |
| - const platformHost = "echoapp.bac.oncarlos.test" |
| + platformHost := "echoapp." + acctSqid + ".oncarlos.test" |
| prt, err := reg.Get(platformHost) |
| if err != nil { |
| t.Fatalf("get platform route: %v", err) |
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go| index a514e9e3e..acfb3cf01 100644 |
| --- a/internal/domains/reconcile.go |
| +++ b/internal/domains/reconcile.go |
| @@ -87,10 +87,12 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| log.Printf("domains: skipping %s: malformed manifest: %v", key, err) |
| continue |
| } |
| - if _, err := reg.EnsureAccount(m.Account); err != nil { |
| - log.Printf("domains: ensure account %q: %v", m.Account, err) |
| - continue |
| - } |
| + // NOTE (multi-account, task 1 of 8): the registry no longer assigns |
| + // account ids, so a manifest naming an account by NAME can no longer |
| + // materialize one — the id has to come from the bucket. Until task 4 |
| + // replaces this whole block with the accounts/<sqid>/account.json |
| + // reader, a named account is only usable if it is already materialized; |
| + // anything else falls through to ops. |
| accountOf[app] = m.Account |
| for i := range routes { |
| if routes[i].App != app || routes[i].Account == m.Account { |
| @@ -346,14 +348,25 @@ func appAccount(routes []registry.Route, accountOf map[string]string, app string |
| return "ops" |
| } |
| -// accountSqid resolves an account name to its frozen sqid, creating the |
| -// account row if needed (idempotent — the row is the sqid's anchor). |
| +// accountSqid resolves an account NAME to its frozen sqid by looking it up |
| +// among the accounts the bucket has already materialized. It never creates a |
| +// row: ids belong to the bucket (spec §1). An unknown name is an error, so |
| +// nothing ever mints under a guessed identity. |
| +// |
| +// NOTE (multi-account, task 1 of 8): task 4 replaces name-keyed lookup |
| +// entirely — the app→account linkage becomes the bucket key path, which |
| +// carries the sqid directly. |
| func accountSqid(reg *registry.Registry, name string) (string, error) { |
| - id, err := reg.EnsureAccount(name) |
| + accts, err := reg.Accounts() |
| if err != nil { |
| return "", err |
| } |
| - return Sqid(id), nil |
| + for _, a := range accts { |
| + if a.Name == name { |
| + return a.Sqid, nil |
| + } |
| + } |
| + return "", fmt.Errorf("no account named %q is materialized", name) |
| } |
| // firstReadyUpstream picks the deterministic upstream a minted domain |
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go| index 4276d8625..56dc37699 100644 |
| --- a/internal/domains/reconcile_test.go |
| +++ b/internal/domains/reconcile_test.go |
| @@ -106,17 +106,25 @@ func TestReconcileMintsPlatformDomainUnderOps(t *testing.T) { |
| } |
| } |
| -func TestReconcileUsesManifestAccount(t *testing.T) { |
| +// The registry no longer invents account ids (multi-account spec §1: the |
| +// bucket assigns, the registry materializes), so a manifest naming an account |
| +// the bucket has never described mints NOTHING — a guessed sqid would be a |
| +// platform domain nobody can ever reproduce after a rebuild. The source route |
| +// is still stamped with what the manifest says. |
| +// |
| +// NOTE (task 1 of 8): task 4 replaces name-keyed manifests entirely with |
| +// accounts/<sqid>/account.json + key-path linkage; this test goes with them. |
| +func TestReconcileWillNotMintUnderAnUnmaterializedAccount(t *testing.T) { |
| s, reg := dirStore(t), regWith(t, registry.Route{Host: "kass.training", Kind: "instance", |
| App: "kass", Status: "ready", Addr: "127.0.0.1:9000"}) |
| manifest(t, s, "kass", "paul", "paul@keymail.dev") |
| if err := domains.Reconcile(ctx, s, reg, cfg(t)); err != nil { |
| t.Fatal(err) |
| } |
| - // paul is the first ensured account after ops: id 2 → frozen sqid "bac". |
| - got := mustGet(t, reg, "kass.bac.oncarlos.test") |
| - if !got.Platform || got.Account != "paul" { |
| - t.Errorf("minted route = %+v", got) |
| + for _, host := range []string{"kass.bab.oncarlos.test", "kass.bac.oncarlos.test"} { |
| + if rt, err := reg.Get(host); err != nil || rt != nil { |
| + t.Errorf("minted %s under a guessed identity: %+v %v", host, rt, err) |
| + } |
| } |
| if src := mustGet(t, reg, "kass.training"); src.Account != "paul" { |
| t.Errorf("source route account not stamped from manifest: %+v", src) |
| @@ -404,8 +412,8 @@ func TestReconcileInertWithoutAppsDomain(t *testing.T) { |
| if !reflect.DeepEqual(before, after) { |
| t.Errorf("unconfigured Reconcile changed the registry:\nbefore %+v\nafter %+v", before, after) |
| } |
| - if id, _ := reg.AccountID("paul"); id != 0 { |
| - t.Error("unconfigured Reconcile read a manifest and minted an account") |
| + if accts, _ := reg.Accounts(); len(accts) != 1 { |
| + t.Errorf("unconfigured Reconcile materialized accounts: %+v (want only ops)", accts) |
| } |
| } |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index e4365680a..1a022aae0 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -112,6 +112,12 @@ func Open(path string) (*Registry, error) { |
| // Path returns the file the registry was opened from. |
| func (r *Registry) Path() string { return r.path } |
| +// Close releases the long-lived handle. Long-running processes (edge, agent, |
| +// console) deliberately never call this — they hold one handle for the |
| +// process lifetime — but a test that rebuilds a registry file from scratch |
| +// must be able to let go of it first. |
| +func (r *Registry) Close() error { return r.sql.Close() } |
| + |
| // migrate is additive (CREATE TABLE IF NOT EXISTS), so deploying new code |
| // over an existing registry is always safe. New columns go in the ALTER list — |
| // the "duplicate column" error is swallowed so the list stays idempotent. |
| @@ -153,47 +159,142 @@ CREATE TABLE IF NOT EXISTS routes ( |
| return err |
| } |
| } |
| - // The accounts table: which account each app belongs to, for platform |
| - // domain sqids. Account #1 is "ops" — the deployment operator's built-in |
| - // account, created by migration with a PINNED id so its sqid is identical |
| - // on every deployment ever bootstrapped. INSERT OR IGNORE keeps re-runs |
| - // (every Open migrates) idempotent. |
| + return r.migrateAccounts() |
| +} |
| + |
| +// migrateAccounts brings the accounts table to its multi-account shape: |
| +// |
| +// id INTEGER PRIMARY KEY (supplied by the caller, NOT autoincrement) |
| +// sqid TEXT (the frozen encoder over that id — the account's public identity) |
| +// name TEXT (free text, deliberately NOT unique — see the spec: identity is |
| +// the sqid, so there is nothing to squat) |
| +// |
| +// The pre-multi-account shape was `(id INTEGER PRIMARY KEY AUTOINCREMENT, |
| +// name TEXT UNIQUE NOT NULL)`, and its rows are exactly the bug this change |
| +// fixes: ids assigned in bucket-listing order, so a rebuild re-homed every |
| +// platform domain. That table is also a pure MATERIALIZATION — every row |
| +// except the pinned ops row is re-derived from the bucket's own |
| +// accounts/<sqid>/account.json manifests on the next reconcile pass, and it |
| +// holds no truth of its own. So the legacy shape is dropped and rebuilt |
| +// rather than ALTERed: carrying the rows forward would preserve wrong ids, |
| +// and carrying the UNIQUE(name) constraint forward would refuse two accounts |
| +// that are both legitimately called "Blog". This is the one deliberate |
| +// exception to the additive-only migration rule, and it is safe precisely |
| +// because there is nothing here to lose. |
| +func (r *Registry) migrateAccounts() error { |
| + legacy, err := r.accountsTableIsLegacy() |
| + if err != nil { |
| + return err |
| + } |
| + if legacy { |
| + if _, err := r.sql.Exec(`DROP TABLE accounts`); err != nil { |
| + return err |
| + } |
| + } |
| if _, err := r.sql.Exec(` |
| CREATE TABLE IF NOT EXISTS accounts ( |
| - id INTEGER PRIMARY KEY AUTOINCREMENT, |
| - name TEXT UNIQUE NOT NULL |
| + id INTEGER PRIMARY KEY, |
| + sqid TEXT NOT NULL DEFAULT '', |
| + name TEXT NOT NULL DEFAULT '' |
| );`); err != nil { |
| return err |
| } |
| - if _, err := r.sql.Exec(`INSERT OR IGNORE INTO accounts (id, name) VALUES (1, 'ops')`); err != nil { |
| + // PARTIAL unique index: real sqids are unique, but a row that predates the |
| + // sqid column (sqid '') must not collide with another such row — an |
| + // unmigrated registry has to open before it can be re-materialized. |
| + if _, err := r.sql.Exec( |
| + `CREATE UNIQUE INDEX IF NOT EXISTS accounts_sqid ON accounts (sqid) WHERE sqid != ''`); err != nil { |
| return err |
| } |
| - return nil |
| + // Account #1 is "ops" — the deployment operator's built-in account, created |
| + // by migration with a PINNED id so its sqid ("bab") is identical on every |
| + // deployment ever bootstrapped. INSERT OR IGNORE keeps re-runs (every Open |
| + // migrates) idempotent, and never overwrites a rename. |
| + _, err = r.sql.Exec(`INSERT OR IGNORE INTO accounts (id, sqid, name) VALUES (1, 'bab', 'ops')`) |
| + return err |
| } |
| -// EnsureAccount returns the id for the named account, creating it if absent — |
| -// idempotent upsert-by-name (the reconcile pass calls this every tick for |
| -// every manifest it sees, so re-ensuring must never mint duplicate rows or |
| -// churn ids). |
| -func (r *Registry) EnsureAccount(name string) (int64, error) { |
| - if _, err := r.sql.Exec(`INSERT OR IGNORE INTO accounts (name) VALUES (?)`, name); err != nil { |
| - return 0, err |
| +// accountsTableIsLegacy reports whether an accounts table exists WITHOUT the |
| +// sqid column — i.e. the pre-multi-account shape. |
| +func (r *Registry) accountsTableIsLegacy() (bool, error) { |
| + rows, err := r.sql.Query(`PRAGMA table_info(accounts)`) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer rows.Close() |
| + exists, hasSqid := false, false |
| + for rows.Next() { |
| + var cid int |
| + var name, typ string |
| + var notNull int |
| + var dflt sql.NullString |
| + var pk int |
| + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { |
| + return false, err |
| + } |
| + exists = true |
| + if name == "sqid" { |
| + hasSqid = true |
| + } |
| } |
| - return r.AccountID(name) |
| + if err := rows.Err(); err != nil { |
| + return false, err |
| + } |
| + return exists && !hasSqid, nil |
| +} |
| + |
| +// Account is one tenancy root: a console-minted id, its frozen sqid (the |
| +// public identity in every platform domain and every bucket key), and a |
| +// human name that is deliberately not unique. |
| +type Account struct { |
| + ID int64 |
| + Sqid string |
| + Name string |
| } |
| -// AccountID returns the id for the named account, or 0, nil when absent — |
| -// "no such account" is an answer, not an error. |
| -func (r *Registry) AccountID(name string) (int64, error) { |
| +// PutAccount materializes an account the BUCKET already decided on — |
| +// idempotent upsert BY ID. The registry never assigns an account id: the id |
| +// arrives from accounts/<sqid>/account.json (or, for ops, from migration), |
| +// which is what makes account identity survive a registry rebuild and be |
| +// identical on every box in a deployment. |
| +func (r *Registry) PutAccount(id int64, sqid, name string) error { |
| + _, err := r.sql.Exec(`INSERT INTO accounts (id, sqid, name) VALUES (?, ?, ?) |
| +ON CONFLICT(id) DO UPDATE SET sqid = excluded.sqid, name = excluded.name`, id, sqid, name) |
| + return err |
| +} |
| + |
| +// AccountBySqid resolves an account's public identity to its id and name, or |
| +// (0, "", nil) when absent — "no such account" is an answer, not an error. |
| +func (r *Registry) AccountBySqid(sqid string) (int64, string, error) { |
| var id int64 |
| - err := r.sql.QueryRow(`SELECT id FROM accounts WHERE name = ?`, name).Scan(&id) |
| + var name string |
| + err := r.sql.QueryRow(`SELECT id, name FROM accounts WHERE sqid = ?`, sqid).Scan(&id, &name) |
| if err == sql.ErrNoRows { |
| - return 0, nil |
| + return 0, "", nil |
| } |
| if err != nil { |
| - return 0, err |
| + return 0, "", err |
| } |
| - return id, nil |
| + return id, name, nil |
| +} |
| + |
| +// Accounts returns every materialized account, ordered by id (so ops is |
| +// always first). |
| +func (r *Registry) Accounts() ([]Account, error) { |
| + rows, err := r.sql.Query(`SELECT id, sqid, name FROM accounts ORDER BY id`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Account |
| + for rows.Next() { |
| + var a Account |
| + if err := rows.Scan(&a.ID, &a.Sqid, &a.Name); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| } |
| const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path, |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index 1d4029882..a633d2419 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -1,6 +1,7 @@ |
| package registry |
| import ( |
| + "os" |
| "path/filepath" |
| "testing" |
| ) |
| @@ -208,24 +209,87 @@ func openTestRegistry(t *testing.T) *Registry { |
| return reg |
| } |
| -// The ops account is the migration's own row — id 1, always, on every |
| -// deployment — so platform-domain sqids for CLI-added apps are stable without |
| -// any console ever existing. EnsureAccount must be an idempotent |
| -// upsert-by-name that never collides with it. |
| -func TestOpsAccountExistsAndEnsureIsIdempotent(t *testing.T) { |
| +// The durability property this whole multi-account spec exists for: the id |
| +// is minted OUTSIDE the registry (by the console, into the bucket), so a |
| +// registry rebuilt from scratch re-materializes the SAME id — and therefore |
| +// the same sqid, and therefore the same platform domains. The old |
| +// AUTOINCREMENT+EnsureAccount shape failed exactly here: ids were assigned in |
| +// bucket-listing order, so a rebuild silently re-homed every platform domain. |
| +func TestAccountsMaterializeIdenticallyAfterRebuild(t *testing.T) { |
| + dir := t.TempDir() |
| + r1, err := Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r1.PutAccount(793481920157, "kqmv", "Kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + r1.Close() |
| + // Rebuild from scratch — the id comes from the bucket, so it must survive. |
| + os.Remove(filepath.Join(dir, "registry.db")) |
| + r2, err := Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r2.Close() |
| + if err := r2.PutAccount(793481920157, "kqmv", "Kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + id, name, err := r2.AccountBySqid("kqmv") |
| + if err != nil || id != 793481920157 || name != "Kass" { |
| + t.Fatalf("rebuild lost identity: id=%d name=%q err=%v", id, name, err) |
| + } |
| +} |
| + |
| +// The ops account is the migration's own row — id 1, sqid "bab", always, on |
| +// every deployment — so platform-domain sqids for CLI-added apps are stable |
| +// without any console ever existing. |
| +func TestOpsAccountIsAlwaysOne(t *testing.T) { |
| reg := openTestRegistry(t) |
| - id, err := reg.AccountID("ops") |
| - if err != nil || id != 1 { |
| - t.Fatalf("ops account: id=%d err=%v, want 1", id, err) |
| + id, name, err := reg.AccountBySqid("bab") |
| + if err != nil || id != 1 || name != "ops" { |
| + t.Fatalf("ops: id=%d name=%q err=%v, want 1/ops", id, name, err) |
| } |
| // Absent is 0, nil — not an error: "no such account" is an answer. |
| - if id, err := reg.AccountID("nobody"); err != nil || id != 0 { |
| - t.Fatalf("AccountID(absent) = %d, %v; want 0, nil", id, err) |
| + if id, _, err := reg.AccountBySqid("nosuch"); err != nil || id != 0 { |
| + t.Fatalf("AccountBySqid(absent) = %d, %v; want 0, nil", id, err) |
| + } |
| +} |
| + |
| +// PutAccount is an upsert BY ID: re-materializing the same manifest is a |
| +// no-op, and a rename in the bucket lands without minting a second row. |
| +func TestPutAccountIdempotentAndRenames(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.PutAccount(42, "cdef", "Old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.PutAccount(42, "cdef", "New"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, name, _ := reg.AccountBySqid("cdef") |
| + if name != "New" { |
| + t.Errorf("rename not applied: %q", name) |
| + } |
| + accts, _ := reg.Accounts() |
| + if len(accts) != 2 { // ops + this one, no duplicate row |
| + t.Errorf("want ops + one, got %d: %+v", len(accts), accts) |
| + } |
| +} |
| + |
| +// Account names are deliberately NOT unique (spec §1): identity is the sqid, |
| +// so there is nothing to squat. A leftover UNIQUE(name) from the pre-sqid |
| +// accounts table would turn two teams both calling their account "Blog" into |
| +// a materialization failure. |
| +func TestAccountNamesNeedNotBeUnique(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.PutAccount(42, "cdef", "Blog"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.PutAccount(43, "cdeg", "Blog"); err != nil { |
| + t.Fatalf("two accounts named Blog must both materialize: %v", err) |
| } |
| - a, _ := reg.EnsureAccount("paul") |
| - b, _ := reg.EnsureAccount("paul") |
| - if a != b || a == 1 { |
| - t.Errorf("EnsureAccount not idempotent or collided with ops: %d %d", a, b) |
| + if id, _, _ := reg.AccountBySqid("cdeg"); id != 43 { |
| + t.Errorf("second Blog lost: id=%d", id) |
| } |
| } |