Skip to content

Promoting a tenant to its own Supabase project

A tenant on the shared tier starts paying, or needs isolation the shared tier does not offer. This guide moves it onto its own Supabase project - copying its schema and rows - without invalidating the session it already holds.

It assumes you already have a shared tier running: a hub project issuing every tenant’s token, and a shared project separated by RLS on a tenant_id claim that tenant cannot write. That guide covers the trust wiring this one depends on and is the prerequisite for it. Where should a tenant live covers when a tenant is worth promoting at all, and what the dedicated project then costs.

  • One dedicated project that the tenant moves into, wired to trust the same hub.
  • A promotion path that copies the tenant’s rows without invalidating its existing session.
  • Optionally, a copy of the tenant’s identity itself, so the dedicated project can issue its own tokens instead of only verifying the hub’s.

Prerequisites: a Personal Access Token in SUPABASE_ACCESS_TOKEN, the service_role key for the shared and dedicated projects, jq, and the shared tier from the guide above already running.

Measured 2026-08-03 against live Supabase projects in ap-southeast-1 on Micro compute, Vault 0.3.1, with two tenants and three rows - the same fixture the shared-tenancy guide measures against. Claims marked measured in the evidence table were executed and captured.

Three further runs, all on 2026-08-04, extend that baseline. A client reading its {ref, key} from a discovery endpoint carried a live promotion with zero re-authentication. The identity-migration copy was extended to auth.mfa_factors and to retiring the source identity. And a third rotation run re-read both key sets at every probe for 37 minutes, separating the issuer’s publication lag from the consumer’s cache lag. Timings quoted for those three runs come from the run itself.

The rotation modules ran live again on 2026-08-25 (key-rotation R01-R03, two throwaway projects, a 20-minute window; published artifacts). That run reproduced the consumer-cache result, refuted the standby rate limit, and did not re-confirm the revoked-key result. Part 4 carries both dated runs.

FactValue
JWKS refreshcached in project config; measured: a trusting project did not accept new-key tokens within 20 minutes of a rotation, and honoured a revoked key for 15 (2026-08-03/04; the 20-minute result reproduced live on 2026-08-25 over 116 probes, the revoked-key result not re-confirmed on that run) - see Part 4: rotating the hub’s key

The hub issues every tenant’s token; the shared and the dedicated project each resolve the hub’s key set once and verify against it, and the tenant_id claim in the token is what RLS scopes the read by.

hub projectissues every tenant's tokenclientpresents the hub's tokenaccess tokenshared projectverifies the hub's key setRLS on tenant_idresolves key set, caches itdedicated projectverifies the hub's key set(after promotion)resolves key set, caches itrequest + tokenrequest + token

Part 1: promote the tenant to its own project

Section titled “Part 1: promote the tenant to its own project”

The tenant is paying. Give it a dedicated project without disturbing its session.

1. Provision and wire it. Create the project, apply the same schema, and register the hub’s JWKS on it, the same way as wiring the trust on the shared project. The schema stays identical, RLS included: the policy is harmless when the table holds one tenant, and identical schemas mean promotion is a data copy rather than a migration.

2. Confirm the token already works there. Before moving any data, the tenant’s existing token should be accepted by the new project and return nothing:

Terminal window
curl -s "https://<dedicated-ref>.supabase.co/rest/v1/items?select=tenant_id" \
-H "apikey: $DEDICATED_ANON" -H "Authorization: Bearer $TOKEN_A" | jq -c
# [] <- accepted, no rows yet. PGRST301 here means the JWKS wiring is wrong.

Distinguishing [] from PGRST301 is the whole check. Do it before the copy, while an empty result is still expected.

3. Copy that tenant’s slice. Filtered dump per tenant-scoped table:

Terminal window
for t in items other_table; do
psql "$SHARED_DB_URL" -c "\copy (select * from public.$t where tenant_id = 'tenant-a') to stdout" \
| psql "$DEDICATED_DB_URL" -c "\copy public.$t from stdin"
done

Reset sequences afterward, or the first insert on the target collides:

select setval('public.items_id_seq', coalesce((select max(id) from public.items), 1));

The collision this prevents is measured in the consolidation direction (tenant-consolidation C04: 23505 on the first write after the merge); the promotion modules resync auth.refresh_tokens_id_seq only.

Storage objects are not in the database. Copy them separately, per tenant prefix.

4. Repoint. Update your registry so tenant-a resolves to the dedicated ref, and verify with the same unchanged token:

Terminal window
curl -s "https://<dedicated-ref>.supabase.co/rest/v1/items?select=tenant_id,body&order=id" \
-H "apikey: $DEDICATED_ANON" -H "Authorization: Bearer $TOKEN_A" | jq -c
# [{"tenant_id":"tenant-a","body":"a-row-1"},{"tenant_id":"tenant-a","body":"a-row-2"}]

No re-login or token reissue. The issuer never changed.

5. Reap. The rows stay on the shared instance until you remove them - promotion is not destructive by itself, so keep them through a validation window, then:

delete from public.items where tenant_id = 'tenant-a';

Part 2: how clients learn where a tenant lives

Section titled “Part 2: how clients learn where a tenant lives”

A tenant’s project ref changes at promotion, and the ref appears in the URL the client calls. Two ways to absorb that:

  • A discovery endpoint. The client asks your control plane for its current ref and publishable key, caches the answer, and re-fetches on failure. A config lookup, not a proxy.
  • A gateway. Clients hit one stable host and it forwards to the right project, injecting keys, so they never see a ref at all. More moving parts and it sits in the data path.

The token is valid against both projects either way, which removes the gateway’s token-reissue job.

Measured 2026-08-04: the discovery endpoint is enough, and the gateway is not needed. A client that read its {ref, publishable key} from a registry at request time followed a tenant through a promotion with zero password logins - the refresh token it already held minted a session at the dedicated project, and the read returned the tenant’s rows there. The other tenant, untouched, still resolved to the shared project and still read its own row, so the flip is per-tenant. The only hosts contacted were the two projects: nothing terminated a request in between.

That leaves ref-hiding as the gateway’s last job, and it is a project setting rather than a component. A vanity subdomain activates over the Management API and gives the tenant a hostname with no project ref in it:

Terminal window
curl -sX POST -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d '{"vanity_subdomain":"acme-labs"}' \
"https://api.supabase.com/v1/projects/$REF/vanity-subdomain/check-availability"
# 201 {"available":true} <- 201, not 200
curl -sX POST -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
-H 'Content-Type: application/json' -d '{"vanity_subdomain":"acme-labs"}' \
"https://api.supabase.com/v1/projects/$REF/vanity-subdomain/activate"

Two things about that call. It wants a bare label, not a hostname - a dotted value is rejected with 400 before availability is evaluated. And the custom domains doc says vanity subdomains are configurable “via the CLI only”; the Management API accepts them directly.

One caveat the same run surfaced: after the move, the shared project keeps serving the tenant’s original token. Promotion is a copy, so both placements answer until the source identity is retired - see below.

Part 3: promotion moves the data, not the identity

Section titled “Part 3: promotion moves the data, not the identity”

A promoted tenant keeps its session, and that is the point of the trust wiring. It does not become independent, and the difference is measurable.

Measured
Refresh the hub-issued token at the dedicated project400 refresh_token_not_found - “Invalid Refresh Token: Refresh Token Not Found”
Refresh it at the hubnew access token, still accepted by the dedicated project
Access-token lifetime3600 s (exp - iat, and the hub’s jwt_exp)
Delete the hub, then read at the dedicated project with a live tokenHTTP 200 for the whole observation window; the integration still holds resolved_jwks
Delete the hub, then refresh there410 Gone, then the host stops resolving

Trust makes the dedicated project a verifier of the hub’s signatures, not an issuer of its own tokens. Verification does not need the hub to exist - the resolved key is held project-side, and a deleted hub’s tokens kept working. Minting does. So the hub is on the availability critical path for every tenant on every project that trusts it, and the blast radius of losing it is one access-token lifetime: an hour on defaults, after which nobody can renew and no dedicated project can recover alone.

Three ways out. Keep the hub on the critical path deliberately and give it the availability that implies; put issuance in a real external IdP so both tiers are verifiers; or move the tenant’s identity into its own project at promotion time. The third one is a runbook rather than an architecture change, so it is the one tested below - and it costs no re-login at all, which was not the expectation going in.

Migrating the identity, so the dedicated project issues its own tokens

Section titled “Migrating the identity, so the dedicated project issues its own tokens”

Unsupported: nothing in Supabase’s docs sanctions writing into the auth schema. Measured on throwaway projects, and smaller than expected.

Copying one table is enough to break the coupling. With a single auth.users row copied and no third-party-auth integration on the target at all, the tenant logs in at its own project, the token it gets back has iss pointing at that project, app_metadata.tenant_id survives the copy, the RLS read returns its rows, and the project refreshes its own tokens. This is identity migration, not federation; the trust wiring is what carries a tenant until it is promoted, not after.

Carrying the session is why this is a SQL copy rather than POST /auth/v1/admin/users: that endpoint mints a user but cannot carry the refresh token the user is already holding. Moving users between projects sets this against the consolidation direction, which accepts a sign-in and gets the supported endpoint in exchange.

Copying four tables carries the session, not just the identity. Add auth.identities, auth.sessions and auth.refresh_tokens, in that order after auth.users for the foreign keys, and the tenant’s existing refresh token works against the new project. No re-login anywhere.

-- On the target, per table, in FK order: users, identities, sessions, refresh_tokens.
-- `select *` does NOT work - see Gotchas and lessons learned.
do $mig$
declare cols text;
begin
select string_agg(quote_ident(column_name), ',' order by ordinal_position) into cols
from information_schema.columns
where table_schema = 'auth' and table_name = 'users' and is_generated = 'NEVER';
execute format(
'insert into auth.users (%s) select %s from json_populate_recordset(null::auth.users, $1::json)',
cols, cols) using $j$<rows as json from the source>$j$::json;
end $mig$;
-- Then, before the tenant's next refresh:
select setval('auth.refresh_tokens_id_seq',
(select coalesce(max(id), 1) from auth.refresh_tokens), true);

Two of the three unexercised parts were measured on 2026-08-04, on three runs that agreed.

MFA travels. A time-based one-time password (TOTP) factor enrolled and verified on the hub arrives at the target already status: verified when auth.mfa_factors is copied alongside the other four tables, and the same secret produces a code that verifies there for an aal2 session. Without this the zero-re-login result would have quietly excluded every account that had enrolled a factor - the accounts most likely to notice being logged out.

Retiring the source identity is one call, and it closes both doors. After DELETE /auth/v1/admin/users/{id} on the hub, the hub refuses a password grant with invalid_credentials and the previously issued refresh token with refresh_token_not_found, while the dedicated project carries on unaffected. Until you make that call, two projects can issue for the same tenant. What it does NOT do is move the data: the tenant’s rows stay on the shared instance, so reaping them is a separate, deliberate step.

Still not exercised: OAuth and SAML identities. That needs a real external provider, and a lab that fakes one is measuring the fake.

Part 4: rotating the hub’s key is a maintenance window

Section titled “Part 4: rotating the hub’s key is a maintenance window”

Every project that trusts the hub caches the hub’s key set. That cache is the whole story, and it makes rotation slow in both directions.

StepMeasured
Create a standby signing key2026-08-04: rate limited on a fresh project - Please wait until <ISO8601>, 144 s, 127 s and 131 s across three runs. 2026-08-25, live: refuted - a POST /v1/projects/{ref}/config/auth/signing-keys with {algorithm} while a standby was already present answered 422 "already has a signing key in standby" (R01b) and a create with none present answered 201 at once (R03c); the constraint is one standby at a time, with no wait
PATCH standby -> in_useprevious key moves to previously_used automatically
Hub starts issuing new-kid tokenswithin 5 s
Trusting project accepts a new-key tokennever within 20 minutes - 401 on all 80 polls (2026-08-03/04); 401 across 116 probes in 20 minutes on 2026-08-25
Old token after rotationstill accepted
Old token after that key is revokedstill accepted for 15 minutes - 200 on all 60 polls (2026-08-03/04; not re-confirmed on 2026-08-25, see below)

The mechanism is visible in the API while it happens: the integration’s resolved_at stays frozen at its pre-rotation value with only the old kid in resolved_jwks.

A third run on 2026-08-04, instrumented to re-read both key sets at every probe, separates two lags the first runs could only observe stacked. The hub published the new kid in its own JWKS in about 7 minutes; on the 2026-08-25 live run it took 241 s. The trusting project’s cached set held exactly one kid on all 282 probes and never re-resolved at all, across 37 minutes after the rotation. So the window is the consumer’s cache, not the issuer’s publication - which is also the second reason the DELETE + re-POST mitigation cannot work - the first is in Gotchas and lessons learned: a consumer forced to re-resolve early would have re-cached a key set that did not yet list the new key. The same run re-read each key’s status immediately before each request, so “revoked but still honoured” is now measured on both sides rather than inferred from a PATCH that returned earlier.

The 2026-08-25 live run did not re-confirm that row: its module order meant the trusting project never cached the revoked key, so that key’s tokens were refused for staleness rather than for revocation, and the 15-minute figure stands on the 2026-08-03/04 runs alone. The same run added a stronger cache result: a fresh integration created while the hub’s JWKS already advertised the new key still served the original kid set, so the consumer side alone defeats re-creation, whatever the issuer publishes. The signing-key surface also moved between the two runs - the keys are managed at /v1/projects/{ref}/config/auth/signing-keys with a PAT, and the project’s /auth/v1/admin/signing-keys is a 404.

Revocation does not revoke. Supabase’s signing-keys documentation says asymmetric revocation “is automatic via the key discovery endpoint”, which is true of the issuing project and false of a third-party consumer inside its cache window. During an incident the hub can rotate and revoke in seconds, and for the next half hour every trusting project will accept the compromised key and reject its replacement.

New sessions break first. Anyone who signs in or refreshes after the rotation gets a token no trusting project will accept, while everyone holding an older token carries on. That is the opposite of the usual rollout shape, where new code paths fail and old ones keep working - here the fresh credential is the broken one.

Plan a hub rotation as a scheduled window with sessions drained, or avoid the hub pattern where key rotation has to be fast. Nothing measured here makes it quick.

The practices this runbook carries, collected with the module or run each rests on. A practice with no module id is a design choice rather than a result; what this does not settle lists what nothing here measures.

PracticeEvidenceModule
Poll GET /v1/projects/{ref}/health?services=auth&services=rest&services=db before the tenant’s first request, then still retry the first write.2 of 2 fresh projects passed the per-service poll and failed the first admin/users call with 500 "Database error checking email", succeeding about ten seconds later.supabase-lab AGENTS.md, provisioning note (2026-08-03); platform-facts F02d
While the dedicated project only verifies the hub’s tokens (Part 3, stance one), point the client’s auth base URL at the hub and its REST URL at the discovered ref.The auth and REST URLs may both point at the dedicated project only after the identity copy (stance three). A refresh presented to the trusting project answers 400 refresh_token_not_found. Storage and Realtime acceptance of hub tokens is not measured.cross-project-auth X03 (2026-08-17); tenant-promotion P01 (2026-08-04)
Retire the source identity on the hub with DELETE /auth/v1/admin/users/{id} after the client’s first refresh, before reaping rows.Until the delete both projects issue for the tenant; after it the source refuses invalid_credentials and refresh_token_not_found while the target is unaffected.P03 (2026-08-04)
Create the standby key with POST /v1/projects/{ref}/config/auth/signing-keys and the body {algorithm}, using a PAT, one standby at a time.The project’s /auth/v1/admin/signing-keys is a 404. A create while one is already present answers 422 "already has a signing key in standby". Status values are standby, in_use, previously_used and revoked, and kid equals id.key-rotation 2026-08-25 live run (R01 premise refuted)
Budget the issuer’s publication at minutes and the consumer’s cache at the whole window, and hold new sign-ins through the window.The hub published the new kid in 241 s on 2026-08-25 and in about 7 minutes on 2026-08-04, while the trusting project’s cached set did not change across 116 probes in 20 minutes or 282 probes in 37 minutes; new sessions break first.key-rotation R02 (2026-08-04 bash; 2026-08-25 live)
Do not DELETE and re-POST the integration to clear the cache.Even after the hub’s JWKS shows the new kid: a fresh integration created while the hub demonstrably advertised the new key still served the original kid set.key-rotation 2026-08-25 (R03 side-finding)
Randomise or namespace test emails when rehearsing against the same project pair.adminCreate answers 422 on a duplicate address, so a rehearsal with constant addresses passes exactly once.tenant-promotion RUNLOG, “Emails are randomised per run”

Run these against the same two-tenant fixture immediately after a promotion. Each check confirms that one property of the move survived - the token, the copied rows, the source project’s state - rather than that access was ever denied; the negative controls for that are the build guide’s Verification table, not this one’s.

CheckHowExpected
Token portablesame token against the dedicated projectaccepted ([] before copy)
Promotion completeafter the copy, same token, dedicated projectthe tenant’s rows
Promotion non-destructivesame token, shared projectrows still present until reaped
Vault survivesselect decrypted_secret from vault.decrypted_secrets on the targetplaintext for secrets re-entered on the target; copied ciphertext fails with invalid ciphertext, and the root-key carry that would change that is not measured
Sequences resetinsert on the targetno PK collision
  • Identical schemas on both tiers. Promotion becomes a data copy. Keep RLS enabled on dedicated projects even with one tenant - it costs nothing and means the two tiers never diverge.
  • Vault does not survive the copy. The published rescue - carry the encryption root key first, while the source still exists - is not measured: vault-root-key has no runs, and its apply step has no known verb or path. The failure (invalid ciphertext on copied ciphertext) is documented in the region migration guide; V02 would measure it and has not run.
  • insert ... select * cannot work. auth.users.confirmed_at and auth.identities.email are GENERATED ALWAYS, and Postgres rejects any non-DEFAULT value for them (428C9). Enumerating columns from the catalog is also what makes the copy tolerant of the two projects running different auth schema versions, which any pair of projects created months apart will.
  • The bigserial sequence does not follow the rows. After landing a row with id = 1 the sequence still reported last_value = 1, is_called = false, so the next nextval() hands out an id that already exists. GoTrue rotates the refresh token on every use, so this detonates on the tenant’s first refresh and looks like session porting being unsupported. It is a missing setval.
  • A fresh project 500s for a while after it reports healthy. 500 with SQLSTATE 57P01 on the first call is Postgres restarting inside the settle window, not an answer. Retry before you believe a failure. Measured across five projects on 2026-08-04: create to ACTIVE_HEALTHY took 131-159 s, and all five then refused their first admin/users write with 500 unexpected_failure and accepted the second, one poll later. It is not a race you can get lucky with - budget the retry.
  • Do not carry the source’s auth.refresh_tokens.id into a target that has already seen a login. The setval in Part 3 assumes the target is pristine, which it is for a project provisioned for this promotion. Copy into one that has any prior auth activity and the insert dies on refresh_tokens_pkey instead, because the low ids are taken. Either let the target assign the id (the token string is what the client presents, and the setval then becomes belt-and-braces) or reconcile the ids yourself. Found on 2026-08-04 by running this copy twice against the same pair.
  • The obvious fix for a stuck trusting project does not work. There is no PATCH on the third-party-auth endpoint, so DELETE + re-POST is the only lever - and the re-created integration reported resolved: true while caching the OLD key again, because the hub’s own /auth/v1/.well-known/jwks.json was still advertising only that key minutes after the rotation. Two lags are stacked: the issuer’s JWKS publication and the consumer’s cache. Forcing the consumer to re-resolve buys nothing while the issuer still publishes the old key set. On 2026-08-25 the consumer half was measured alone: a fresh integration created after the hub’s JWKS already advertised the new key still cached the original kid set, so re-creation fails even once the issuer has caught up.

Stated plainly, because the architecture is only partly demonstrated. The routing layer used to head this list and no longer does: it was measured on 2026-08-04, the discovery endpoint carried a promotion with no re-authentication and nothing in the data path, and ref-hiding turned out to be a vanity subdomain rather than a proxy. See the section above.

  • The unexercised parts of the identity migration. MFA factors and retiring the source user were measured on 2026-08-04. OAuth and SAML identities were not, and need a real external provider.
  • The rotation anomaly. In one of two runs the old token started failing before its key was revoked, while that key was still the only one the trusting project had cached. A third run on 2026-08-04, capturing the JWKS body, the PostgREST error code and each key’s status at every probe, did not reproduce it: the old token returned 200 on all 140 of its probes. That makes the observation unstable rather than explained - one non-reproduction identifies no mechanism - so it stays here.
ClaimHow it was checkedResult
A promoted tenant can refresh against its own projectPresented the hub’s refresh token to the dedicated project, and to the hub as controlMeasured - no. 400 refresh_token_not_found at the dedicated project; the hub issues fine and the result is still accepted there
Verification needs the issuer to be aliveDeleted the hub, then kept reading at the dedicated project with a live tokenMeasured - no. HTTP 200 for the whole window, resolved_jwks still present, while refresh at the hub went 410 Gone
The cost of losing the hubRead exp - iat and the hub’s jwt_expMeasured - 3600 s, so one hour to lockout on defaults
The coupling can be removed by copying auth.usersCopied one row to a project with NO integration configured, then logged in thereMeasured - login succeeds, iss is the new project, the claim survives, RLS holds, and it refreshes its own tokens
A rotation on the hub propagates promptlyRotated to a new ES256 key, then polled a new-key token against the trusting project for 20 minutesMeasured - no. 401 on 80/80 polls, with the integration’s resolved_at frozen pre-rotation (2026-08-03/04); 401 across 116 probes in 20 minutes on 2026-08-25, cached kid set unchanged
Revoking a key stops trusting projects accepting itMarked the old key revoked, then polled its token for 15 minutesMeasured - no. 200 on 60/60 polls (2026-08-03/04). Not re-confirmed on 2026-08-25: the trusting project never cached the revoked key on that run
Re-creating the integration clears the stale cacheDELETE + re-POST, then polled at 500 msMeasured - no. Cached the old kid again; the hub’s own JWKS was still advertising only that key (2026-08-04). On 2026-08-25 a fresh integration created after the hub’s JWKS advertised the new key still served the original kid set
The tenant’s live session can be portedCopied users, identities, sessions and refresh_tokens in FK order, resynced the sequence, presented the OLD refresh tokenMeasured - accepted, new token issued by the new project. Zero re-logins
A discovery endpoint carries a promotion without a gatewayClient re-read its {ref, key} from a registry after the flip and continued with the refresh token it already heldMeasured - zero password logins, rows read at the dedicated project, the other tenant unaffected, only the two project hosts contacted
Ref-hiding needs no proxycheck-availability then activate on the dedicated projectMeasured - both 201, hostname carries no ref. Wants a bare label; the docs say CLI-only, the API accepts it
MFA survives promotionEnrolled and verified a real TOTP factor, copied auth.mfa_factors, verified the same secret at the targetMeasured - factor arrives verified, target session reaches aal2. Three runs
Retiring the source identity closes both issuing pathsDELETE /auth/v1/admin/users/{id} on the hub, then password grant and old refresh token there, and a login at the targetMeasured - invalid_credentials and refresh_token_not_found at the hub, target unaffected, the tenant’s rows still on the hub
The rotation window is the consumer’s cache, not the issuer’sRe-read the hub’s JWKS and the trusting project’s cached set at every probe for 37 minutesMeasured - hub published the new kid in about 7 minutes; the consumer never re-resolved, 282 probes (2026-08-04); 241 s and 116 probes over 20 minutes on 2026-08-25
A standby key is rate limited on creationPOST /v1/projects/{ref}/config/auth/signing-keys twice on a fresh hub, 2026-08-25Refuted - a create with a standby already present answered 422 "already has a signing key in standby" (R01b) and a create with none present answered 201 at once (R03c); the 2026-08-04 Please wait until <ISO8601> refusals (144 s, 127 s, 131 s) did not recur
Create to writable, not just healthyTimed five projects from create to the first admin/users write that succeededMeasured - 131-159 s to ACTIVE_HEALTHY; 5 of 5 refused the first write and took the second
Copying auth.refresh_tokens ids into a used targetRan the copy twice against the same project pairMeasured - refresh_tokens_pkey collision the second time; let the target assign the id
One token validates against two projectsSame unchanged token against bothMeasured - accepted by both
Promotion works without re-loginCopied one tenant’s rows, re-read with the same tokenMeasured - rows returned, no re-auth
Promotion is non-destructiveRe-read the shared project after the copyMeasured - rows still present
Vault survives a transfer but not a copyCross-referenced from the transfer labMeasured - decrypts after transfer, invalid ciphertext after pg_dump

The harness for these runs lives in the supabase-lab repo under experiments/tenant-promotion/:

FileWhat it holds
tests/p01-promotion-follows.tsthe discovery-endpoint client following a promotion with the refresh token it already held
tests/p02-mfa-survives.tsthe TOTP factor copied with auth.mfa_factors and verified at the target
tests/p03-retire-source-identity.tsretiring the source user: both issuing paths fail at the source, target unaffected
tests/p04-vanity-subdomain.tsthe check-availability + activate calls and the bare-label rule
lib/promote.tsthe auth table copy and the setval resync
RUNLOG.mdthe per-test narrative and what the first live run changed, pinned to the lab commit; the raw probes are not published because they carry project refs
key-rotation/out/2026-08-25/the redacted artifacts of the 2026-08-25 rotation run Part 4 quotes (R01-R03)
ModuleExperimentTestArtifact
C04tenant-consolidationc04-key-collision.tsnone published
F02dplatform-factsf02-project-surface.tsnone published
P01tenant-promotionp01-promotion-follows.tsnone published
P03tenant-promotionp03-retire-source-identity.tsnone published
R01key-rotationr01-rate-limit.tsout/2026-08-25
R01bkey-rotationr01-rate-limit.tsout/2026-08-25
R02key-rotationr02-no-reresolve.tsout/2026-08-25
R03key-rotationr03-revoked-key.tsout/2026-08-25
R03ckey-rotationr03-revoked-key.tsout/2026-08-25
V02vault-root-keyv02-copy-without-key.tsnone published
V04vault-root-keyv04-deleted-source.tsnone published
X03cross-project-authx03-refresh-to-issuer.tsnone published