reserved-sqids-285
Squashed from reserved-sqids-285 into main.
8 files changed,
+644
−11
auth_e2e_test.go+62 −10internal/console/materialize.go+9 −0internal/console/reserved_sqids_test.go+140 −0internal/domains/mint.go+8 −1internal/domains/reserved.go+76 −0internal/domains/reserved_test.go+340 −0internal/provision/plan14_root_reads_test.go+6 −0internal/provision/testdata/plan14-root-reads-reserved285.tsv+3 −0
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.012s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 352.804s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.141s
ok github.com/carlosframework/platform/cmd/moveprobe 93.282s
ok github.com/carlosframework/platform/cmd/zddprobe 0.709s
ok github.com/carlosframework/platform/internal/activator 234.346s
ok github.com/carlosframework/platform/internal/agentsock 0.016s
ok github.com/carlosframework/platform/internal/alerting 0.004s
ok github.com/carlosframework/platform/internal/analytics 0.023s
ok github.com/carlosframework/platform/internal/appconfig 2.336s
ok github.com/carlosframework/platform/internal/apperr 0.071s
ok github.com/carlosframework/platform/internal/applog 0.135s
ok github.com/carlosframework/platform/internal/awsinv 0.004s
ok github.com/carlosframework/platform/internal/boxchoice 0.004s
ok github.com/carlosframework/platform/internal/boxid 0.869s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.011s
ok github.com/carlosframework/platform/internal/caps 0.012s
ok github.com/carlosframework/platform/internal/certwarm 0.002s
ok github.com/carlosframework/platform/internal/cgroup 0.704s
ok github.com/carlosframework/platform/internal/changelog 0.306s
ok github.com/carlosframework/platform/internal/clamd 0.055s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 367.513s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.488s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.009s
ok github.com/carlosframework/platform/internal/dbverify 0.004s
ok github.com/carlosframework/platform/internal/dnsrec 0.002s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.242s
ok github.com/carlosframework/platform/internal/doorkey 0.006s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 25.987s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.008s
ok github.com/carlosframework/platform/internal/fleetapi 0.123s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.019s
ok github.com/carlosframework/platform/internal/geoip 0.029s
ok github.com/carlosframework/platform/internal/host 127.582s
ok github.com/carlosframework/platform/internal/hostactors 0.293s
ok github.com/carlosframework/platform/internal/hostbox 2.220s
ok github.com/carlosframework/platform/internal/hostclaim 0.521s
ok github.com/carlosframework/platform/internal/hostevidence 0.015s
ok github.com/carlosframework/platform/internal/hostexec 4.865s
ok github.com/carlosframework/platform/internal/hostfence 0.038s
ok github.com/carlosframework/platform/internal/hostfiles 10.507s
ok github.com/carlosframework/platform/internal/hostfloor 0.015s
ok github.com/carlosframework/platform/internal/hostguard 0.993s
ok github.com/carlosframework/platform/internal/hostops 1.441s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.016s
ok github.com/carlosframework/platform/internal/hostquarantine 0.016s
ok github.com/carlosframework/platform/internal/hostrelocate 0.462s
ok github.com/carlosframework/platform/internal/hostresolve 0.153s
ok github.com/carlosframework/platform/internal/hostwrite 0.017s
ok github.com/carlosframework/platform/internal/infraview 0.016s
ok github.com/carlosframework/platform/internal/instancectl 1.817s
ok github.com/carlosframework/platform/internal/instanceexec 2.702s
ok github.com/carlosframework/platform/internal/instancefs 0.020s
ok github.com/carlosframework/platform/internal/instancekey 0.019s
ok github.com/carlosframework/platform/internal/instanceupdates 0.025s
ok github.com/carlosframework/platform/internal/layout 1.438s
ok github.com/carlosframework/platform/internal/lease 9.453s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.005s
ok github.com/carlosframework/platform/internal/ledger 0.012s
ok github.com/carlosframework/platform/internal/mail 0.180s
ok github.com/carlosframework/platform/internal/maintenance 0.079s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.567s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.076s
ok github.com/carlosframework/platform/internal/metrics 0.005s
ok github.com/carlosframework/platform/internal/moves 21.051s
ok github.com/carlosframework/platform/internal/moves/archive 0.793s
ok github.com/carlosframework/platform/internal/objstore 0.177s
ok github.com/carlosframework/platform/internal/ops 0.160s
ok github.com/carlosframework/platform/internal/park 0.196s
ok github.com/carlosframework/platform/internal/privatefile 0.005s
ok github.com/carlosframework/platform/internal/provision 12.633s
ok github.com/carlosframework/platform/internal/publicrelease 0.007s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.046s
ok github.com/carlosframework/platform/internal/relay 7.233s
ok github.com/carlosframework/platform/internal/release 20.017s
ok github.com/carlosframework/platform/internal/rollout 3.686s
ok github.com/carlosframework/platform/internal/rolloutwords 0.007s
ok github.com/carlosframework/platform/internal/rootaws 0.007s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.026s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.295s
ok github.com/carlosframework/platform/internal/sim/hostlife 9.489s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.541s
ok github.com/carlosframework/platform/internal/status 0.042s
ok github.com/carlosframework/platform/internal/steering 0.006s
ok github.com/carlosframework/platform/internal/stopevidence 0.231s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.004s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.547s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.023s
=== RUN TestT4StorageProbeMinio
2026/10/14 15:04:08 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE6DA2F18E9088, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.022s
minio contract: PASS
diff --git a/auth_e2e_test.go b/auth_e2e_test.go| index 38bc15301..22ee1dfb7 100644 |
| --- a/auth_e2e_test.go |
| +++ b/auth_e2e_test.go |
| @@ -48,10 +48,14 @@ func TestAuthCLI(t *testing.T) { |
| fc := newFakeConsole(t) |
| home := t.TempDir() |
| creds := filepath.Join(home, ".carlos", "credentials") |
| - env := append(os.Environ(), |
| + // carlosTestEnv, never os.Environ(): openStore prefers an inherited |
| + // CARLOS_DEPLOYMENT_BUCKET over any dir store, so blank it (and the AWS |
| + // profile) so no child can reach a real bucket. |
| + env := carlosTestEnv(t, |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "AWS_PROFILE=", |
| "CARLOS_CREDENTIALS="+creds, |
| "HOME="+home, |
| - "CARLOS_CONSOLE=", |
| ) |
| run := func(args ...string) (string, error) { |
| @@ -128,12 +132,13 @@ func TestAuthCLI(t *testing.T) { |
| func TestAccountsCreateNeedsAnOwner(t *testing.T) { |
| bin := buildCarlos(t) |
| tmp := t.TempDir() |
| - env := append(os.Environ(), |
| + env := carlosTestEnv(t, |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "AWS_PROFILE=", |
| "CARLOS_DEPLOYMENT_DIR="+filepath.Join(tmp, "store"), |
| "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| "HOME="+tmp, |
| - "CARLOS_CONSOLE=", |
| ) |
| cmd := exec.Command(bin, "accounts", "create", "-name", "orphan") |
| cmd.Env = env |
| @@ -191,12 +196,13 @@ func TestAccountsCreateWithOwnerFlag(t *testing.T) { |
| tmp := t.TempDir() |
| storeDir := filepath.Join(tmp, "store") |
| cmd := exec.Command(bin, "accounts", "create", "-name", "kass", "-owner", "paul@keymail.dev") |
| - cmd.Env = append(os.Environ(), |
| + cmd.Env = carlosTestEnv(t, |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "AWS_PROFILE=", |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| "CARLOS_CREDENTIALS="+filepath.Join(tmp, "credentials"), |
| "CARLOS_DATA_DIR="+tmp, |
| "HOME="+tmp, |
| - "CARLOS_CONSOLE=", |
| ) |
| // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| cmd.Dir = tmp |
| @@ -225,6 +231,47 @@ func TestAccountsCreateWithOwnerFlag(t *testing.T) { |
| } |
| } |
| +// The headless path mints through the same allocator as the console, so it |
| +// steps over a reserved sqid too (#285): with 1167 the highest account in the |
| +// bucket, the next account is 1169 ("dew"), never 1168 ("dev"). |
| +func TestAccountsCreateSkipsAReservedSqid(t *testing.T) { |
| + bin := buildCarlos(t) |
| + tmp := t.TempDir() |
| + storeDir := filepath.Join(tmp, "store") |
| + store := release.NewDirStore(storeDir) |
| + if _, err := store.Put(t.Context(), domains.AccountManifestKey(domains.Sqid(1167)), |
| + []byte(`{"id":1167,"sqid":"`+domains.Sqid(1167)+`","name":"neighbour","created_at":"2026-08-02T09:00:00Z"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + cmd := exec.Command(bin, "accounts", "create", "-name", "shop", "-owner", "owner@example.com") |
| + // openStore prefers CARLOS_DEPLOYMENT_BUCKET over CARLOS_DEPLOYMENT_DIR, so |
| + // an inherited bucket (plus AWS credentials) would mint a real account. |
| + // Blank both, so the child can only reach the dir store. |
| + cmd.Env = carlosTestEnv(t, |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "AWS_PROFILE=", |
| + "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| + "CARLOS_DATA_DIR="+tmp, |
| + ) |
| + // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| + cmd.Dir = tmp |
| + out, err := cmd.CombinedOutput() |
| + if err != nil { |
| + t.Fatalf("carlos accounts create --owner: %v\n%s", err, out) |
| + } |
| + if _, _, err := store.Get(t.Context(), domains.AccountManifestKey("dev")); err == nil { |
| + t.Fatalf("accounts create minted the reserved sqid dev:\n%s", out) |
| + } |
| + body, _, err := store.Get(t.Context(), domains.AccountManifestKey("dew")) |
| + if err != nil { |
| + t.Fatalf("no manifest at accounts/dew/ (%v):\n%s", err, out) |
| + } |
| + var m domains.AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil || m.ID != 1169 || m.Name != "shop" { |
| + t.Fatalf("manifest = %+v (%v), want shop at id 1169", m, err) |
| + } |
| +} |
| + |
| // A padded name is trimmed BEFORE it is validated or minted — the gate and |
| // the write must agree on what "the name" is. Before this fix, -name " |
| // kass " passed ValidAccountName on the trimmed copy but handed the |
| @@ -236,6 +283,8 @@ func TestAccountsCreateTrimsTheNameBeforeMinting(t *testing.T) { |
| storeDir := filepath.Join(tmp, "store") |
| cmd := exec.Command(bin, "accounts", "create", "-name", " kass ", "-owner", "paul@keymail.dev") |
| cmd.Env = carlosTestEnv(t, |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "AWS_PROFILE=", |
| "CARLOS_DEPLOYMENT_DIR="+storeDir, |
| "CARLOS_DATA_DIR="+tmp, |
| ) |
| @@ -282,13 +331,16 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) { |
| t.Fatal(err) |
| } |
| cmd := exec.Command(bin, "accounts", "create", "-name", "kass") |
| - cmd.Env = append(os.Environ(), |
| + cmd.Env = carlosTestEnv(t, |
| + // No deployment store of any kind: the console owns the bucket write |
| + // on this path, and a laptop has none configured. Blanked rather than |
| + // left out, so an inherited bucket (or dir) can never be reached. |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "CARLOS_DEPLOYMENT_DIR=", |
| + "AWS_PROFILE=", |
| "CARLOS_CREDENTIALS="+creds, |
| "CARLOS_DATA_DIR="+tmp, |
| "HOME="+tmp, |
| - "CARLOS_CONSOLE=", |
| - // Deliberately no CARLOS_DEPLOYMENT_DIR: the console owns the bucket |
| - // write on this path, and a laptop has no deployment store configured. |
| ) |
| // pick() walks cwd ancestors for .carlos/config, so the subprocess must not run from the real checkout. |
| cmd.Dir = tmp |
diff --git a/internal/console/materialize.go b/internal/console/materialize.go| index 295a052e2..dd3020ca7 100644 |
| --- a/internal/console/materialize.go |
| +++ b/internal/console/materialize.go |
| @@ -80,6 +80,15 @@ func MaterializeAccounts(ctx context.Context, s release.Store, st *Store) (int, |
| "not a new account", key, sqid, m.Sqid, m.ID, domains.Sqid(m.ID)) |
| continue |
| } |
| + // A live account holding a reserved sqid (#285). The allocator never |
| + // mints one, so this fires only if the list grows over an existing |
| + // account or a manifest was written by hand. The account is left alone, |
| + // as the ruling says; this line is what makes it visible on every |
| + // console start instead of surfacing as broken DNS. |
| + if !m.Deleted() && domains.IsReservedSqid(m.Sqid) { |
| + log.Printf("console: account %s (id %d) has a reserved sqid; its hostnames under the apps domain "+ |
| + "collide with platform infrastructure (#285)", m.Sqid, m.ID) |
| + } |
| // Everything the row and the manifest can say to each other. Only the |
| // last case — never seen here, and live — falls through to birth. |
| switch { |
diff --git a/internal/console/reserved_sqids_test.go b/internal/console/reserved_sqids_test.go| new file mode 100644 |
| index 000000000..357d8f677 |
| --- /dev/null |
| +++ b/internal/console/reserved_sqids_test.go |
| @@ -0,0 +1,140 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// devAccountID is the id whose sqid is "dev" (#285). |
| +const devAccountID = int64(1168) |
| + |
| +// Every console door that creates an account (landing's personal-account |
| +// mint, POST /accounts/new, POST /auth/cli/accounts) mints through |
| +// domains.MintAccount, so none of them can hand out the reserved "dev". Each |
| +// door is driven against a bucket whose highest account is 1167, so the next |
| +// plain id would be 1168. |
| +func TestConsoleCreationDoorsSkipReservedSqids(t *testing.T) { |
| + doors := map[string]func(t *testing.T, fx *consoleFixture) (owner string){ |
| + "landing": func(t *testing.T, fx *consoleFixture) string { |
| + fx.setUser("newbie@keymail.dev") |
| + defer fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-new") |
| + if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || !strings.HasPrefix(loc, "/a/") { |
| + t.Fatalf("landing = %d -> %q, want 302 into an account", st, loc) |
| + } |
| + return "newbie@keymail.dev" |
| + }, |
| + "POST /accounts/new": func(t *testing.T, fx *consoleFixture) string { |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + if st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", |
| + url.Values{"csrf": {csrf}, "name": {"shop"}}); st != http.StatusSeeOther { |
| + t.Fatalf("create = %d -> %q, want 303", st, loc) |
| + } |
| + return "paul@keymail.dev" |
| + }, |
| + "POST /auth/cli/accounts": func(t *testing.T, fx *consoleFixture) string { |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-acct") |
| + if st, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "shop"}, token); st != http.StatusOK { |
| + t.Fatalf("accounts create = %d %v", st, out) |
| + } |
| + return "paul@keymail.dev" |
| + }, |
| + } |
| + for name, door := range doors { |
| + t.Run(name, func(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := t.Context() |
| + if err := WriteAccountManifest(ctx, fx.rel, devAccountID-1, domains.Sqid(devAccountID-1), |
| + "neighbour", time.Unix(1700000000, 0)); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := door(t, fx) |
| + |
| + accts, err := fx.st.AccountsFor(owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var made *Account |
| + for i := range accts { |
| + if accts[i].ID > devAccountID-1 { |
| + made = &accts[i] |
| + } |
| + } |
| + if made == nil { |
| + t.Fatalf("no new account for %s in %+v", owner, accts) |
| + } |
| + if made.ID != devAccountID+1 || made.Sqid != "dew" { |
| + t.Fatalf("minted (%d, %q), want (%d, \"dew\"): the door skipped the reserved-sqid rule", |
| + made.ID, made.Sqid, devAccountID+1) |
| + } |
| + if _, _, err := fx.rel.Get(ctx, AccountManifestKey("dev")); err == nil { |
| + t.Fatal("a manifest landed at accounts/dev/") |
| + } |
| + if a, err := fx.st.AccountBySqid("dev"); err != nil || a != nil { |
| + t.Fatalf("AccountBySqid(dev) = %+v %v, want none", a, err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The ruling leaves existing accounts alone, so an account that already holds |
| +// a reserved sqid (the list grew over it, or a manifest was written by hand) |
| +// is materialized as usual and named in the log, once per pass. A tombstoned |
| +// one is not named: it serves nothing. |
| +func TestMaterializeFlagsALiveReservedSqid(t *testing.T) { |
| + var logged bytes.Buffer |
| + log.SetOutput(&logged) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + st := openTestStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + ctx := t.Context() |
| + created := time.Unix(1700000000, 0).UTC() |
| + edgeID := int64(49868) // Sqid "edge" |
| + for _, a := range []struct { |
| + id int64 |
| + name string |
| + }{{devAccountID, "held"}, {devAccountID + 1, "plain"}, {edgeID, "retired"}} { |
| + if err := WriteAccountManifest(ctx, rel, a.id, domains.Sqid(a.id), a.name, created); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := domains.TombstoneAccount(ctx, rel, "edge", created); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + if _, err := MaterializeAccounts(ctx, rel, st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a, err := st.AccountBySqid("dev"); err != nil || a == nil || a.ID != devAccountID { |
| + t.Fatalf("AccountBySqid(dev) = %+v %v: an existing account must be left as it is", a, err) |
| + } |
| + out := logged.String() |
| + if !strings.Contains(out, "account dev (id 1168) has a reserved sqid") { |
| + t.Errorf("no reserved-sqid line for the live dev account:\n%s", out) |
| + } |
| + flagged := 0 |
| + for _, line := range strings.Split(out, "\n") { |
| + if !strings.Contains(line, "reserved sqid") { |
| + continue |
| + } |
| + flagged++ |
| + if strings.Contains(line, "—") { |
| + t.Errorf("the reserved-sqid log line carries an em dash: %s", line) |
| + } |
| + } |
| + if flagged != 1 { |
| + t.Errorf("%d reserved-sqid lines, want exactly one (dev; not the plain dew, not the retired edge):\n%s", flagged, out) |
| + } |
| +} |
diff --git a/internal/domains/mint.go b/internal/domains/mint.go| index be905f63e..1fa33bfe1 100644 |
| --- a/internal/domains/mint.go |
| +++ b/internal/domains/mint.go |
| @@ -130,6 +130,13 @@ const mintAttempts = 5 |
| // 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. |
| +// |
| +// An id whose sqid is reserved (IsReservedSqid, #285) is stepped over: the |
| +// answer is the first id past the highest claimed one whose sqid is free. No |
| +// manifest or tombstone is written for the skipped id, and none is needed. |
| +// The skip is a pure function of the id, so every minter computes the same |
| +// answer, a restart changes nothing, and once a later id is minted the |
| +// skipped one sits below the highest claim, where nothing allocates. |
| func NextAccountID(ctx context.Context, s release.Store) (int64, error) { |
| keys, err := s.List(ctx, "accounts/") |
| if err != nil { |
| @@ -155,7 +162,7 @@ func NextAccountID(ctx context.Context, s release.Store) (int64, error) { |
| max = m.ID |
| } |
| } |
| - return max + 1, nil |
| + return skipReservedIDs(max + 1), nil |
| } |
| // MintAccount allocates the next free account id and publishes its manifest, |
diff --git a/internal/domains/reserved.go b/internal/domains/reserved.go| new file mode 100644 |
| index 000000000..c07c41f44 |
| --- /dev/null |
| +++ b/internal/domains/reserved.go |
| @@ -0,0 +1,76 @@ |
| +package domains |
| + |
| +// reservedSqids are the labels no account may ever be given as its sqid |
| +// (#285, Paul's ruling 2026-10-14). |
| +// |
| +// Why: an account's apps live at <app>.<sqid>.<apps-domain>, and the |
| +// deployment's own infrastructure also hangs labels directly under the apps |
| +// domain. When the two meet, DNS answers for the infrastructure, not the |
| +// account. On 2026-10-13 flagship gained `*.dev.oncarlos.com` pointing at the |
| +// dev deployment's box, and Sqid(1168) is "dev": every app of account 1168 |
| +// would have resolved to dev's edge, and dev's edge would have tried to |
| +// issue certificates for them. |
| +// |
| +// The alphabet and the offset stay frozen. Instead, NextAccountID steps over |
| +// any id whose sqid is listed here, so the id is simply never handed out. |
| +// Existing accounts are untouched: console.MaterializeAccounts logs any live |
| +// account that holds a listed sqid, so adding a label that some account |
| +// already has shows up on the next console start rather than silently. |
| +// |
| +// Only list labels that Sqid can actually produce. TestReservedSqidsAreProducible |
| +// fails on any other entry, so dead entries cannot creep in. A label cannot |
| +// be produced when it: |
| +// - contains i, l or o (not in the frozen alphabet): relay, platform, |
| +// console, mail, tunnel, static; |
| +// - has fewer than 3 letters (sqidOffset's floor): us, uk, au, eu; |
| +// - starts with "a" (base 23 has no leading zero digit): acme, app, api, |
| +// admin, assets, auth; |
| +// - contains anything but lowercase letters: eu-west-1, _acme-challenge. |
| +// |
| +// Extending: add the label with a comment naming where it comes from. The |
| +// skip only matters for ids that have not been minted yet, so adding a label |
| +// is always safe. Removing one is safe too, but once the allocator has |
| +// passed its id that id stays unused for good (allocation is "one past the |
| +// highest id in the bucket", so nothing goes back for it). |
| +var reservedSqids = []string{ |
| + // dev.oncarlos.com: the dev deployment's apps domain. Flagship's Route53 |
| + // zone has `*.dev.oncarlos.com A <dev edge>` (carloku-infrastructure |
| + // dns53.tf, oncarlos_dev_wildcard, #9). Sqid(1168). |
| + "dev", |
| + // edge.<apps-domain>: the steering hostname the converger publishes and |
| + // health-checks on every armed edge (steering.ConvergeDeployment, |
| + // edge steeringprobe, console reservedDomains). Sqid(49868). |
| + "edge", |
| + // www.<apps-domain>: not served today, but it is the label anyone would |
| + // add next for the apps domain's own apex site, and a tenant holding it |
| + // would look like the platform's own website. Held back pre-emptively; |
| + // Sqid(9978). |
| + "www", |
| +} |
| + |
| +// ReservedSqids returns a copy of the labels no account may be given as its |
| +// sqid. A copy, so no caller can change what the allocator skips. |
| +func ReservedSqids() []string { |
| + return append([]string(nil), reservedSqids...) |
| +} |
| + |
| +// IsReservedSqid reports whether s is a label no account may be given. |
| +func IsReservedSqid(s string) bool { |
| + for _, r := range reservedSqids { |
| + if s == r { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// skipReservedIDs returns id, or the first id after it whose sqid is not |
| +// reserved. A pure function of the id and the list, which is what makes the |
| +// skip safe without any stored state: every minter, on every box and after |
| +// every restart, steps over exactly the same ids. |
| +func skipReservedIDs(id int64) int64 { |
| + for IsReservedSqid(Sqid(id)) { |
| + id++ |
| + } |
| + return id |
| +} |
diff --git a/internal/domains/reserved_test.go b/internal/domains/reserved_test.go| new file mode 100644 |
| index 000000000..34f9e97db |
| --- /dev/null |
| +++ b/internal/domains/reserved_test.go |
| @@ -0,0 +1,340 @@ |
| +package domains_test |
| + |
| +import ( |
| + "encoding/json" |
| + "math" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// devID is the account id whose sqid is "dev". Pinned here as well as |
| +// derived, because #285 was filed against the wrong id (1169, which is |
| +// "dew"): a test that only ever derived it would not have caught that. |
| +const devID = 1168 |
| + |
| +// sqidID inverts Sqid by plain base-23 decoding, the way the encoder builds |
| +// its digits. ok is false for a string Sqid can never emit for a positive id. |
| +// It lives in the test on purpose: nothing in production decodes a sqid, and |
| +// the producibility check should not trust the encoder to vouch for itself. |
| +func sqidID(s string) (int64, bool) { |
| + const alphabet = "abcdefghjkmnpqrstuvwxyz" |
| + if len(s) < 3 || len(s) > 16 { |
| + return 0, false |
| + } |
| + var n int64 |
| + for i := 0; i < len(s); i++ { |
| + d := strings.IndexByte(alphabet, s[i]) |
| + if d < 0 { |
| + return 0, false |
| + } |
| + if n > (math.MaxInt64-int64(d))/23 { |
| + return 0, false // past every int64 id: Sqid never gets here |
| + } |
| + n = n*23 + int64(d) |
| + } |
| + id := n - 23*23 |
| + if id < 1 || domains.Sqid(id) != s { |
| + return 0, false // a leading "a", or "baa" (id 0) |
| + } |
| + return id, true |
| +} |
| + |
| +func TestDevIsReservedAndIsSqid1168(t *testing.T) { |
| + if got := domains.Sqid(devID); got != "dev" { |
| + t.Fatalf("Sqid(%d) = %q, want \"dev\"", devID, got) |
| + } |
| + if !domains.IsReservedSqid("dev") { |
| + t.Fatal(`"dev" is not reserved: account 1168's apps would resolve to the dev deployment's edge`) |
| + } |
| + if domains.Sqid(1169) != "dew" || domains.IsReservedSqid("dew") { |
| + t.Fatalf("Sqid(1169) = %q (reserved %v): 1169 is the id the allocator should land on", |
| + domains.Sqid(1169), domains.IsReservedSqid(domains.Sqid(1169))) |
| + } |
| +} |
| + |
| +// Every listed label must be one Sqid can actually emit. A label with i, l |
| +// or o in it, under 3 letters, or starting with "a" can never be an account's |
| +// sqid, and listing it would only suggest a protection that is not needed. |
| +func TestReservedSqidsAreProducible(t *testing.T) { |
| + list := domains.ReservedSqids() |
| + if len(list) == 0 { |
| + t.Fatal("no reserved sqids") |
| + } |
| + seen := map[string]bool{} |
| + for _, label := range list { |
| + if seen[label] { |
| + t.Errorf("%q is listed twice", label) |
| + } |
| + seen[label] = true |
| + id, ok := sqidID(label) |
| + if !ok { |
| + t.Errorf("%q is listed as reserved but Sqid can never produce it; drop it", label) |
| + continue |
| + } |
| + if !domains.ValidSqid(label) { |
| + t.Errorf("%q decodes to id %d but ValidSqid rejects it", label, id) |
| + } |
| + t.Logf("reserved %q = Sqid(%d)", label, id) |
| + } |
| + // The decoder itself, against the frozen golden values and the cases it |
| + // must refuse. |
| + for s, want := range map[string]int64{"bab": 1, "cxn": 1000, "dev": devID} { |
| + if got, ok := sqidID(s); !ok || got != want { |
| + t.Errorf("sqidID(%q) = %d %v, want %d", s, got, ok, want) |
| + } |
| + } |
| + for _, s := range []string{"app", "acme", "baa", "relay", "us", "ops"} { |
| + if _, ok := sqidID(s); ok { |
| + t.Errorf("sqidID(%q) decoded, but Sqid never emits it", s) |
| + } |
| + } |
| +} |
| + |
| +// ReservedSqids hands out a copy: a caller editing its slice must not change |
| +// what the allocator skips. |
| +func TestReservedSqidsIsACopy(t *testing.T) { |
| + list := domains.ReservedSqids() |
| + list[0] = "zzz" |
| + if domains.IsReservedSqid("zzz") || !slices.Contains(domains.ReservedSqids(), "dev") { |
| + t.Fatal("editing ReservedSqids' result changed the reserved set") |
| + } |
| +} |
| + |
| +// The allocator steps over every reserved id, seeded one below it with a |
| +// single manifest rather than a thousand real rows. |
| +func TestNextAccountIDSkipsReservedSqids(t *testing.T) { |
| + ctx := t.Context() |
| + for _, label := range domains.ReservedSqids() { |
| + id, ok := sqidID(label) |
| + if !ok { |
| + t.Fatalf("%q is not producible", label) |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + writeManifest(t, s, id-1, domains.Sqid(id-1)) |
| + got, err := domains.NextAccountID(ctx, s) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got <= id { |
| + t.Errorf("after id %d, NextAccountID = %d (%q); want past %d, whose sqid is the reserved %q", |
| + id-1, got, domains.Sqid(got), id, label) |
| + } |
| + if domains.IsReservedSqid(domains.Sqid(got)) { |
| + t.Errorf("NextAccountID = %d, whose sqid %q is reserved", got, domains.Sqid(got)) |
| + } |
| + } |
| + |
| + // And the plain case is untouched: an id whose sqid is free is the |
| + // answer, exactly as before. |
| + s := release.NewDirStore(t.TempDir()) |
| + writeManifest(t, s, devID-2, domains.Sqid(devID-2)) |
| + if got, err := domains.NextAccountID(ctx, s); err != nil || got != devID-1 { |
| + t.Errorf("NextAccountID after %d = %d (%v), want %d", devID-2, got, err, devID-1) |
| + } |
| +} |
| + |
| +// MintAccount, the one allocator every creation path uses, lands on 1169 |
| +// across the skip, and writes nothing at accounts/dev/. |
| +func TestMintAccountNeverMintsDev(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + writeManifest(t, s, devID-1, domains.Sqid(devID-1)) |
| + id, sqid, err := domains.MintAccount(ctx, s, "shop", "owner@example.com", time.Unix(1700000000, 0)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != devID+1 || sqid != "dew" { |
| + t.Fatalf("minted (%d, %q), want (%d, \"dew\")", id, sqid, devID+1) |
| + } |
| + if _, _, err := s.Get(ctx, domains.AccountManifestKey("dev")); err == nil { |
| + t.Fatal("a manifest landed at accounts/dev/") |
| + } |
| +} |
| + |
| +// The race at the skip: another minter takes 1169 between our listing and our |
| +// write. We must re-list and take 1170, never fall back to 1168. |
| +func TestMintAccountLosesTheRaceAtTheSkip(t *testing.T) { |
| + ctx := t.Context() |
| + inner := release.NewDirStore(t.TempDir()) |
| + writeManifest(t, inner, devID-1, domains.Sqid(devID-1)) |
| + planted := false |
| + s := &racingStore{Store: inner, beforePut: func(key string) { |
| + if planted { |
| + return |
| + } |
| + planted = true |
| + if key != domains.AccountManifestKey("dew") { |
| + t.Errorf("first create-only write went to %s, want accounts/dew/", key) |
| + } |
| + writeManifest(t, inner, devID+1, "dew") |
| + }} |
| + id, sqid, err := domains.MintAccount(ctx, s, "shop", "", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if id != devID+2 || sqid != domains.Sqid(devID+2) { |
| + t.Fatalf("minted (%d, %q), want (%d, %q)", id, sqid, devID+2, domains.Sqid(devID+2)) |
| + } |
| +} |
| + |
| +// Concurrent minters around the skip: every one gets a distinct id, none gets |
| +// 1168, and together they fill 1169.. with no gap but the reserved one. |
| +func TestConcurrentMintsAroundTheSkip(t *testing.T) { |
| + ctx := t.Context() |
| + s := release.NewDirStore(t.TempDir()) |
| + writeManifest(t, s, devID-1, domains.Sqid(devID-1)) |
| + |
| + // Five at once: a minter loses at most once to each of the others, and |
| + // MintAccount allows five attempts. |
| + const n = 5 |
| + ids := make([]int64, n) |
| + errs := make([]error, n) |
| + var wg sync.WaitGroup |
| + start := make(chan struct{}) |
| + for i := range n { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + <-start |
| + ids[i], _, errs[i] = domains.MintAccount(ctx, s, "shop", "", time.Now()) |
| + }() |
| + } |
| + close(start) |
| + wg.Wait() |
| + for i, err := range errs { |
| + if err != nil { |
| + t.Fatalf("minter %d: %v", i, err) |
| + } |
| + } |
| + slices.Sort(ids) |
| + for i, id := range ids { |
| + if want := int64(devID + 1 + i); id != want { |
| + t.Fatalf("minted ids %v, want %d..%d", ids, devID+1, devID+n) |
| + } |
| + } |
| + if _, _, err := s.Get(ctx, domains.AccountManifestKey("dev")); err == nil { |
| + t.Fatal("a concurrent mint landed at accounts/dev/") |
| + } |
| +} |
| + |
| +// The skip holds across restarts: a fresh store over the same bucket (a new |
| +// process, another box) computes the same answer, before and after the id |
| +// past the skip is minted. Nothing is stored for the skipped id, so there is |
| +// nothing for a restart to lose. |
| +func TestReservedSkipSurvivesRestart(t *testing.T) { |
| + ctx := t.Context() |
| + dir := t.TempDir() |
| + writeManifest(t, release.NewDirStore(dir), devID-1, domains.Sqid(devID-1)) |
| + |
| + for range 2 { |
| + got, err := domains.NextAccountID(ctx, release.NewDirStore(dir)) |
| + if err != nil || got != devID+1 { |
| + t.Fatalf("NextAccountID on a fresh store = %d (%v), want %d", got, err, devID+1) |
| + } |
| + } |
| + if _, _, err := domains.MintAccount(ctx, release.NewDirStore(dir), "shop", "", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := domains.NextAccountID(ctx, release.NewDirStore(dir)) |
| + if err != nil || got != devID+2 { |
| + t.Fatalf("NextAccountID after minting %d = %d (%v), want %d", devID+1, got, err, devID+2) |
| + } |
| + keys, err := release.NewDirStore(dir).List(ctx, "accounts/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, k := range keys { |
| + body, _, err := release.NewDirStore(dir).Get(ctx, k) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m domains.AccountManifest |
| + if err := json.Unmarshal(body, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + if domains.IsReservedSqid(m.Sqid) { |
| + t.Fatalf("%s holds the reserved sqid %q", k, m.Sqid) |
| + } |
| + } |
| +} |
| + |
| +// Every creation path goes through the one rule. The rule lives in |
| +// NextAccountID, MintAccount is its only caller, and the account rows the |
| +// console and the registry write must take their ids from MintAccount. This |
| +// census pins the callers, so a new creation path has to be added here on |
| +// purpose, with someone checking that it mints through MintAccount. |
| +// |
| +// The materialization writers (console EnsureAccount, registry PutAccount in |
| +// domains.Reconcile and the CLI) copy an id the bucket already holds; they |
| +// never choose one, so they are not creation paths. |
| +func TestAccountCreationPathsCensus(t *testing.T) { |
| + _, here, _, ok := runtime.Caller(0) |
| + if !ok { |
| + t.Fatal("no caller") |
| + } |
| + root := filepath.Join(filepath.Dir(here), "..", "..") |
| + want := map[string][]string{ |
| + "domains.NextAccountID(": nil, // only MintAccount, inside this package |
| + "NextAccountID(ctx": {"internal/domains/mint.go"}, |
| + "domains.MintAccount(": { |
| + "accounts.go", // carlos accounts create --owner |
| + "internal/console/server.go", // Server.mintAccount: landing, /accounts/new, /auth/cli/accounts |
| + }, |
| + "s.mintAccount(": { |
| + "internal/console/authcli.go", // POST /auth/cli/accounts |
| + "internal/console/server.go", // landing mint and POST /accounts/new |
| + }, |
| + ".CreateAccount(": {"internal/console/server.go"}, // inside Server.mintAccount only |
| + } |
| + got := map[string][]string{} |
| + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + switch d.Name() { |
| + case ".git", "node_modules", "vendor", "testdata": |
| + return filepath.SkipDir |
| + } |
| + if strings.HasPrefix(d.Name(), ".") && path != root { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { |
| + return nil |
| + } |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + rel, _ := filepath.Rel(root, path) |
| + rel = filepath.ToSlash(rel) |
| + for needle := range want { |
| + if strings.Contains(string(b), needle) && !slices.Contains(got[needle], rel) { |
| + got[needle] = append(got[needle], rel) |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for needle, files := range want { |
| + slices.Sort(files) |
| + slices.Sort(got[needle]) |
| + if !slices.Equal(files, got[needle]) { |
| + t.Errorf("%q is called from %v, want %v. A new account creation path must mint through "+ |
| + "domains.MintAccount so it skips reserved sqids (#285); then add it here", needle, got[needle], files) |
| + } |
| + } |
| +} |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index fe8dbe6b7..d1c2bb971 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -668,6 +668,12 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| // activator rows and four new ones (generation socket directories) add |
| // fourteen frozen declarations. |
| seal = "e736a5e2652cc546db02c5a385ae7da9fa8a4313d3064f6b431642431974d65d" |
| + // Census overlay reserved285 (#285): envelope-only, for the reserved-sqid |
| + // skip in the account allocator and its materialization log line. No |
| + // root-read unit changes, so the legacy seal above stands. |
| + if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-reserved285.tsv"), &inv, true); err != nil { |
| + t.Fatal(err) |
| + } |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/testdata/plan14-root-reads-reserved285.tsv b/internal/provision/testdata/plan14-root-reads-reserved285.tsv| new file mode 100644 |
| index 000000000..031735ddb |
| --- /dev/null |
| +++ b/internal/provision/testdata/plan14-root-reads-reserved285.tsv |
| @@ -0,0 +1,3 @@ |
| +# Plan 14 census overlay reserved285 (amadan #285, reserved account sqids): the account allocator (internal/domains NextAccountID) steps over ids whose sqid is a reserved label, and the console's account materialization logs a live account that holds one. Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added. Earlier overlays are immutable. |
| +@coverage internal/domains 5d2afacc1669ea25966a274801f709a1f896c4c24c946766bb4bf1cf29915ea1 |
| +@coverage internal/console 8a423453c4f4e86c0e6ba1548333bb36159487adaed9c8e97a1a63f63f031444 |