domains: account ids become sequential, allocated by a create-only bucket write — nine-character sqids are unusable
The encoder is base-23, so a random 40-bit id encodes to a NINE-character sqid: kass.nyfbskdye.oncarlos.com. Nobody can read that out over a phone, type it without a typo, or recognise it as theirs. Sequential ids give bab, bac, bad…, growing one character per 23x accounts. This does not reintroduce the durability bug. That bug was the REGISTRY assigning ids in bucket-listing order, so a rebuild reassigned them. Durability comes from minted once, recorded in the manifest, materialized-never-assigned — unchanged here. Sequential is exactly as durable as random. It gives up nothing in secrecy either, and the old comments claiming otherwise were wrong: every platform hostname's certificate is published in Certificate Transparency within seconds of issue, so a sqid was never secret and making it unguessable protected nothing. Account existence stays hidden where it can actually be hidden — a non-member gets the same 404 as a nonexistent page. domains.MintAccount lists the manifests, takes max+1 (never 0, never 1 = ops), and claims it with a CREATE-ONLY Put. That is what makes an unlocked listing safe: two boxes minting at the same instant pick the same integer, exactly one write wins, and the loser re-lists and takes the next id rather than overwriting a live account's identity. Bounded to 5 attempts, then a loud failure rather than a spin. Tested by planting the competitor's manifest between the List and the Put. Minting now lives in internal/domains, next to the frozen encoder and the manifest-key parser both readers already share — so the console and the CLI use one allocator instead of two copies of a subtle CAS loop, and the wire shape (AccountManifest) has one definition instead of three. Consequence worth naming: the console's "new account" can no longer succeed while the bucket is unreachable, because the bucket IS the allocator. A console row whose manifest never landed would be an account that vanishes on the next rebuild, which is worse than a button that reports the outage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+412
−144
README.md+9 −7accounts.go+9 −49docs/superpowers/specs/2026-08-02-multi-account-design.md+24 −7e2e_test.go+5 −2internal/console/account.go+10 −42internal/console/account_test.go+26 −18internal/console/server.go+12 −12internal/console/server_account_test.go+6 −6internal/console/store.go+2 −1internal/console/store_test.go+11 −0internal/domains/mint.go+139 −0internal/domains/mint_test.go+159 −0
diff --git a/README.md b/README.md| index d31ca528a..4f5dd8f05 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -29,13 +29,15 @@ has members. Kass, Keymail and Woodstar each get their own rather than sharing |
| one flat list, and a self-hosted single-app deployment can give team A and |
| team B separate accounts that deploy independently. |
| -An account is `{id, sqid, name}`. The **id** is a random 40-bit integer minted |
| -by the console at creation and written to `accounts/<sqid>/account.json` in |
| -the deployment bucket; the registry only ever *materializes* what the bucket |
| -says, never assigns. That is what makes account identity survive a registry |
| -rebuild and be identical on every box — and, because the ids are random, |
| -sqids are not enumerable: you cannot walk `bab`, `bac`, `bad` to discover who |
| -else is on a deployment. The **sqid** is the frozen encoder over that id and |
| +An account is `{id, sqid, name}`. The **id** is the next free integer, |
| +allocated at creation by a create-only write of `accounts/<sqid>/account.json` |
| +in the deployment bucket; the registry only ever *materializes* what the |
| +bucket says, never assigns. That is what makes account identity survive a |
| +registry rebuild and be identical on every box: minted once, recorded in the |
| +bucket, never reassigned. Sequential ids keep sqids short enough to live in a |
| +hostname you can read out (`kass.bac.oncarlos.com`); they are not secret, and |
| +never were — every platform hostname's certificate is published in |
| +Certificate Transparency. The **sqid** is the frozen encoder over that id and |
| is the account's public identity, in every platform domain and every bucket |
| key. The **name** is free text and deliberately not unique: two accounts may |
| both be called "Blog", because identity is the sqid and there is nothing to |
diff --git a/accounts.go b/accounts.go| index 1f9b26ead..717f10761 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -13,8 +13,6 @@ package main |
| import ( |
| "context" |
| "crypto/ed25519" |
| - "crypto/rand" |
| - "encoding/binary" |
| "errors" |
| "flag" |
| "fmt" |
| @@ -31,7 +29,7 @@ import ( |
| const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| - create -name <name> mint an account: a random 40-bit id, its |
| + create -name <name> mint an account: the next free id, its |
| frozen sqid, and the bucket manifest every |
| box materializes identity from |
| list sqid, name, and app count |
| @@ -66,24 +64,6 @@ func RunAccounts(args []string) { |
| } |
| } |
| -// newAccountID mints an account id the same way the console does: 40 random |
| -// bits, never 0 or 1 (id 1 is the built-in ops account, pinned by registry |
| -// migration). Duplicated rather than shared because internal/console is the |
| -// console's own package and the CLI must not depend on it — the RULE, not the |
| -// code, is what has to match, and both are pinned by tests. |
| -func newAccountID() int64 { |
| - for { |
| - var b [8]byte |
| - if _, err := rand.Read(b[:]); err != nil { |
| - log.Fatalf("accounts: read randomness: %v", err) |
| - } |
| - id := int64(binary.BigEndian.Uint64(b[:]) & ((1 << 40) - 1)) |
| - if id > 1 { |
| - return id |
| - } |
| - } |
| -} |
| - |
| func runAccountsCreate(args []string) { |
| fs := flag.NewFlagSet("accounts create", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database") |
| @@ -92,14 +72,15 @@ func runAccountsCreate(args []string) { |
| if strings.TrimSpace(*name) == "" { |
| log.Fatal("usage: carlos accounts create -name <name>") |
| } |
| - id := newAccountID() |
| - sqid := domains.Sqid(id) |
| store := openStore() |
| - // The bucket first: identity lives there, and the registry only ever |
| - // materializes it. A registry row whose manifest never landed would be an |
| - // account that vanishes on the next rebuild. |
| - if err := writeAccountManifest(context.Background(), store, id, sqid, *name, time.Now()); err != nil { |
| - log.Fatalf("accounts create: write manifest: %v", err) |
| + // The bucket first, and not just for ordering: the manifest write IS the |
| + // allocator. domains.MintAccount picks the next free integer and claims it |
| + // with a create-only Put, so this CLI and a console minting at the same |
| + // moment cannot land on the same id — and the registry, which only ever |
| + // materializes what the bucket says, never has to assign anything. |
| + id, sqid, err := domains.MintAccount(context.Background(), store, *name, time.Now()) |
| + if err != nil { |
| + log.Fatalf("accounts create: %v", err) |
| } |
| r, err := registry.Open(*regPath) |
| if err != nil { |
| @@ -113,27 +94,6 @@ func runAccountsCreate(args []string) { |
| fmt.Printf("use it with: carlos ship -account %s -app <app> ...\n", sqid) |
| } |
| -// writeAccountManifest publishes an account's identity into the bucket at |
| -// accounts/<sqid>/account.json. Overwrite-safe (read etag, put, one retry), |
| -// the same shape the console's writer uses. |
| -func writeAccountManifest(ctx context.Context, s release.Store, id int64, sqid, name string, created time.Time) error { |
| - body := []byte(fmt.Sprintf(`{"id":%d,"sqid":%q,"name":%q,"created_at":%q}`, |
| - id, sqid, name, created.UTC().Format(time.RFC3339))) |
| - key := "accounts/" + sqid + "/account.json" |
| - put := func() error { |
| - etag := "" |
| - if _, cur, err := s.Get(ctx, key); err == nil { |
| - etag = cur |
| - } |
| - _, err := s.Put(ctx, key, body, etag) |
| - return err |
| - } |
| - if err := put(); err != nil { |
| - return put() // one retry on a lost race; a second loss surfaces |
| - } |
| - return nil |
| -} |
| - |
| func runAccountsList(args []string) { |
| fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| regPath := fs.String("registry", defaultRegistry(), "registry database") |
diff --git a/docs/superpowers/specs/2026-08-02-multi-account-design.md b/docs/superpowers/specs/2026-08-02-multi-account-design.md| index 35d935064..6cb753e36 100644 |
| --- a/docs/superpowers/specs/2026-08-02-multi-account-design.md |
| +++ b/docs/superpowers/specs/2026-08-02-multi-account-design.md |
| @@ -13,7 +13,8 @@ in §8 — which is what currently blocks enabling `PLATFORM_APPS_DOMAIN`. |
| An **account** is `{id, sqid, name, created_at}`: |
| -- **`id`** — a random 40-bit integer minted at creation. Not sequential. |
| +- **`id`** — the next free integer, allocated at creation by a create-only |
| + write into the bucket (§ "Durable identity"). Never a registry row number. |
| - **`sqid`** — the frozen encoder (`internal/domains`) over that id; the |
| account's public identity, appearing in every platform domain |
| (`kass.<sqid>.oncarlos.com`) and every bucket key. |
| @@ -34,7 +35,7 @@ inventing a permissions model before there is a second user to test it |
| against. Every account has at least one owner; the last owner cannot be |
| removed or demoted (transfer first). |
| -### Durable identity — why the console mints the id |
| +### Durable identity — why the id is minted into the bucket |
| Account identity must survive a registry rebuild and be identical on every |
| box in a deployment. Deriving it from an AUTOINCREMENT registry row id (the |
| @@ -43,18 +44,34 @@ bucket-listing order at first reconcile, so a rebuild — or a second box |
| materializing accounts in a different order — silently re-homes every |
| platform domain. |
| -Instead: **the console mints the random id at account creation and writes |
| -it into the bucket**, at `accounts/<sqid>/account.json`: |
| +Instead: **the id is minted once, into the bucket**, at |
| +`accounts/<sqid>/account.json`: |
| ```json |
| -{"id": 793481920157, "sqid": "kqmv", "name": "Kass", |
| +{"id": 3, "sqid": "bad", "name": "Kass", |
| "created_at": "2026-08-02T09:00:00Z"} |
| ``` |
| The registry only ever *materializes* what the bucket says — it never |
| assigns. The bucket is the control plane; identity belongs there like |
| -everything else. Randomness also means sqids are **not enumerable**: you |
| -cannot walk `bab`, `bac`, `bad` to discover who else is on a deployment. |
| +everything else. Durability comes from *minted once, recorded in the manifest, |
| +materialized-never-assigned* — not from how the number is chosen. |
| + |
| +**Sequential, not random** (`domains.MintAccount`): list the manifests, take |
| +the highest id, claim `max+1` with a **create-only** `Put`. Create-only is |
| +what makes the listing safe without a lock — two boxes minting at the same |
| +instant both pick the same integer, exactly one write wins, and the loser |
| +re-lists and takes the next one rather than overwriting a live account's |
| +identity. Retries are bounded; exhausting them fails loudly. |
| + |
| +Random ids were tried first and were a mistake: the encoder is base-23, so a |
| +random 40-bit id encodes to a **nine-character** sqid — |
| +`kass.nyfbskdye.oncarlos.com` — which nobody can read out, type, or recognise |
| +as theirs. Sequential gives `bab`, `bac`, `bad`…, growing one character per |
| +23× accounts. It gives up nothing: sqids are **not secret and never were**, |
| +because every platform hostname's certificate is published in Certificate |
| +Transparency within seconds of issue. Account existence is hidden where it can |
| +actually be hidden — a non-member gets the same 404 as a nonexistent page. |
| `ops` keeps the fixed id `1` (sqid `bab`), created by migration on every |
| deployment — so a console-less self-hoster is unaffected and every app |
diff --git a/e2e_test.go b/e2e_test.go| index 527d91c15..f74431d1e 100644 |
| --- a/e2e_test.go |
| +++ b/e2e_test.go |
| @@ -529,8 +529,11 @@ func TestMultiAccountEndToEnd(t *testing.T) { |
| t.Fatal(err) |
| } |
| - // Two accounts, minted the way the console does it: a random id, its |
| - // frozen sqid, and a bucket manifest the agent materializes from. |
| + // Two accounts, in the shape minting leaves behind: an id, its frozen |
| + // sqid, and a bucket manifest the agent materializes from. The ids are |
| + // hand-picked (and deliberately large) rather than minted here — this test |
| + // is about the agent reading manifests, and a fixture that depended on the |
| + // allocator would move every time the allocator did. |
| reg, err := registry.Open(filepath.Join(tmp, "registry.db")) |
| if err != nil { |
| t.Fatal(err) |
diff --git a/internal/console/account.go b/internal/console/account.go| index 61ea7b88f..e7529eeda 100644 |
| --- a/internal/console/account.go |
| +++ b/internal/console/account.go |
| @@ -1,14 +1,13 @@ |
| -// Account identity: minted here, written to the bucket, materialized by the |
| -// registry. The console is where an account is born — the id is a random |
| -// 40-bit integer, not a row number — because identity has to survive a |
| -// registry rebuild and be identical on every box in a deployment. See |
| +// Account identity: minted into the BUCKET (domains.MintAccount), then |
| +// materialized by the registry and by the console's own tables. The id is the |
| +// next free integer, allocated by a create-only manifest write rather than by |
| +// any local row number, because identity has to survive a registry rebuild and |
| +// be identical on every box in a deployment. See |
| // docs/superpowers/specs/2026-08-02-multi-account-design.md §1. |
| package console |
| import ( |
| "context" |
| - "crypto/rand" |
| - "encoding/binary" |
| "encoding/json" |
| "errors" |
| "os" |
| @@ -18,34 +17,6 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// accountIDBits is the width of a minted account id. 40 bits is ~1.1e12 |
| -// values: with the ~thousands of accounts a deployment plausibly holds, a |
| -// collision is negligible, while the sqid stays short enough to live |
| -// comfortably in a hostname label. |
| -const accountIDBits = 40 |
| - |
| -// NewAccountID mints an account id: 40 random bits, never 0 and never 1 (id 1 |
| -// is the built-in ops account on every deployment, pinned by registry |
| -// migration). Randomness is not just uniqueness — it makes sqids |
| -// non-enumerable, so nobody can walk bab, bac, bad to discover who else is on |
| -// a deployment. |
| -// |
| -// crypto/rand.Read cannot fail on any platform this runs on (Go 1.24 made it |
| -// infallible); a defensive retry loop would be untestable dead code, so a |
| -// draw of 0 or 1 simply redraws. |
| -func NewAccountID() int64 { |
| - for { |
| - var b [8]byte |
| - if _, err := rand.Read(b[:]); err != nil { |
| - continue |
| - } |
| - id := int64(binary.BigEndian.Uint64(b[:]) & ((1 << accountIDBits) - 1)) |
| - if id > 1 { |
| - return id |
| - } |
| - } |
| -} |
| - |
| // accountSqid is the account's public identity: the FROZEN encoder over its |
| // id (internal/domains). Wrapped here so every mint in the console goes |
| // through one place — the encoding is what every platform domain and every |
| @@ -54,17 +25,14 @@ func accountSqid(id int64) string { return domains.Sqid(id) } |
| // AccountManifestKey is where an account's identity lives in the bucket. The |
| // sqid is in the PATH as well as the body so the agent can list accounts |
| -// without opening every object. |
| -func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" } |
| +// without opening every object. Defined once, in internal/domains, and shared |
| +// by everything that touches the object: the console, the CLI and the agent's |
| +// reconcile pass. |
| +func AccountManifestKey(sqid string) string { return domains.AccountManifestKey(sqid) } |
| // accountManifest is the wire shape at that key. The agent's reconcile pass |
| // materializes exactly this and assigns nothing. |
| -type accountManifest struct { |
| - ID int64 `json:"id"` |
| - Sqid string `json:"sqid"` |
| - Name string `json:"name"` |
| - CreatedAt time.Time `json:"created_at"` |
| -} |
| +type accountManifest = domains.AccountManifest |
| // WriteAccountManifest publishes an account into the deployment bucket — the |
| // one place account identity crosses from the console to every box. The |
diff --git a/internal/console/account_test.go b/internal/console/account_test.go| index 8094256e9..06ca4b103 100644 |
| --- a/internal/console/account_test.go |
| +++ b/internal/console/account_test.go |
| @@ -50,8 +50,7 @@ func TestWriteAccountManifestRoundTrips(t *testing.T) { |
| // The sqid a minted account carries must be the frozen encoder over its id — |
| // nothing else, ever, or a platform domain stops resolving to its account. |
| func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
| - for i := 0; i < 50; i++ { |
| - id := NewAccountID() |
| + for id := int64(2); id < 60; id++ { |
| if got := domains.Sqid(id); got != domains.Sqid(id) || got == "" { |
| t.Fatalf("Sqid(%d) unstable/empty: %q", id, got) |
| } |
| @@ -61,25 +60,34 @@ func TestMintedSqidIsTheFrozenEncoding(t *testing.T) { |
| } |
| } |
| -// Account ids are minted by the CONSOLE (not the registry, not the bucket |
| -// listing order) so that identity survives a registry rebuild. Randomness is |
| -// load-bearing twice over: it stops two boxes materializing different ids for |
| -// the same account, and it makes sqids non-enumerable — you cannot walk |
| -// bab, bac, bad to discover who else is on a deployment. |
| -func TestNewAccountIDIsRandomAndNeverReserved(t *testing.T) { |
| - seen := map[int64]bool{} |
| - for i := 0; i < 200; i++ { |
| - id := NewAccountID() |
| - if id <= 1 { |
| - t.Fatalf("id %d collides with ops/zero", id) |
| +// Account ids are SEQUENTIAL, and allocated in the BUCKET |
| +// (domains.MintAccount) — not by the registry, not by a console row number. |
| +// Identity survives a registry rebuild for the reason it always did: minted |
| +// once, recorded in the manifest, materialized-never-assigned. What sequential |
| +// buys is a sqid a human can use: a 40-bit random id encoded to NINE |
| +// characters (kass.nyfbskdye.oncarlos.com), which nobody can read out over a |
| +// phone or type without a typo. It gives up nothing in secrecy, because sqids |
| +// were never secret — every platform hostname's certificate is published in |
| +// Certificate Transparency within seconds of issue. |
| +func TestConsoleMintsSequentialIdsIntoTheBucket(t *testing.T) { |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + var last int64 |
| + for i := 0; i < 3; i++ { |
| + id, sqid, err := domains.MintAccount(ctx, rel, "Account", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id <= domains.ReservedAccountID { |
| + t.Fatalf("minted id %d collides with ops/zero", id) |
| } |
| - if id > 1<<40 { |
| - t.Fatalf("id %d exceeds 40 bits", id) |
| + if last != 0 && id != last+1 { |
| + t.Errorf("minted %d after %d, want the next integer", id, last) |
| } |
| - if seen[id] { |
| - t.Fatalf("repeat within 200 draws: %d", id) |
| + if len(sqid) > 3 { |
| + t.Errorf("sqid %q is %d characters — the whole point is that it is short", sqid, len(sqid)) |
| } |
| - seen[id] = true |
| + last = id |
| } |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4b1401bc8..5b90a3b64 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -22,6 +22,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -367,23 +368,22 @@ func (s *Server) currentAccount(ctx context.Context, addr string) (string, error |
| return a.Sqid, nil |
| } |
| -// mintAccount creates an account owned by addr: a random id (never derived |
| -// from a row number), its frozen sqid, the console DB rows, and the bucket |
| -// manifest that every box materializes from. |
| +// mintAccount creates an account owned by addr: the next free id allocated in |
| +// the bucket, its frozen sqid, and the console DB rows. |
| // |
| -// The manifest write is best-effort by design — the account already exists in |
| -// the console, and a bucket hiccup must not lose it. The consequence of a |
| -// missed write is only that platform domains wait for the next successful |
| -// one, which the account page re-attempts. |
| +// The bucket write comes FIRST and is no longer best-effort, because it is now |
| +// the allocator: the id is claimed by a create-only manifest write, which is |
| +// what stops two boxes minting the same integer at the same moment. A console |
| +// row whose manifest never landed would be an account that vanishes on the |
| +// next rebuild — worse than a "new account" button that reports the outage. |
| func (s *Server) mintAccount(ctx context.Context, name, ownerAddr string) (*Account, error) { |
| - id := NewAccountID() |
| - sqid := accountSqid(id) |
| now := time.Now() |
| - if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil { |
| + id, sqid, err := domains.MintAccount(ctx, s.cfg.Releases, name, now) |
| + if err != nil { |
| return nil, err |
| } |
| - if err := WriteAccountManifest(ctx, s.cfg.Releases, id, sqid, name, now); err != nil { |
| - log.Printf("console: account manifest for %s: %v", sqid, err) |
| + if err := s.store.CreateAccount(id, sqid, name, ownerAddr, now); err != nil { |
| + return nil, err |
| } |
| if err := s.store.SetLastAccount(ownerAddr, id); err != nil { |
| log.Printf("console: set last account for %s: %v", ownerAddr, err) |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index b5815da21..d94213795 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -51,7 +51,7 @@ func TestLandingRedirectsToLastAccount(t *testing.T) { |
| t.Fatalf("account home = %d", st) |
| } |
| // A second account, visited after, becomes the MRU. |
| - id := NewAccountID() |
| + id := testAccountID() |
| sqid := accountSqid(id) |
| if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| @@ -175,11 +175,11 @@ func TestInviteAcceptCreatesMembership(t *testing.T) { |
| func TestSwitcherListsOnlyMyAccounts(t *testing.T) { |
| fx := newConsoleFixture(t) |
| // An account paul is not a member of. |
| - otherID := NewAccountID() |
| + otherID := testAccountID() |
| if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Someone Else", "sam@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| - mineID := NewAccountID() |
| + mineID := testAccountID() |
| if err := fx.st.CreateAccount(mineID, accountSqid(mineID), "Mine", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| @@ -204,7 +204,7 @@ func TestSwitcherListsOnlyMyAccounts(t *testing.T) { |
| func TestAuditIsScopedToTheAccount(t *testing.T) { |
| fx := newConsoleFixture(t) |
| now := time.Now() |
| - other := NewAccountID() |
| + other := testAccountID() |
| if err := fx.st.CreateAccount(other, accountSqid(other), "Other", "sam@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| @@ -265,7 +265,7 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - moved := NewAccountID() |
| + moved := testAccountID() |
| sqid := accountSqid(moved) |
| if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
| @@ -325,7 +325,7 @@ func TestSameAppNameInTwoAccounts(t *testing.T) { |
| signUp(t, fx, c, "code-1") |
| csrf := sessionToken(t, fx, c) |
| - id := NewAccountID() |
| + id := testAccountID() |
| sqid := accountSqid(id) |
| if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| t.Fatal(err) |
diff --git a/internal/console/store.go b/internal/console/store.go| index e5a994c2d..7fb7cb82f 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -128,7 +128,8 @@ func (s *Store) migrate() error { |
| );`, |
| // ---- multi-account ------------------------------------------------- |
| // The account is the tenancy primitive: it owns apps and has members. |
| - // id is MINTED (NewAccountID), never autoincrement — see account.go. |
| + // id is MINTED into the bucket (domains.MintAccount) and materialized |
| + // here, never autoincrement — see account.go. |
| // name is deliberately NOT unique: identity is the sqid, so there is |
| // nothing to squat. |
| `CREATE TABLE IF NOT EXISTS accounts ( |
diff --git a/internal/console/store_test.go b/internal/console/store_test.go| index ceb8d56f4..54b788bd4 100644 |
| --- a/internal/console/store_test.go |
| +++ b/internal/console/store_test.go |
| @@ -3,10 +3,21 @@ package console |
| import ( |
| "path/filepath" |
| "reflect" |
| + "sync/atomic" |
| "testing" |
| "time" |
| ) |
| +// testAccountID hands out distinct account ids for fixtures that just need an |
| +// account to exist. Real minting is a bucket operation now |
| +// (domains.MintAccount, which allocates the next free integer with a |
| +// create-only manifest write) and most of these tests have no bucket in play; |
| +// they only care that two fixture accounts differ. Starts well above the |
| +// built-in ops id and any hand-written literal in these tests. |
| +var testIDs atomic.Int64 |
| + |
| +func testAccountID() int64 { return 1000 + testIDs.Add(1) } |
| + |
| func openTestStore(t *testing.T) *Store { |
| t.Helper() |
| s, err := Open(filepath.Join(t.TempDir(), "console.db")) |
diff --git a/internal/domains/mint.go b/internal/domains/mint.go| new file mode 100644 |
| index 000000000..41ab52da8 |
| --- /dev/null |
| +++ b/internal/domains/mint.go |
| @@ -0,0 +1,139 @@ |
| +// Account identity is minted HERE, into the bucket, because the bucket is the |
| +// control plane: the console mints from the web surface, `carlos accounts |
| +// create` mints from a box with no console at all, and both must land on the |
| +// same allocator. The registry and the console DB only ever materialize what |
| +// the bucket says (Reconcile §1, console.MaterializeAccounts) — neither of |
| +// them assigns. |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// AccountManifest is the wire shape at accounts/<sqid>/account.json — the one |
| +// place account identity crosses between the console, the CLI and every box. |
| +// The sqid is in the PATH as well as the body so a reader can list accounts |
| +// without opening every object; the two must always agree, and both must equal |
| +// Sqid(id). |
| +type AccountManifest struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +// AccountManifestKey is where an account's identity lives in the bucket. |
| +func AccountManifestKey(sqid string) string { return "accounts/" + sqid + "/account.json" } |
| + |
| +// ReservedAccountID is the built-in ops account, pinned on every deployment |
| +// (console or not) so every app always has an account. Nothing else may ever |
| +// hold it, which is why minting starts at 2. |
| +const ReservedAccountID = 1 |
| + |
| +// mintAttempts bounds the create-only retry loop. A handful of losses means |
| +// another minter is genuinely racing us; a store that conflicts forever is a |
| +// broken deployment, and spinning on it hides that. |
| +const mintAttempts = 5 |
| + |
| +// NextAccountID is the id a new account should take: one past the highest any |
| +// manifest in the bucket claims, never 0 and never 1 (ops). |
| +// |
| +// Sequential, not random, and the reason is the sqid. The encoder is base-23 |
| +// over a 3-character floor, so a 40-bit random id encodes to NINE characters — |
| +// kass.nyfbskdye.oncarlos.com — a hostname nobody can read out over a phone, |
| +// type without a typo, or recognise as theirs. Sequential ids give bab, bac, |
| +// bad…, and length grows one character per 23x accounts, so a deployment is |
| +// years of growth away from a four-character sqid. |
| +// |
| +// This costs nothing in durability, which is the property the multi-account |
| +// spec exists for. The bug that made randomness look load-bearing was the |
| +// REGISTRY assigning ids in bucket-listing order, so a rebuild — or a second |
| +// box listing in a different order — silently re-homed every platform domain. |
| +// Durability comes from "minted once, recorded in the bucket manifest, |
| +// materialized-never-assigned", and that is unchanged: sequential ids are |
| +// exactly as durable as random ones. |
| +// |
| +// It buys nothing in secrecy either, and the old comments claiming otherwise |
| +// were wrong. Sqids appear in platform hostnames, and every certificate issued |
| +// for one is published in Certificate Transparency logs within seconds — |
| +// crt.sh will list them for anybody who asks. A sqid was never secret, so |
| +// making it unguessable protected nothing. Account existence is hidden where |
| +// it can actually be hidden: the console answers a non-member with the same |
| +// 404 a nonexistent page gets. |
| +// |
| +// A manifest that disagrees with itself still CONSUMES its id here (unlike |
| +// materialization, which skips it): handing that id to somebody else would put |
| +// two objects on one identity, which is the single thing this scheme exists to |
| +// prevent. |
| +func NextAccountID(ctx context.Context, s release.Store) (int64, error) { |
| + keys, err := s.List(ctx, "accounts/") |
| + if err != nil { |
| + return 0, fmt.Errorf("domains: list accounts: %w", err) |
| + } |
| + max := int64(ReservedAccountID) |
| + for _, key := range keys { |
| + if _, ok := AccountManifestSqid(key); !ok { |
| + continue |
| + } |
| + body, _, err := s.Get(ctx, key) |
| + if err != nil { |
| + // Unreadable is not "free": an id we cannot see is an id we must |
| + // not reuse, so a read failure fails the mint rather than risking |
| + // a collision. |
| + return 0, fmt.Errorf("domains: read %s: %w", key, err) |
| + } |
| + var m AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + continue // unparseable: claims no id we can honour |
| + } |
| + if m.ID > max { |
| + max = m.ID |
| + } |
| + } |
| + return max + 1, nil |
| +} |
| + |
| +// MintAccount allocates the next free account id and publishes its manifest, |
| +// returning the id and its frozen sqid. |
| +// |
| +// The listing NextAccountID does is not a lock, so the write is CREATE-ONLY |
| +// (ifMatch ""): two boxes minting at the same moment both pick the same |
| +// integer, and exactly one Put wins. The loser re-lists and takes the next id |
| +// rather than overwriting — an overwrite would erase a live account's identity |
| +// and re-home every platform domain it owns. |
| +// |
| +// The manifest lands BEFORE any local row exists, everywhere this is called, |
| +// and that ordering is deliberate: a local row whose manifest never landed is |
| +// an account that vanishes on the next rebuild, while a manifest with no local |
| +// row is materialized back on the next pass. |
| +func MintAccount(ctx context.Context, s release.Store, name string, created time.Time) (int64, string, error) { |
| + for attempt := 0; attempt < mintAttempts; attempt++ { |
| + id, err := NextAccountID(ctx, s) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + sqid := Sqid(id) |
| + body, err := json.Marshal(AccountManifest{ID: id, Sqid: sqid, Name: name, CreatedAt: created.UTC()}) |
| + if err != nil { |
| + return 0, "", err |
| + } |
| + _, err = s.Put(ctx, AccountManifestKey(sqid), body, "") |
| + if err == nil { |
| + return id, sqid, nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return 0, "", fmt.Errorf("domains: write account manifest: %w", err) |
| + } |
| + // Somebody else took this id between our listing and our write. Re-list |
| + // and try the next one. |
| + } |
| + return 0, "", fmt.Errorf("domains: could not mint an account id in %d attempts — "+ |
| + "another minter keeps taking the next free id, or the deployment bucket is rejecting "+ |
| + "create-only writes", mintAttempts) |
| +} |
diff --git a/internal/domains/mint_test.go b/internal/domains/mint_test.go| new file mode 100644 |
| index 000000000..b27ed74c2 |
| --- /dev/null |
| +++ b/internal/domains/mint_test.go |
| @@ -0,0 +1,159 @@ |
| +package domains_test |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Account ids are SEQUENTIAL, and the sqid length is why: the frozen encoder |
| +// is base-23, so a 40-bit id encodes to nine characters — |
| +// kass.nyfbskdye.oncarlos.com — which nobody can read out, type, or trust. |
| +// Sequential ids give bab, bac, bad…, growing a character every 23x. |
| +func TestNextAccountIDIsTheNextFreeInteger(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + |
| + // An empty bucket starts at 2: 0 is not an id and 1 is reserved for the |
| + // built-in ops account on every deployment. |
| + got, err := domains.NextAccountID(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got != 2 { |
| + t.Errorf("NextAccountID(empty) = %d, want 2", got) |
| + } |
| + if len(domains.Sqid(got)) > 3 { |
| + t.Errorf("Sqid(%d) = %q — sequential ids exist to keep this short", got, domains.Sqid(got)) |
| + } |
| + |
| + for _, id := range []int64{2, 7} { |
| + writeManifest(t, s, id, domains.Sqid(id)) |
| + } |
| + if got, err := domains.NextAccountID(ctx, s); err != nil || got != 8 { |
| + t.Errorf("NextAccountID = %d (%v), want 8 — one past the highest claimed", got, err) |
| + } |
| +} |
| + |
| +// A manifest that disagrees with itself still CONSUMES its id. It is skipped |
| +// for materialization (it is corruption, not an account), but handing its id |
| +// to somebody else would make two objects claim one identity — the one thing |
| +// the whole minting scheme exists to prevent. |
| +func TestNextAccountIDCountsIdsItWouldNotMaterialize(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, "accounts/wrong/account.json", |
| + []byte(`{"id":9,"sqid":"wrong","name":"Corrupt"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, "accounts/junk/account.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := domains.NextAccountID(ctx, s); err != nil || got != 10 { |
| + t.Errorf("NextAccountID = %d (%v), want 10 — a claimed id is claimed", got, err) |
| + } |
| +} |
| + |
| +// Two boxes minting at once must never agree on an id. The listing that picks |
| +// max+1 is not a lock, so the manifest write is CREATE-ONLY: the loser gets a |
| +// CAS conflict, re-lists, and takes the next one. Overwriting would erase an |
| +// account's identity and re-home every domain it owns. |
| +func TestMintAccountLosesTheRaceAndTakesTheNextID(t *testing.T) { |
| + ctx := t.Context() |
| + inner := release.NewDirStore(t.TempDir()) |
| + planted := false |
| + s := &racingStore{Store: inner, beforePut: func(key string) { |
| + if planted { |
| + return |
| + } |
| + planted = true |
| + // Another minter got there first, between our List and our Put. |
| + writeManifest(t, inner, 2, domains.Sqid(2)) |
| + }} |
| + |
| + id, sqid, err := domains.MintAccount(ctx, s, "Kass", time.Unix(1700000000, 0)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != 3 || sqid != domains.Sqid(3) { |
| + t.Errorf("minted (%d, %q), want the next free id 3 (%q)", id, sqid, domains.Sqid(3)) |
| + } |
| + // The competitor's manifest is untouched. |
| + body, _, err := inner.Get(ctx, "accounts/"+domains.Sqid(2)+"/account.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m struct { |
| + Name string `json:"name"` |
| + } |
| + if err := json.Unmarshal(body, &m); err != nil || m.Name != "Competitor" { |
| + t.Errorf("the racing minter's manifest was overwritten: %s", body) |
| + } |
| + // And ours landed, with the id in the body matching the sqid in the path. |
| + body, _, err = inner.Get(ctx, "accounts/"+sqid+"/account.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var mine struct { |
| + ID int64 `json:"id"` |
| + Sqid string `json:"sqid"` |
| + Name string `json:"name"` |
| + } |
| + if err := json.Unmarshal(body, &mine); err != nil { |
| + t.Fatal(err) |
| + } |
| + if mine.ID != id || mine.Sqid != sqid || mine.Name != "Kass" { |
| + t.Errorf("minted manifest = %+v, want id %d sqid %q", mine, id, sqid) |
| + } |
| +} |
| + |
| +// Retries are BOUNDED. A store that conflicts forever is a broken deployment, |
| +// not something to spin on: fail loudly rather than loop. |
| +func TestMintAccountGivesUpLoudly(t *testing.T) { |
| + ctx := t.Context() |
| + inner := release.NewDirStore(t.TempDir()) |
| + next := int64(2) |
| + s := &racingStore{Store: inner, beforePut: func(key string) { |
| + writeManifest(t, inner, next, domains.Sqid(next)) |
| + next++ |
| + }} |
| + _, _, err := domains.MintAccount(ctx, s, "Kass", time.Now()) |
| + if err == nil { |
| + t.Fatal("minting against a permanently racing store succeeded") |
| + } |
| + if !strings.Contains(err.Error(), "account") { |
| + t.Errorf("give-up error does not say what failed: %v", err) |
| + } |
| +} |
| + |
| +func writeManifest(t *testing.T, s release.Store, id int64, sqid string) { |
| + t.Helper() |
| + body := []byte(`{"id":` + itoa(id) + `,"sqid":"` + sqid + `","name":"Competitor",` + |
| + `"created_at":"2026-08-02T09:00:00Z"}`) |
| + if _, err := s.Put(context.Background(), "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func itoa(i int64) string { |
| + b, _ := json.Marshal(i) |
| + return string(b) |
| +} |
| + |
| +// racingStore runs a hook immediately before each Put, so a test can plant the |
| +// object the minter is about to create — the one interleaving that matters and |
| +// the one a sequential allocator has to survive. |
| +type racingStore struct { |
| + release.Store |
| + beforePut func(key string) |
| +} |
| + |
| +func (r *racingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + r.beforePut(key) |
| + return r.Store.Put(ctx, key, body, ifMatch) |
| +} |