console: the delete revoke takes a token snapshot, not a record read (#155)
The first fix for Astra's finding 1 read the record before retirement, which moved the delete door's own record reads and broke two import-delete race tests in CI. The delete now snapshots the host's live instance token ids from the console database before retiring the record and revokes exactly those after it, so a replacement's token is still spared. Census overlay 155 regenerated: three new SQL units, still PROPOSED-155.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+114
−62
docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md+6 −2internal/console/apiinstances.go+6 −5internal/console/instancetoken.go+62 −35internal/console/instancetoken_mint_test.go+3 −3internal/console/instancetoken_store_test.go+27 −9internal/console/server.go+1 −1internal/provision/plan14_root_reads_test.go+2 −2internal/provision/testdata/plan14-root-reads-155.tsv+7 −5
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? 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 294.472s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 2.577s
ok github.com/carlosframework/platform/cmd/moveprobe 93.189s
ok github.com/carlosframework/platform/cmd/zddprobe 0.710s
ok github.com/carlosframework/platform/internal/activator 249.417s
ok github.com/carlosframework/platform/internal/agentsock 0.015s
ok github.com/carlosframework/platform/internal/alerting 0.003s
ok github.com/carlosframework/platform/internal/analytics 0.019s
ok github.com/carlosframework/platform/internal/appconfig 2.440s
ok github.com/carlosframework/platform/internal/apperr 0.069s
ok github.com/carlosframework/platform/internal/applog 0.137s
ok github.com/carlosframework/platform/internal/awsinv 0.003s
ok github.com/carlosframework/platform/internal/boxchoice 0.005s
ok github.com/carlosframework/platform/internal/boxid 0.612s
? 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.008s
ok github.com/carlosframework/platform/internal/caps 0.011s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.691s
ok github.com/carlosframework/platform/internal/changelog 0.312s
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 397.041s
? 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 5.789s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.008s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.005s
ok github.com/carlosframework/platform/internal/docsite 0.005s
ok github.com/carlosframework/platform/internal/domains 2.313s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.004s
ok github.com/carlosframework/platform/internal/edge 26.504s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.129s
ok github.com/carlosframework/platform/internal/gate 1.654s
ok github.com/carlosframework/platform/internal/genselect 0.015s
ok github.com/carlosframework/platform/internal/geo 0.011s
ok github.com/carlosframework/platform/internal/geoip 0.031s
ok github.com/carlosframework/platform/internal/host 161.931s
ok github.com/carlosframework/platform/internal/hostactors 0.533s
ok github.com/carlosframework/platform/internal/hostbox 2.433s
ok github.com/carlosframework/platform/internal/hostclaim 0.466s
ok github.com/carlosframework/platform/internal/hostevidence 0.013s
ok github.com/carlosframework/platform/internal/hostexec 4.749s
ok github.com/carlosframework/platform/internal/hostfence 0.032s
ok github.com/carlosframework/platform/internal/hostfiles 10.439s
ok github.com/carlosframework/platform/internal/hostfloor 0.013s
ok github.com/carlosframework/platform/internal/hostguard 1.007s
ok github.com/carlosframework/platform/internal/hostops 1.559s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.013s
ok github.com/carlosframework/platform/internal/hostquarantine 0.013s
ok github.com/carlosframework/platform/internal/hostrelocate 0.440s
ok github.com/carlosframework/platform/internal/hostresolve 0.131s
ok github.com/carlosframework/platform/internal/hostwrite 0.013s
ok github.com/carlosframework/platform/internal/infraview 0.013s
ok github.com/carlosframework/platform/internal/instancectl 4.179s
ok github.com/carlosframework/platform/internal/instanceexec 2.689s
ok github.com/carlosframework/platform/internal/instancefs 0.018s
ok github.com/carlosframework/platform/internal/instancekey 0.015s
ok github.com/carlosframework/platform/internal/instanceupdates 0.022s
ok github.com/carlosframework/platform/internal/layout 1.517s
ok github.com/carlosframework/platform/internal/lease 9.805s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.018s
ok github.com/carlosframework/platform/internal/ledger 0.011s
ok github.com/carlosframework/platform/internal/mail 0.148s
ok github.com/carlosframework/platform/internal/maintenance 0.078s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.493s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.070s
ok github.com/carlosframework/platform/internal/metrics 0.004s
ok github.com/carlosframework/platform/internal/moves 20.944s
ok github.com/carlosframework/platform/internal/moves/archive 0.767s
ok github.com/carlosframework/platform/internal/objstore 0.167s
ok github.com/carlosframework/platform/internal/ops 0.163s
ok github.com/carlosframework/platform/internal/park 0.205s
ok github.com/carlosframework/platform/internal/privatefile 0.003s
ok github.com/carlosframework/platform/internal/provision 13.615s
ok github.com/carlosframework/platform/internal/publicrelease 0.008s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 4.870s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 19.163s
ok github.com/carlosframework/platform/internal/rollout 3.627s
ok github.com/carlosframework/platform/internal/rolloutwords 0.004s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/rootparticipation 0.007s
ok github.com/carlosframework/platform/internal/rootstate 0.024s
ok github.com/carlosframework/platform/internal/runs 0.572s
ok github.com/carlosframework/platform/internal/s3compat 0.004s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.487s
ok github.com/carlosframework/platform/internal/seeds 0.010s
ok github.com/carlosframework/platform/internal/sim/hostlife 8.673s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 4.594s
ok github.com/carlosframework/platform/internal/status 0.038s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.195s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.003s
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.002s
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.001s
ok github.com/carlosframework/platform/internal/vet 1.537s
=== 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
linux/arm64 carlos binary: 47120546 bytes (ceiling 52000000)
--- 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/16 07:15:13 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEF1357558427B, 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.021s
minio contract: PASS
diff --git a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md| index db823f2b8..517e8a671 100644 |
| --- a/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md |
| +++ b/docs/superpowers/specs/2026-10-16-instance-bound-tokens-design.md |
| @@ -136,8 +136,12 @@ A token is bound to one incarnation of its host: |
| pinned by an AST test listing every non-test caller of |
| `provision.CreateRecord` and of `hostops.PrepareInstance`; a new caller |
| fails the test until it is reviewed against this rule and added. |
| -- `handleAPIInstanceDelete` revokes every live `kind='instance'` row for |
| - (account, app, host) **after** the record is gone, best effort: a failed |
| +- `handleAPIInstanceDelete` snapshots the host's live `kind='instance'` row |
| + ids from the console database before it retires the record, and revokes |
| + exactly those **after** the record is gone, best effort (so a token minted |
| + for a replacement in between is spared, Astra final review 1; a snapshot |
| + rather than a bucket read of the record, so the delete's own reads of the |
| + record are unchanged): a failed |
| revoke is logged and the delete still answers 200. (Rev 4 said "before, |
| and a failed revoke deletes nothing". Built that way, it refused a claim-mode |
| delete retry whose app is under the delete's own exclusive operation, |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 2ed8ca050..611ede623 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -2218,12 +2218,13 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| if s.refuseDeleteDuringMove(ctx, w, acct.Sqid, app, host) { |
| return |
| } |
| - // The incarnation this delete retires, so the token revoke after it |
| - // spares any replacement (spec 2026-10-16-instance-bound-tokens §3.4). |
| - born := s.deletingIncarnation(ctx, acct, app, host) |
| + // The tokens this delete will revoke, snapshotted from the console's |
| + // own database before the record goes, so the revoke after it spares |
| + // any replacement (spec 2026-10-16-instance-bound-tokens §3.4). |
| + tokens := s.deletingInstanceTokens(acct, app, host) |
| if answered, finished := s.refuseImportDelete(w, r, acct, app, host); answered { |
| if finished { |
| - s.revokeDeletedInstanceTokens(ctx, acct, app, host, born) |
| + s.revokeDeletedInstanceTokens(ctx, acct, app, host, tokens) |
| s.auditActivity(acct, app, principal, Activity{ |
| Action: "instance-delete", TargetType: "instance", Target: host, Message: message, |
| Detail: "deleted instance " + host + " (cli)", |
| @@ -2250,7 +2251,7 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| jsonError(w, http.StatusBadGateway, "could not delete the instance — try again") |
| return |
| } |
| - s.revokeDeletedInstanceTokens(ctx, acct, app, host, born) |
| + s.revokeDeletedInstanceTokens(ctx, acct, app, host, tokens) |
| // DNS seam (spec §10): a multi-region deployment retires the instance's |
| // CNAME here, after the record is gone. |
| // |
diff --git a/internal/console/instancetoken.go b/internal/console/instancetoken.go| index 38f7a02d5..1426da7de 100644 |
| --- a/internal/console/instancetoken.go |
| +++ b/internal/console/instancetoken.go |
| @@ -57,21 +57,52 @@ VALUES (?, ?, ?, '', ?, 0, ?, ?, ?, ?, ?)`, |
| return raw, nil |
| } |
| -// RevokeInstanceTokensContext soft-revokes every live token bound to one |
| -// incarnation of host, and no other: a replacement created since keeps its |
| -// own (Astra final review, 1). Zero rows is not an error: the delete door |
| -// calls it for every host, minted for or not. |
| -func (s *Store) RevokeInstanceTokensContext(ctx context.Context, accountID int64, app, host string, born int64, now time.Time) error { |
| +// LiveInstanceTokenIDs lists the listing ids of host's live instance |
| +// tokens: the snapshot the delete door takes before it retires the record, |
| +// so the revoke after it touches exactly those and never a token minted |
| +// for a replacement in between (Astra final review, 1). A read of the |
| +// console's own database; no bucket read, so the delete's own reads of the |
| +// record are unchanged. |
| +func (s *Store) LiveInstanceTokenIDs(accountID int64, app, host string) ([]string, error) { |
| + rows, err := s.db.Query(`SELECT token_hash FROM cli_tokens |
| +WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND revoked_at = 0 ORDER BY token_hash`, |
| + cliKindInstance, accountID, app, host) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var ids []string |
| + for rows.Next() { |
| + var id string |
| + if err := rows.Scan(&id); err != nil { |
| + return nil, err |
| + } |
| + ids = append(ids, id) |
| + } |
| + return ids, rows.Err() |
| +} |
| + |
| +// RevokeInstanceTokenIDsContext soft-revokes the live instance tokens of |
| +// (account, app) among ids, and nothing else: an id of another kind or |
| +// another app is left alone. No ids is not an error. |
| +func (s *Store) RevokeInstanceTokenIDsContext(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error { |
| + if len(ids) == 0 { |
| + return nil |
| + } |
| return s.withAutoAppSQL(ctx, accountID, app, "sql.RevokeInstanceTokens", func(view *Store) error { |
| - return view.claimRawRevokeInstanceTokens(accountID, app, host, born, now) |
| + return view.claimRawRevokeInstanceTokenIDs(accountID, app, ids, now) |
| }) |
| } |
| -func (s *Store) claimRawRevokeInstanceTokens(accountID int64, app, host string, born int64, now time.Time) error { |
| - _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? |
| -WHERE kind = ? AND account_id = ? AND app = ? AND host = ? AND host_born = ? AND revoked_at = 0`, |
| - now.Unix(), cliKindInstance, accountID, app, host, born) |
| - return err |
| +func (s *Store) claimRawRevokeInstanceTokenIDs(accountID int64, app string, ids []string, now time.Time) error { |
| + for _, id := range ids { |
| + if _, err := s.db.Exec(`UPDATE cli_tokens SET revoked_at = ? |
| +WHERE token_hash = ? AND kind = ? AND account_id = ? AND app = ? AND revoked_at = 0`, |
| + now.Unix(), id, cliKindInstance, accountID, app); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| } |
| // InstanceScope is an instance token's scope as the account pane shows it. |
| @@ -146,37 +177,33 @@ func (s *Server) handleAPIInstanceTokenMint(w http.ResponseWriter, r *http.Reque |
| // revokeInstanceTokensFor is the delete door's revoke, through the test seam |
| // when one is set. |
| -func (s *Server) revokeInstanceTokensFor(ctx context.Context, accountID int64, app, host string, born int64, now time.Time) error { |
| +func (s *Server) revokeInstanceTokensFor(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error { |
| if s.revokeInstanceTokens != nil { |
| - return s.revokeInstanceTokens(ctx, accountID, app, host, born, now) |
| + return s.revokeInstanceTokens(ctx, accountID, app, ids, now) |
| } |
| - return s.store.RevokeInstanceTokensContext(ctx, accountID, app, host, born, now) |
| + return s.store.RevokeInstanceTokenIDsContext(ctx, accountID, app, ids, now) |
| } |
| -// deletingIncarnation reads the incarnation a delete is about to retire, so |
| -// the revoke after it touches that one only. Zero when there is no record |
| -// (the delete is idempotent) or it could not be read; the revoke then does |
| -// nothing, and the incarnation check still holds for any token left. |
| -func (s *Server) deletingIncarnation(ctx context.Context, acct *Account, app, host string) int64 { |
| - rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| - if err != nil || rec.CreatedAt.IsZero() { |
| - return 0 |
| +// deletingInstanceTokens snapshots host's live instance tokens before the |
| +// delete retires the record, so the revoke after it spares any token minted |
| +// for a replacement in between. A read failure snapshots nothing; the |
| +// incarnation check still holds for any token left. |
| +func (s *Server) deletingInstanceTokens(acct *Account, app, host string) []string { |
| + ids, err := s.store.LiveInstanceTokenIDs(acct.ID, app, host) |
| + if err != nil { |
| + log.Printf("console: api instances delete %s/%s/%s: list instance tokens (they answer 404 regardless): %v", acct.Sqid, app, host, err) |
| + return nil |
| } |
| - return rec.CreatedAt.UnixNano() |
| + return ids |
| } |
| -// revokeDeletedInstanceTokens revokes the retired incarnation's instance |
| -// tokens once its record is gone (spec §3.4). Best effort, and after the |
| -// delete rather than before it: the incarnation check is the guarantee, so |
| -// a token this misses can only answer 404, while a revoke made a |
| -// precondition would refuse a delete retry whose app is under its own |
| -// exclusive operation (claim mode). Scoped to born, so a replacement |
| -// created in between keeps its tokens. |
| -func (s *Server) revokeDeletedInstanceTokens(ctx context.Context, acct *Account, app, host string, born int64) { |
| - if born == 0 { |
| - return |
| - } |
| - if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, host, born, time.Now()); err != nil { |
| +// revokeDeletedInstanceTokens revokes the snapshot's tokens once the record |
| +// is gone (spec §3.4). Best effort, and after the delete rather than before |
| +// it: the incarnation check is the guarantee, so a token this misses can |
| +// only answer 404, while a revoke made a precondition would refuse a delete |
| +// retry whose app is under its own exclusive operation (claim mode). |
| +func (s *Server) revokeDeletedInstanceTokens(ctx context.Context, acct *Account, app, host string, ids []string) { |
| + if err := s.revokeInstanceTokensFor(ctx, acct.ID, app, ids, time.Now()); err != nil { |
| log.Printf("console: api instances delete %s/%s/%s: revoke instance tokens (they answer 404 regardless): %v", acct.Sqid, app, host, err) |
| } |
| } |
diff --git a/internal/console/instancetoken_mint_test.go b/internal/console/instancetoken_mint_test.go| index c361c80d9..246135bf2 100644 |
| --- a/internal/console/instancetoken_mint_test.go |
| +++ b/internal/console/instancetoken_mint_test.go |
| @@ -107,7 +107,7 @@ func TestDeleteRevokesInstanceTokens(t *testing.T) { |
| // the host is created again. |
| func TestDeleteSurvivesAFailedRevoke(t *testing.T) { |
| w := newSelfWorld(t) |
| - w.fx.srv.revokeInstanceTokens = func(context.Context, int64, string, string, int64, time.Time) error { |
| + w.fx.srv.revokeInstanceTokens = func(context.Context, int64, string, []string, time.Time) error { |
| return errors.New("database is locked") |
| } |
| base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| @@ -199,11 +199,11 @@ func TestPersonalSettingsInstanceTokenRevoke(t *testing.T) { |
| func TestDeleteRevokeSparesAReplacement(t *testing.T) { |
| w := newSelfWorld(t) |
| var replacement string |
| - w.fx.srv.revokeInstanceTokens = func(ctx context.Context, accountID int64, app, host string, born int64, now time.Time) error { |
| + w.fx.srv.revokeInstanceTokens = func(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error { |
| time.Sleep(time.Millisecond) |
| rec := w.create(t, selfHostA, "service") |
| replacement = mintBoundToken(t, w.fx.st, OpsAccountID, "titogo", selfHostA, rec.CreatedAt.UnixNano()) |
| - return w.fx.st.RevokeInstanceTokensContext(ctx, accountID, app, host, born, now) |
| + return w.fx.st.RevokeInstanceTokenIDsContext(ctx, accountID, app, ids, now) |
| } |
| base := instancesURL(w.fx, w.fx.sqid, "titogo") + "/" + selfHostA |
| if st, _, body := rawReq(t, http.MethodDelete, base, w.member, ""); st != http.StatusOK { |
diff --git a/internal/console/instancetoken_store_test.go b/internal/console/instancetoken_store_test.go| index 033aaab70..df16331f9 100644 |
| --- a/internal/console/instancetoken_store_test.go |
| +++ b/internal/console/instancetoken_store_test.go |
| @@ -142,19 +142,36 @@ func TestInstanceTokensListRevokeAndNeverExpire(t *testing.T) { |
| if err != nil || len(mine) != 1 || mine[0].Host != "a.example.com" { |
| t.Fatalf("CLITokens = %+v, %v", mine, err) |
| } |
| - other := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn+1) |
| - if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "a.example.com", testBorn, time.Now()); err != nil { |
| + ids, err := s.LiveInstanceTokenIDs(acct.ID, "shop", "a.example.com") |
| + if err != nil || len(ids) != 1 || ids[0] != hashToken(raw) { |
| + t.Fatalf("LiveInstanceTokenIDs = %v, %v", ids, err) |
| + } |
| + // Minted after the snapshot: a replacement's, which the revoke spares. |
| + later := mintBoundToken(t, s, acct.ID, "shop", "a.example.com", testBorn+1) |
| + if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", ids, time.Now()); err != nil { |
| t.Fatal(err) |
| } |
| if _, ok := s.CLIPrincipal(raw, time.Now()); ok { |
| t.Fatal("revoked instance token still resolves") |
| } |
| - if _, ok := s.CLIPrincipal(other, time.Now()); !ok { |
| - t.Fatal("revoking one incarnation took another's token") |
| + if _, ok := s.CLIPrincipal(later, time.Now()); !ok { |
| + t.Fatal("the revoke took a token minted after its snapshot") |
| + } |
| + // No ids is not an error: delete calls it for every host. |
| + if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", nil, time.Now()); err != nil { |
| + t.Fatalf("revoking no tokens: %v", err) |
| + } |
| + // The ids are scoped to the account and app given: an app token's id, |
| + // or another app's instance token, is never revoked through here. |
| + appTok, err := s.NewAppToken(acct.ID, "shop", "alice@keymail.dev", "router", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.RevokeInstanceTokenIDsContext(context.Background(), acct.ID, "shop", []string{hashToken(appTok)}, time.Now()); err != nil { |
| + t.Fatal(err) |
| } |
| - // Zero rows is not an error: delete calls it for every host. |
| - if err := s.RevokeInstanceTokensContext(context.Background(), acct.ID, "shop", "never.example.com", testBorn, time.Now()); err != nil { |
| - t.Fatalf("revoking a host with no tokens: %v", err) |
| + if _, ok := s.CLIPrincipal(appTok, time.Now()); !ok { |
| + t.Fatal("the instance revoke took an app token") |
| } |
| } |
| @@ -303,8 +320,9 @@ func TestInstanceTokenMintAndRevokeAreAdmitted(t *testing.T) { |
| if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok { |
| t.Fatal("gated mint does not resolve") |
| } |
| + ids := []string{hashToken(raw)} |
| restore := cx.foreign(t, claimAppRef{Account: "bab", App: "kass"}) |
| - err = cx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", testBorn, time.Now()) |
| + err = cx.st.RevokeInstanceTokenIDsContext(context.Background(), OpsAccountID, "kass", ids, time.Now()) |
| restore() |
| if err == nil { |
| t.Fatal("an instance token was revoked while its app was closed under a foreign exclusive operation") |
| @@ -312,7 +330,7 @@ func TestInstanceTokenMintAndRevokeAreAdmitted(t *testing.T) { |
| if _, ok := cx.st.CLIPrincipal(raw, time.Now()); !ok { |
| t.Fatal("the refused revoke still revoked the token") |
| } |
| - if err := cx.st.RevokeInstanceTokensContext(context.Background(), OpsAccountID, "kass", "a.example.com", testBorn, time.Now()); err != nil { |
| + if err := cx.st.RevokeInstanceTokenIDsContext(context.Background(), OpsAccountID, "kass", ids, time.Now()); err != nil { |
| t.Fatalf("gated revoke: %v", err) |
| } |
| if _, ok := cx.st.CLIPrincipal(raw, time.Now()); ok { |
diff --git a/internal/console/server.go b/internal/console/server.go| index 52612e619..eff0c64e9 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -856,7 +856,7 @@ type Server struct { |
| // revokeInstanceTokens, when set, replaces the delete door's revoke of |
| // the host's instance tokens (a TEST SEAM: a revoke that fails leaves a |
| // token that can only 404, instancetoken.go). |
| - revokeInstanceTokens func(ctx context.Context, accountID int64, app, host string, born int64, now time.Time) error |
| + revokeInstanceTokens func(ctx context.Context, accountID int64, app string, ids []string, now time.Time) error |
| // moveLogSettleHook, when set, runs as the drainer settles an |
| // unconfirmed declared_lost row, after it read the claim (a TEST SEAM: |
| // a declaration that commits in between, movesrecovery.go). |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 64ff17d7d..1a193385a 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -792,13 +792,13 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| } |
| // Census overlay 155 (amadan #155 half 1, instance-bound tokens): the |
| // console's cli_tokens gains an instance kind; eleven changed units are |
| - // re-frozen at main's dispositions, read sets unchanged in kind, and two |
| + // re-frozen at main's dispositions, read sets unchanged in kind, and three |
| // new SQL units are proposed at their siblings' disposition as |
| // PROPOSED-155, awaiting the Plan 14 controller's ruling on #280. |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-155.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - seal = "78159fba3932168c05430821bd5490d319d3a92ca3e066cab504c58784bc402d" |
| + seal = "dd8f3289d5a81d1c2961c707709d65e2eed2708270dfea7d636f57815972b109" |
| 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-155.tsv b/internal/provision/testdata/plan14-root-reads-155.tsv| index 823031f10..77e4ce7d4 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-155.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-155.tsv |
| @@ -1,5 +1,5 @@ |
| -# Plan 14 census overlay 155 (amadan #155 half 1, instance-bound tokens; loaded last, after census-ruling-home-part2). PROPOSED, awaiting the Plan 14 controller's ruling on #280: every changed unit is re-frozen at main's disposition with its read set unchanged in kind; the two new units are proposed at their siblings' disposition (claimRawNewAppToken, claimRawRevokeCLIToken: accepted-legacy;rev3=J4;basis=root-input) as rev3=PROPOSED-155. |
| -# What changed. The console's own SQLite: cli_tokens gains host and host_born (authstore migrateCLIAuth), a kind 'instance' that CLIPrincipal resolves and both listings (CLITokens, ServicePrincipals) select, the latter two leaving out an instance row whose app row is gone; purge (PurgeAppRow, PurgeClaimAppRow) deletes instance rows, rename (renameAppIn) and account move (MoveApp) carry them, and their stale-credential counts do not count them. New: claimRawNewInstanceToken (INSERT) and claimRawRevokeInstanceTokens (UPDATE revoked_at, scoped to host_born), both behind withAutoAppSQL. Templates: one more kind branch on the two credential tables. cli.go runInstances: one more sub-verb dispatch ("token"). No bucket, file, process or registry read is added or changed: every new read is the console database it already reads. |
| +# Plan 14 census overlay 155 (amadan #155 half 1, instance-bound tokens; loaded last, after census-ruling-home-part2). PROPOSED, awaiting the Plan 14 controller's ruling on #280: every changed unit is re-frozen at main's disposition with its read set unchanged in kind; the three new units are proposed at their siblings' disposition (claimRawNewAppToken, claimRawRevokeCLIToken, CLITokens: accepted-legacy;rev3=J4;basis=root-input) as rev3=PROPOSED-155. |
| +# What changed. The console's own SQLite: cli_tokens gains host and host_born (authstore migrateCLIAuth), a kind 'instance' that CLIPrincipal resolves and both listings (CLITokens, ServicePrincipals) select, the latter two leaving out an instance row whose app row is gone; purge (PurgeAppRow, PurgeClaimAppRow) deletes instance rows, rename (renameAppIn) and account move (MoveApp) carry them, and their stale-credential counts do not count them. New: claimRawNewInstanceToken (INSERT), LiveInstanceTokenIDs (SELECT of a host's live instance token ids, the delete door's snapshot) and claimRawRevokeInstanceTokenIDs (UPDATE revoked_at for exactly those ids, kind, account and app), the two writes behind withAutoAppSQL. Templates: one more kind branch on the two credential tables. cli.go runInstances: one more sub-verb dispatch ("token"). No bucket, file, process or registry read is added or changed: every new read is the console database it already reads. |
| # Envelopes: . (cli.go, apimode.go, instances_cli.go), internal/console, internal/console/templates, internal/instanceupdates (a comment) and internal/svcrole (Self and AllowSelf). |
| @legacy 81fa050f7f80e18bef87fd647dcbb5444284e4590ca77bae141f1eaf63c360d3 cli.go runInstances legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the carlos CLI: its API verbs on a person's machine as that person, its local listing by hand on a box as whoever runs it (no box unit): main's body with one more dispatch arm, import, which goes to the API-mode import verbs; read sites unchanged file:Open@1398,registry:App@3271,registry:Backing@3353,registry:Channel@3286,registry:Environment@3297,registry:Hibernate@3162,registry:Host@3263,registry:Kind@3278,registry:Replication@3338,registry:Status@3312,registry:Unit@3364,registry:Version@3322,whole:Route@3136 90ac75c402c0f9387224b37638ac1f71735938f8267f8de224cdac69a8d5c4c4 |
| cli.go runInstances legacy accepted-legacy accepted-legacy;rev3=census-ruling-seeded-instances;basis=the carlos CLI: its API verbs on a person's machine as that person, its local listing by hand on a box as whoever runs it (no box unit): main's body with one more dispatch arm, import, which goes to the API-mode import verbs; read sites unchanged file:Open@1398,registry:App@3271,registry:Backing@3353,registry:Channel@3286,registry:Environment@3297,registry:Hibernate@3162,registry:Host@3263,registry:Kind@3278,registry:Replication@3338,registry:Status@3312,registry:Unit@3364,registry:Version@3322,whole:Route@3136 90ac75c402c0f9387224b37638ac1f71735938f8267f8de224cdac69a8d5c4c4 |
| @@ -9,10 +9,12 @@ internal/console/authstore.go Store.CLITokens legacy accepted-legacy accepted-le |
| internal/console/authstore.go Store.migrateCLIAuth legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@1447,file:Exec@2798,file:Exec@3236,file:Exec@4078 7c64bb4da8ff85da69894d8d35e0d2c4f6f2bf16aa569e8525739181a9405c56 |
| @legacy 2f88505a2a3c35013a519410b4626d5cadaeb2f4d843d4baf09413dd95612791 internal/console/claim_app_identity.go Store.PurgeClaimAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@218,file:QueryRowContext@790 c8fc2e007f44c86f2d187a44cd717a5ec942e325a00996c47e52b6b6254da04a |
| internal/console/claim_app_identity.go Store.PurgeClaimAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@218,file:QueryRowContext@790 c8fc2e007f44c86f2d187a44cd717a5ec942e325a00996c47e52b6b6254da04a |
| +@legacy 5b36aac0ad61c28d6decdf7b252c9614f4660864d09d4e916a6cdf56a0df710d internal/console/instancetoken.go Store.LiveInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Query@106 0de6abf3e12dadd071c4656c4be38efe60dcb5b72956597d2c830dfc13378760 |
| +internal/console/instancetoken.go Store.LiveInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Query@106 0de6abf3e12dadd071c4656c4be38efe60dcb5b72956597d2c830dfc13378760 |
| @legacy 2f560698310d14a181885fb9780eb328cc17d223c899164fe0cc1412fefbd285 internal/console/instancetoken.go Store.claimRawNewInstanceToken legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@223 83b922e4c913313143b8269be2f7f51a2dd34871fc9be52bdf638a33c272d280 |
| internal/console/instancetoken.go Store.claimRawNewInstanceToken legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@223 83b922e4c913313143b8269be2f7f51a2dd34871fc9be52bdf638a33c272d280 |
| -@legacy 04a44845920e2b89e5bfc403fa02a76c0f67ec2eb08fe6239af4c3b6bffa5e6e internal/console/instancetoken.go Store.claimRawRevokeInstanceTokens legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@126 e16ae4c17aba3dd29ccb1b606f7bbfe8ddc57adf9ef9e3fd4ad4d182f3b9621e |
| -internal/console/instancetoken.go Store.claimRawRevokeInstanceTokens legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@126 e16ae4c17aba3dd29ccb1b606f7bbfe8ddc57adf9ef9e3fd4ad4d182f3b9621e |
| +@legacy d84fef2ccc67f10283a0c11e3b9fa32452ed399bc39c31b918fa9748b000f690 internal/console/instancetoken.go Store.claimRawRevokeInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@154 01868be0799ae3bd587a352c541742aa83716d327368328529e771e79131a960 |
| +internal/console/instancetoken.go Store.claimRawRevokeInstanceTokenIDs legacy accepted-legacy accepted-legacy;rev3=PROPOSED-155;basis=root-input file:Exec@154 01868be0799ae3bd587a352c541742aa83716d327368328529e771e79131a960 |
| @legacy e870152e6181307e7debc3fff4a8ba98c9ff2820f2bf131c18100b7feb0beee0 internal/console/store.go Store.MoveApp legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3969,file:Exec@4105,file:QueryRow@173,file:QueryRow@1945,file:QueryRow@311,file:QueryRow@3231,file:QueryRow@3821,file:QueryRow@5176,file:QueryRow@5293,file:QueryRow@756 821a6190e38573136c099740017b4603f2cd576b19d3ebceef8bacb5e9e1c3f9 |
| internal/console/store.go Store.MoveApp legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:Exec@3969,file:Exec@4105,file:QueryRow@173,file:QueryRow@1945,file:QueryRow@311,file:QueryRow@3231,file:QueryRow@3821,file:QueryRow@5176,file:QueryRow@5293,file:QueryRow@756 821a6190e38573136c099740017b4603f2cd576b19d3ebceef8bacb5e9e1c3f9 |
| @legacy 1d9c3495448e25b6e236261229acf5f135b08b486540f53bad919fa410a80fd8 internal/console/store.go Store.PurgeAppRow legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:QueryRowContext@549 c05fdfb05d250105b76c86249a09f53ce1d786900f0647cb5fed92671a1c7c82 |
| @@ -28,7 +30,7 @@ internal/console/templates/account_settings.html external legacy accepted-legacy |
| @legacy 51707c74b09db7a82b3d7e002be5ca4128222ad2982e1f17c86115deab569613 internal/console/templates/settings.html external legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:external@0 548151bab281e2104f47c4640ab3d6ed794e8031b1d5deaacc718d5636110d70 |
| internal/console/templates/settings.html external legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input file:external@0 548151bab281e2104f47c4640ab3d6ed794e8031b1d5deaacc718d5636110d70 |
| @coverage . 884fc3f51bc79c14ed09322cadd6adf2f927f9af98a38cc3705c16c8f7868637 |
| -@coverage internal/console ee1b6db9a0848b9759d260a8fdebc3ab542a792aa0705a8e0599fc872f9f3a2f |
| +@coverage internal/console dfc881b3ea8f79f92a166a697ff5e66c12ee28cdf1a5b0858cef642f61efd9f2 |
| @coverage internal/console/templates 4797ceb2096a72c8f5c00f2fcb4eb3c67c4604114e3a785e2d18cb57cdefd5e7 |
| @coverage internal/instanceupdates f896490a4a844dfa372d66510c1e2e510d4d7511b9db025ce22ecb565f4d6c0d |
| @coverage internal/svcrole 9dffb494a915f18aba94a903d1c6db4bb3687299f5888f394294ce95d21b3f2d |