Skip to main content

Deployment

Constellation is deployed on Vercel as a multi-zone topology:

ZoneHostVercel project
Directory (root)constellation.planetb2b.comconstellation-directory
Project Tracker/projects/* (rewritten)constellation-platform
Catalog/catalog/* (rewritten)constellation-catalog
Docsdocs.planetb2b.comconstellation-docs

Directory is the root zone and rewrites requests for /projects/* and /catalog/* to the other Vercel projects.

NEXT_PUBLIC_BASE_PATH​

Sub-zone apps read this env var at build time:

EnvironmentValueBehaviour
Production/projects or /catalogMulti-zone: served under prefix, rewrites from Directory
PreviewunsetStandalone: served at / for preview deploys
Local devunsetStandalone: turbo dev on app-local port

fetch() and basePath​

Next.js auto-prepends basePath to <Link> and router.push, but not to fetch(). Use the apiUrl() helper from each app's src/lib/api-url.ts:

import { apiUrl } from '@/lib/api-url';
const res = await fetch(apiUrl('/api/people')); // '/projects/api/people' in prod

Migration gate on release PRs​

Every PR targeting main (release branches and hotfixes) runs the Verify Release Migrations (main) check. It fails the merge if either staging or production is missing a migration that's on the branch. Its read-only credentials live only in the Release Migrations GitHub environment, which admits the default branch (develop) alone. The workflow runs on pull_request_target, which GitHub always reads from the default branch, so no other branch's workflow can read them. The job is read-only — it never applies migrations. It also never runs the branch's own code: the checker comes from main, and the branch's migration files are read as data (INF-219), so a checker change applies one release after it ships. Apply outstanding migrations to both databases via the Supabase dashboard SQL editor or the Supabase MCP (apply_migration / execute_sql) before opening the release PR.

A migration containing CONCURRENTLY cannot go through a transaction

Scan the migrations you are about to apply first — recursively, since an entry can be a directory:

grep -rl CONCURRENTLY apps/<module>/prisma/migrations/<entry>

PostgreSQL rejects CREATE INDEX CONCURRENTLY / DROP INDEX CONCURRENTLY outright inside a transaction block, so a tool that wraps what it is given does not merely apply them more slowly — the migration fails. Those files are written deliberately without BEGIN;/COMMIT; to avoid holding an ACCESS EXCLUSIVE lock on a busy table for the length of a full index build, and they say so in their header.

Apply them with a session that does not wrap, and always with ON_ERROR_STOP=1 — psql's default is to report an error and carry on, which lets a migration fail its DDL and still run its final schema_migrations insert, recording an incomplete migration as applied:

psql "$PROD_DATABASE_URL" -v ON_ERROR_STOP=1 -f <file>

A migration entry can hold more than one file — 033_tasks_backlog_grooming is migration.sql plus post_migration.sql, and the grep points only at the one containing CONCURRENTLY. Apply every .sql in the entry in runner order (migration.sql first) and record the tracking row only once they have all succeeded; stamping the entry after applying one file marks the whole migration as done while the rest never ran.

The raw psql path also bypasses the module runner's tracking-row write, and not every migration self-inserts. Check the file for a schema_migrations insert and add one yourself if it has none, or the release gate stays red against a database that is already up to date — chained to the apply with && so it cannot stamp a migration that failed, into the owning module's schema (see scripts/check-migrations.ts), and keyed on the migration entry name (a directory-based migration is tracked by its directory, not by the inner .sql file).

If you use the dashboard or MCP instead, run the concurrent statement on its own and then confirm the index is valid: a failed concurrent build leaves an INVALID index behind, which a later re-run guarded by IF NOT EXISTS will skip straight past.

SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;

Full procedure: .claude/skills/release-and-migrations/SKILL.md § Migrations Before the PR.

A ledger row with no file on disk is an orphan, which fails --strict, unless it is the pre-rename filename of a registered migration rename whose renamed file is on disk and applied (MIGRATION_RENAMES in packages/platform/testing/src/migration-renames.ts, PLT-979). The gate lists such a row as superseded, with the one-line DELETE that clears it, and does not fail on it; for a ledger the shared runner (runPrismaMigrations) migrates, db:migrate also clears it on its next run. Renumbering a migration therefore means adding its pair to that registry in the same PR. A registry entry that cannot describe a rename — a self-rename, an old filename registered twice, a chain, or an old file still on disk beside another name of the same migration — fails its module outright, and db:migrate refuses it before its first statement (PLT-827). A migration renamed twice registers each old filename against its current file.

See scripts/check-migrations.ts (invoked with --strict from the gate) for the implementation.

Platform packages carry their own migrations​

Migrations do not live only under apps/<module>/prisma/migrations. A shared package can own a schema too: @constellation-platform/jobs owns jobs and @constellation-platform/events owns events. Their files are in packages/platform/<package>/migrations, applied by the package's own db:migrate, and tracked in <schema>.schema_migrations like any module's. npx turbo run db:migrate picks them up with everything else; turbo.json runs the events package first, then jobs, then @constellation/project-tracker#db:migrate, because project-tracker's older migrations create the same jobs.queue and events objects.

Concurrent migration runs wait for each other​

That ordering holds within one turbo run. Two independent runs against the same database — two operators, or CI and a person — are serialised by a database-level lock instead (PLT-829): every transaction the shared runner (runPrismaMigrations) opens, and the jobs and events baseline steps, first take the transaction-scoped advisory lock (829, 0). A second run therefore waits while the first is applying a file, rather than failing, then re-reads its ledger and skips anything the first applied. The wait is bounded on the server by MIGRATION_TX_TIMEOUT_MS (ten minutes by default), set as the lock_timeout of the acquire alone; past it the run fails with could not take the migration lock (829, 0). A timeout rolls back only the transaction that was waiting, so files the same run applied earlier stay recorded, and a re-run continues from the ledger. To see who holds the lock: SELECT pid, granted FROM pg_locks WHERE locktype = 'advisory' AND classid = 829 AND objid = 0;.

Catalog, Directory, Wiki, jobs and events take part. Project Tracker does not yet: its db:migrate is a psql runner (setup-local-db.ts) that does not take the lock, so it can still run concurrently with another module's run (PT-1418), and neither does scripts/apply-coordinator-migrations.sh (INF-775). Do not start two db:migrate runs that include Project Tracker against one database at once.

Baseline adoption on databases that already have jobs.queue​

Project Tracker's grandfathered 013_jobs_queue.sql created jobs.queue long before the package had a runner, so on staging, on production and on any machine that has run PT's db:setup, the table exists while jobs.schema_migrations does not. Re-applying 001_jobs.sql there is not what you want, and neither is skipping it silently.

The runner therefore baseline-adopts 001: it records the file as applied without executing it, but only after comparing the deployed table against a reference the same server builds from 001's own declaration — columns, constraints, indexes, RLS flags and every policy's command, kind, role scope and predicate. The reference is a closed set, so an object neither 001 nor 002 declares is refused rather than tolerated: an extra permissive policy is a cross-tenant read path that applying 001 would not remove, and an extra UNIQUE index rejects enqueues the schema permits.

Three outcomes, and each is loud:

  • matches — 001 is recorded as applied and never executed, then 002 runs normally;
  • differs repairably (a missing index, RLS not forced, a policy 001 recreates) — adoption is declined and 001 is applied, which is written entirely in IF NOT EXISTS / DROP POLICY IF EXISTS form;
  • differs unrepairably — the runner aborts and names the difference. Nothing is recorded. Reconcile the database by hand before re-running; a recorded migration is a claim every later migration acts on.

Baseline adoption on databases that already have the events schema​

Project Tracker's grandfathered 006_events_outbox.sql built events.outbox, events.subscriptions and events.deliveries — with their policies, the platform_dispatcher grants and the NOTIFY trigger — long before the package had a runner. 001_outbox.sql cannot be applied over them: its policies and trigger are created unguarded.

The events runner therefore adopts 001 only when every object it declares is present and identical, compared on the server's own catalog against a reference built from 001's text: every column, constraint (foreign keys included), index, policy, trigger and rule of the three tables, the trigger function, and platform_dispatcher's effective privileges, which must be exactly what 001 grants — SELECT, INSERT, UPDATE on each table — or, per table, the narrower set PLT-1045's revoke leaves (SELECT plus UPDATE on the dispatched_at column on outbox, SELECT, INSERT on subscriptions), with no grant option and no withheld verb on any column, USAGE alone on the schema (ADR-036 § D5), and a NOLOGIN role with no elevated attribute that is a member of no other role. On a database with none of the tables it applies 001 instead, and runs the same check before recording it — applying repairs neither a pre-existing role's attributes nor a default privilege that lands on the new tables. There is no repair path, because 001 cannot be re-applied: any difference aborts the run, names it, and records nothing. Because the runner orders before project-tracker's and the jobs package's migrations, a refusal stops those too. The narrower set is accepted only on a database whose objects already existed: project-tracker's forward repair 20261007075949_revoke_dispatcher_unexercised_privileges.sql narrows them wherever PT's migrations run without the Turbo graph (CI's PT-only jobs, a standalone db:setup). On a fresh database, applying 001 must still produce 001's grant, and the events package's own revoke follows it. The same holds for the policies: on a database whose objects already existed, a table's dispatcher policies may already be PLT-1761's per-command set (outbox_dispatcher_read and outbox_dispatcher_update on outbox, subscriptions_dispatcher_read and subscriptions_dispatcher_insert on subscriptions) instead of 001's FOR ALL one, because project-tracker's forward repair 20261007115720_split_dispatcher_policies_per_command.sql makes that change wherever PT's migrations run first. Adoption accepts exactly the set the events migration 20261007115719_split_dispatcher_policies_per_command.sql produces, derived from that file's own text, and nothing in between.

Before a release that first carries this runner, or whenever a deployed database may have been changed by hand, check it read-only:

DIRECT_URL=<target> npx turbo run db:inspect-baseline --filter=@constellation-platform/events

It takes no lock and leaves nothing behind — on a database with none of the tables it rehearses applying 001 under a savepoint and rolls it back — and exits non-zero listing what db:migrate would refuse. On a database whose ledger already records 001 it says so and checks nothing further, because db:migrate then skips the baseline too. To recover from a refusal, reconcile each named object with 001_outbox.sql by hand — restore the policy, revoke the extra privilege — and re-run db:migrate. The adoption transaction waits at most ten seconds for each of its three table locks. While it waits, writers queued behind it wait too, and once it holds a lock, writers to that table wait until the inspection commits — so the delay it adds is bounded, per lock and by the inspection's own length, not eliminated. A timeout records nothing and is safe to re-run. A refusal reports what it found, and a partial set of tables or a missing platform_dispatcher role is reported before the checks that depend on it, so reconciling may take more than one run.

The preflight checks 001 only. The two events migrations after it each refuse to record themselves unless exactly what they declare results. The first, PLT-1045's narrowing of platform_dispatcher (no INSERT on events.outbox, UPDATE there on dispatched_at only, no UPDATE on events.subscriptions), refuses to record itself unless exactly that results. A grant the REVOKE cannot remove (through PUBLIC, an inherited role, another grantor, or a single column) refuses it, and so does a GRANT UPDATE (dispatched_at) the applier was not entitled to make. The second, PLT-1761's split of the FOR ALL dispatcher policies per command, refuses unless the permissive policies on events.outbox and events.subscriptions are exactly the tenant policies and the per-command dispatcher policies, each compared on its whole definition: any other permissive policy, such as a hand-made one, would OR past the tenant checks. Restrictive policies are not checked, since they can only narrow access. It takes its two table locks up front, subscriptions first, the order a dispatch cycle takes them in, so a cycle running during the release cannot deadlock it. Because the events runner runs first, either refusal stops the jobs and project-tracker migrations too. So before the release that carries them, rehearse both on each target, in order. The rehearsal rolls back whatever happens:

psql "<target DIRECT_URL>" -v ON_ERROR_STOP=1 --single-transaction \
-c "SET LOCAL lock_timeout = '10s'" \
-f packages/platform/events/migrations/20261007075948_revoke_dispatcher_unexercised_privileges.sql \
-f packages/platform/events/migrations/20261007115719_split_dispatcher_policies_per_command.sql \
-f packages/platform/events/migrations/20261009111327_remove_events_function_default_privilege.sql \
-c 'ROLLBACK'

A clean rehearsal ends with ROLLBACK and then psql's harmless WARNING: there is no transaction in progress. A PLT-1045: error names the surviving privilege: remove it as its grantor, then rehearse again. A PLT-1761: error names each policy that is absent, different or undeclared: drop or restore it by hand, then rehearse again. Re-running the revoke on a target that already has it changes nothing.

PLT-1724's removal of the hand-installed events function default privilege can refuse too, so it is in the rehearsal above. It revokes every function default in schema events that grants constellation_app under a creator the applier can act for, then refuses, without recording itself, while one still reaches the runtime role directly, through a role it inherits, or through a role it may SET ROLE to. A PLT-1733: error names the creator roles whose default remains: read pg_default_acl for schema events and pg_auth_members for constellation_app, revoke the default as that creator, then rehearse again. A PLT-1733 FATAL error means only that the applier may not insert into events.schema_migrations. Like the other two, either refusal stops the jobs and project-tracker migrations behind the events runner.

Every cross-tenant drain audits its claim and its settle​

Five drains claim jobs.queue rows across tenants: the wiki's /api/cron/process-jobs and Project Tracker's notification, agent-run, run-score and cycle-retrospective drains. Since PLT-1342 all five run one shared transaction machine, runInWorkerBypassTransaction in @constellation-platform/jobs, on the confined constellation_jobs_worker login. What an operator can rely on:

  • Every claim and every mutating settle writes an auditCritical() entry in its own transaction — wiki.job.claimed / wiki.job.settled, or projects.job.claimed / projects.job.settled — attributed to the job's tenant, with actor SYSTEM and resource type jobs.queue. The entry records lifecycle fields only (job type, status, attempts), never the payload or the last error.
  • An audit or outbox failure rolls the queue change back. A refused claim leaves the job pending for the next tick; a refused settle leaves it running for the stale-claim lease. So the drain's audit and outbox privileges are a hard prerequisite, and its preflight refuses to start without them. On the audit side those are USAGE on the audit schema and EXECUTE on audit.write_entry, the audit chain's only writer. The drain does not need direct write access to audit.audit_entries or audit.chain_heads (PLT-1764).
  • A settle is refused, and writes nothing, when the job has moved on since it was claimed or observed — reclaimed at a higher attempts, or no longer running. The drain logs it and does not count the job as drained.
  • A suspended tenant's jobs are still claimed and audited. Attribution deliberately bypasses Project Tracker's tenant-liveness gate (ADR-044 § D7); gating it would stall the job type for every tenant. Org-less notifications are audited under the all-zeros tenant they are stored under, which no tenant session reads.

Runtime-role grants travel with the migration​

002_worker_claim_policy_and_runtime_grants.sql grants constellation_app schema USAGE plus SELECT, INSERT, UPDATE on jobs.queue — not DELETE — and read-but-not-write on jobs.schema_migrations. This is deliberate and it is why enqueuing works on a deployed database at all: RLS grants nothing on its own, and locally the privileges came from scripts/db-init.sql, which is a dev-only fixture. Without them the symptom is 42501 out of PostgresJobQueue.add() and out of every worker claim.

The grants are guarded on the role existing, and followed by hard postconditions — including a negative one, because REVOKE reports success whether or not a grant existed and so says nothing about write access reaching the role through PUBLIC or through a role it is a member of. If the migration role lacks authority to grant, the file raises instead of being recorded: the shared runner suppresses any statement that fails with 42501 and logs it as a skip, which would otherwise leave the ledger claiming work that never happened.

The events package does the same for publish() since PLT-1745. 20261007125232_runtime_publisher_grants.sql grants constellation_app schema USAGE on events, INSERT on events.outbox and SELECT on its id column only, which the RETURNING id needs. It does not grant table-level SELECT, because outbox_tenant_read scopes reads to the tenant rather than to the row just written, so that grant would expose every outbox payload the tenant holds. Where constellation_app exists, the file refuses to be recorded unless all three privileges are present. Where the role does not exist yet, the file grants nothing and is still recorded, so provision the role before the first events migration runs (PLT-424 tracks the general fix).

It narrows nothing that is already there, and it sets no default privilege on events. A database where constellation_app already holds table-level privileges on events.outbox keeps them. Locally that comes from scripts/db-init.sql's default privilege, and in a deployed environment it may come from INF-60's hand provisioning. A column grant next to a table-level one does not narrow it. Whether to revoke the wider grant is PLT-1482's decision.

audit migration 013: apply it in one transaction​

Applies to Dedicated Cloud, On-Prem, and any database that has not yet applied packages/platform/db/migrations/013_audit_chain_heads_rls.sql — and to any database that records it but was never verified. SaaS staging and production applied it in one transaction on 2026-09-13, starting from no policies on the table, and its check passed on both; they need nothing. (PLT-1225)

013 turns on tenant row-level security for audit.chain_heads. It creates each of its three policies only when that policy name is missing. Then it enables and forces RLS. Only after that does it check that the table carries exactly those three policies with its definitions. The file opens no transaction of its own. So when it is applied one statement at a time (psql -f without -1), the ENABLE and FORCE have already committed when that check runs. If the table already had a same-named policy defined differently, or an extra permissive policy, the check fails but RLS stays on with that policy in force. For example, a leftover USING (true) select policy lets every role read every tenant's chain heads. A same-named policy that is stricter than 013's fails the same check and takes the same repair. Without ON_ERROR_STOP psql keeps going after the failure and records 013 as applied, so the migration ledger shows nothing wrong.

Before applying, check the table. On a database that has not applied 013 this returns f | f | {}. If policies is not empty, go to the repair below instead of applying:

SELECT c.relrowsecurity, c.relforcerowsecurity,
array(SELECT polname FROM pg_policy WHERE polrelid = c.oid ORDER BY polname) AS policies
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'audit' AND c.relname = 'chain_heads';

Apply 013 on its own, in one transaction, after 001–012:

psql "$DIRECT_URL" -X -v ON_ERROR_STOP=1 -1 \
-c "SET LOCAL lock_timeout = '5s'" \
-f packages/platform/db/migrations/013_audit_chain_heads_rls.sql

-1 puts the -c and the whole file in one transaction, so if the check fails the ENABLE is rolled back too. -X skips ~/.psqlrc, where settings such as AUTOCOMMIT or ON_ERROR_ROLLBACK could change what commits. Use -1 for this file only, not the whole directory: 004_audit_partitioning.sql opens and commits its own transaction, and -1 does not work as intended on a file that does.

Run from the repository root, npm run db:migrate also applies 013 in one transaction. It reaches packages/platform/db/migrations through Project Tracker's migration runner, which applies 013 with --single-transaction. It sets no lock timeout, though. On a live database its ACCESS EXCLUSIVE request can wait indefinitely behind an open transaction, with every audit write queued behind it, so use the command above there. A checkout from before PLT-1225 applies 013 one statement at a time; use the command above on those too.

Creating a policy and enabling RLS each take an ACCESS EXCLUSIVE lock on audit.chain_heads, held until the transaction ends. That blocks reads as well as writes, so audit writes that advance the hash chain wait for it. The lock request also queues behind any transaction already using the table. Apply in a quiet window. lock_timeout limits each lock wait on its own, not the apply as a whole. If any one lock cannot be acquired within 5 seconds, the apply gives up (exit 3, table unchanged). It does not bound how long audit writes are blocked. They wait while the apply waits for its locks, and those waits can add up past 5 seconds. Once it holds the table lock, they wait until its transaction commits or rolls back. It is set with SET LOCAL inside the transaction rather than through PGOPTIONS, which a connection string carrying its own options= silently overrides.

If 013 fails with tenant RLS was enabled on audit.chain_heads but recording the tracking row in audit.schema_migrations was refused, do not follow that message's advice to insert the row by hand. Under -1 the message is wrong: the rollback has already undone the ENABLE, so a hand-inserted row would record 013 with RLS off. Grant the applying role both privileges the ledger insert needs, INSERT and SELECT (filename) (its ON CONFLICT (filename) reads that column): GRANT INSERT, SELECT (filename) ON audit.schema_migrations TO <applying role>. Then apply again.

Verify, both after applying and on any database that already records 013 but was never checked. The first query must return t | t with the three names below, and this one must return exactly these rows:

SELECT policyname, permissive, roles, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'audit' AND tablename = 'chain_heads'
ORDER BY policyname;
policynamepermissiverolescmdqualwith_check
chain_heads_tenant_isolationPERMISSIVE{public}SELECT((tenant_id)::text = current_setting('app.tenant_id'::text, true))empty
chain_heads_tenant_isolation_insertPERMISSIVE{public}INSERTempty((tenant_id)::text = current_setting('app.tenant_id'::text, true))
chain_heads_tenant_isolation_updatePERMISSIVE{public}UPDATE((tenant_id)::text = current_setting('app.tenant_id'::text, true))((tenant_id)::text = current_setting('app.tenant_id'::text, true))

Repair, when the preflight shows a policy, 013's policy check refuses it, or the verification fails. First inspect the policies that are there. Then replace them all in one transaction. The command drops every policy on the table and re-runs 013, which recreates its three and checks them again. It takes the same ACCESS EXCLUSIVE lock for the length of that one transaction, so run it in a quiet window too:

psql "$DIRECT_URL" -X -v ON_ERROR_STOP=1 -1 \
-c "SET LOCAL lock_timeout = '5s'" \
-c "DO \$\$ DECLARE p record; BEGIN FOR p IN SELECT polname FROM pg_policy WHERE polrelid = 'audit.chain_heads'::regclass LOOP EXECUTE format('DROP POLICY %I ON audit.chain_heads', p.polname); END LOOP; END \$\$" \
-f packages/platform/db/migrations/013_audit_chain_heads_rls.sql

Never insert the audit.schema_migrations row by hand to get past the check.

Why 013 itself is not fixed. It has shipped and been applied. The ledger matches migrations by filename alone, so an edited 013 would never run on a database that already recorded the original. A new migration cannot close the gap either: it runs after 013, when RLS has already been switched on.

Upgrade prerequisite: the wiki INTERNAL classification tier​

Applies to Dedicated Cloud and On-Prem. On SaaS the platform controls the rollout and there is nothing to do.

The INTERNAL classification tier reaches the wiki across two releases, and they must be installed in order:

ReleaseWhat it does
N — the release carrying PLT-502Every wiki build can read INTERNAL. Nothing can create one: the wiki.classification domain and every write schema still reject it.
N+1 — the release carrying PLT-977Widens the domain and the write schemas, so INTERNAL pages become creatable.

Release N is the minimum version from which release N+1 may be installed. Upgrading straight from N-1 to N+1 skips the waypoint: the outgoing build then runs a four-value event validator while the incoming one can already publish an INTERNAL payload, and the outgoing dispatcher rejects it. Deliveries are retried rather than lost, but a repeatedly-failing row holds a slot at the head of an oldest-first outbox scan until the old build retires, so a long-tailed upgrade pays throughput for it.

Nothing enforces this mechanically on these tiers — the operator chooses when and from what version to upgrade, and this repository has no sequential- upgrade gate. That is why it is written here as a prerequisite rather than assumed. The reasoning, and the residuals it accepts, are in ADR-032 § The rolling-deploy story.

The exposure begins when somebody classifies a page INTERNAL, which is an operator action against this documented prerequisite — not something the upgrade performs on its own. A deployment that cannot accept that should not take the tier: raise the case rather than skipping the waypoint, since the strategy lives in the artifact and cannot be selected per deployment.

Environment variables per Vercel project​

Vercel projectVarValueEnvironments
constellation-platformNEXT_PUBLIC_BASE_PATH/projectsRequired on production. Standalone preview/dev unset (serves at /)
constellation-catalogNEXT_PUBLIC_BASE_PATH/catalogRequired on production. Standalone preview/dev unset
constellation-directoryPROJECTS_ZONE_URLhttps://constellation-platform.vercel.appProduction and trusted develop-scoped staging only (local dev may use localhost)
constellation-directoryCATALOG_ZONE_URLhttps://constellation-catalog.vercel.appProduction and trusted develop-scoped staging only (local dev may use localhost)

The resolveBasePath() helper in scripts/resolve-base-path.ts fails fast if NEXT_PUBLIC_BASE_PATH is missing on Vercel production — without it the sub-zone app deploys at / and breaks multi-zone routing.

The checked manifest and the nightly lockstep check​

The manifest describes environment bindings, not deployment freshness. Directory, Catalog, Wiki and Platform deploy develop automatically (see Staging refresh), but a passing variable check still does not prove staging has refreshed or its zones are compatible.

The table above is prose. The checked source for which variable names each Vercel project must hold is scripts/vercel-env-matrix.json (INF-824). It covers the six projects, per environment: production, preview (the generic Preview a feature-branch deployment sees) and staging (Preview explicitly scoped to develop; generic Preview must be empty). It also declares the lockstep groups, the values that must be identical across projects within one environment: the shared JWT and Supabase secrets, KB_READS_EMITTER_SECRET, CRON_SECRET on Project Tracker and the wiki, the MCP client secret (directory's MCP_CLIENT_SECRET and the connector's DIRECTORY_CLIENT_SECRET), and the user and password of every zone's runtime DATABASE_URL.

npm run check:vercel-env compares the manifest with the live projects, and the nightly Vercel Env Lockstep workflow runs it with --strict and files one PT issue when it is red. It never prints a value: values are reduced to keyed digests under a per-run key as they are read, and the report says only which projects agree with which. A sensitive variable cannot be read back, so a comparison that needs one fails, unless the group's acceptSensitive names that environment with a reason, as it does for the production DATABASE_URL. Add required variables only to trusted production/staging scopes in the manifest. The preview-isolation policy rejects every configured variable in untrusted Preview scopes.

Database credentials are scoped per project and per environment​

A Vercel environment variable is scoped to an explicit set of environments, and in this repo each app zone's DATABASE_URL / DIRECT_URL is set on that zone's own Vercel project. A deployment reads whichever value is scoped to the environment it runs in — which may or may not be the same value production reads, because one variable can be scoped across several environments.

Two consequences, both established while triaging INF-590:

  • The blast radius of a bad credential is a per-project, per-environment question. The zones that hold a DATABASE_URL against the shared database are the ones with a Prisma datasource: apps/{directory,project-tracker,catalog,wiki,agents}/prisma/schema.prisma. Derive the set from those rather than from a hand-copied list — the zone table at the top of this page is not it, and is known to be incomplete. Two deployed projects are outside the set: constellation-docs is a static Docusaurus build reaching no database, and constellation-pt-mcp keeps its OAuth state in a dedicated Neon store under OAUTH_DATABASE_URL. DIRECT_URL is a separate, more privileged credential — the broad BYPASSRLS role used by migrations, local scripts and the CRON_SECRET -gated cron-bypass routes at runtime — so it is not invalidated by rotating the constellation_app runtime password, and rotating it reaches cron routes as well as migrations.
  • A database error from a preview deployment is not automatically a production incident, and the environment tag tells you which. Each server init (sentry.server.config.ts) resolves the tier at runtime: ERROR_REPORTING_ENVIRONMENT when the operator set it, otherwise VERCEL_ENV ?? NODE_ENV, so its tag is the real deployment tier. A browser cannot read either variable — Next.js inlines only NEXT_PUBLIC_* into the client bundle — so each app's next.config.ts resolves the same tier at build time into NEXT_PUBLIC_ERROR_REPORTING_ENVIRONMENT, and the browser init reports that value (INF-605): the wiki (PLT-1323), the agents zone (PLT-1370), the catalog zone (CAT-91), Directory (DIR-145) and Project Tracker (PT-1073). Before PT-1073 Project Tracker's browser init read VERCEL_ENV directly and would have tagged a preview production, but nothing loaded it, so no Project Tracker browser event was ever sent under that tag. Production checks are pending in PLT-1369, PLT-1442, CAT-99, DIR-197 and PT-1339. Each app reports only once NEXT_PUBLIC_SENTRY_DSN is set on its own Vercel project. Every browser event, and every wiki, agents, catalog and Directory server event, is scrubbed to the adapter's allowlist: no exception message, breadcrumb or request URL leaves. The primary exception carries instead a description the scrub derives from the error's structure — its Prisma code and SQLSTATE, or its application error code and status, with a fixed label (PLT-1497). And even on the server the tag names the tier, not the credential: a variable scoped across several environments carries one value. Read the tag, note which runtime produced the event, then read that variable's scopes in the Vercel project before deciding the blast radius.
  • Staging names itself staging. Staging is the Preview deployment of develop (ADR-080), which Vercel labels preview. That label is not the tier, so those deployments must set ERROR_REPORTING_ENVIRONMENT=staging, scoped to the develop branch only. Its accepted values are production, staging, preview, development and test; any other value is ignored and the tag falls back to VERCEL_ENV. The browser's value is fixed at build, so the variable takes effect only on a rebuild. Events sent before it was set keep the tag they were sent with.

Note that a saved value reaches only deployments created after it, so a changed credential is not in use until the affected deployments are rebuilt. Writing the full rotation procedure — which scopes, in which order, and how each is verified — needs Vercel operational access and is not attempted here.

Staging: auth redirects and email​

Automatic refresh: Directory, Catalog, Wiki and Platform deploy develop on every merge their ignore command admits; the INF-847 hold was lifted on 2026-11-04. A zone skipped by its ignore command keeps its previous deployment, so the alias and credential contract below does not by itself prove every zone is current. See Staging refresh.

Staging is the Preview deployment of the develop branch on each zone's Vercel project, backed by the Supabase project constellation-dev. It has no hostnames of its own beyond the develop aliases below (INF-828). This section names hosts and variables only. The console steps and the values are recorded on the ticket.

Zonedevelop aliasVercel project
Directory shellconstellation-dev.planetb2b.comconstellation-directory
Project Trackerprojects-dev.planetb2b.comconstellation-platform
Catalogcatalog-dev.planetb2b.comconstellation-catalog
Wikinone; reached through the shellconstellation-wiki

The shell origin is staging's public origin. People sign in and follow email links on https://constellation-dev.planetb2b.com, which rewrites /projects, /catalog and /wiki to the zones, as production's shell does. A zone's own alias serves that zone alone, for checking a deployment directly.

Unlike a PR preview, staging is built the way production is. Each sub-zone's Preview set scoped to develop must carry NEXT_PUBLIC_BASE_PATH (/projects, /catalog, /wiki), and Directory's must carry PROJECTS_ZONE_URL, CATALOG_ZONE_URL and WIKI_ZONE_URL, pointing at the sub-zones' develop deployments. A generic PR preview leaves all of them unset and serves standalone, as NEXT_PUBLIC_BASE_PATH describes. Without them, the shell paths and the email links below reach the wrong deployment or no route at all.

Supabase Auth URL configuration (constellation-dev)​

  • Site URL: https://constellation-dev.planetb2b.com.
  • Redirect URLs keep the entries already present, plus one per auth callback on the shell origin:
    • https://constellation-dev.planetb2b.com/auth/callback** (Directory)
    • https://constellation-dev.planetb2b.com/projects/auth/callback** (Project Tracker)
    • https://constellation-dev.planetb2b.com/catalog/auth/callback** (Catalog; the route itself arrives with CAT-92)

Supabase admits a redirect on the Site URL's own scheme, host and port without any entry, so while the Site URL is the shell origin these entries are a safeguard rather than the thing that admits the callbacks. They keep the callbacks working if the Site URL is ever set to another origin. Each ends in ** because Supabase matches an entry against the whole redirect URL, query string included, and most callbacks carry one (?next=…). An entry for the bare path would not match those, and Supabase would send the person to the Site URL instead.

These entries are deliberately absent:

  • The zones' own aliases (projects-dev, catalog-dev). A sign-in started on one of them returns to the Site URL rather than to that alias.
  • A wildcard for PR previews, such as https://*-planet-b2-b.vercel.app/**. It would also match hosts that another Vercel account can claim. PR previews have no staging authentication access, including password sign-in; see PR previews (INF-847).
  • The Google provider. Staging enables it only through the federated sign-in rollout (INF-659).

Project Tracker's password-reset link carries the /projects base path, so on the shell it reaches Project Tracker's /projects/auth/callback (PT-1483). An environment built before PT-1483 sends it to Directory's /auth/callback instead, so the reset completes end to end only once PT-1483 is deployed there. Do not add /projects to NEXT_PUBLIC_SITE_URL: it is a bare origin, and Project Tracker appends the base path itself.

Email from staging​

Project Tracker is the only app that sends email of its own (invitations and notifications). Four variables decide who that mail comes from and where its links point, each set on constellation-platform as a Preview variable scoped to the develop branch. Password-reset and sign-up confirmation mail is different: Supabase Auth sends it from constellation-dev's own email settings, which these variables do not control.

VariableStaging setting
RESEND_API_KEYA Resend key of staging's own, restricted to sending from the existing domain
NOTIFICATION_FROM_EMAILA staging sender on the existing domain, named as staging (recorded on INF-828)
NEXT_PUBLIC_APP_URLhttps://constellation-dev.planetb2b.com
NEXT_PUBLIC_EMAIL_BASE_URLhttps://constellation-dev.planetb2b.com/projects

Set these explicitly on develop; generic Preview must be empty. Without explicit staging values, application defaults can select deployment-specific links or the production sender. The two NEXT_PUBLIC_* values take effect only on a fresh build. A separate key does not stop staging from mailing real inboxes: send staging invitations only to addresses the team controls. Untrusted PR previews have no configured variables or mail credentials under INF-847. During containment, keep untrusted builds disabled while provisioning trusted staging and removing old generic entries; see the procedure below.

PR previews​

Credential-free restoration is governed by PR Preview admission. PR previews are credential-free and cannot use staging backends. Staging is the develop deployment of each zone, which deploys automatically on merge (the INF-847 exclusion was lifted on 2026-11-04; see Staging refresh), and only it receives the Preview variables scoped to develop.

Preview credential isolation (INF-847). Branch-controlled code must not receive shared staging or production credentials. Generic Preview and every Preview branch scope other than exactly develop must contain no configured environment variables, across all projects. This includes public-looking names, integration-provided variables, signing keys, database URLs, mail tokens, worker credentials and unknown future variables. A name or encrypted storage type does not establish safety. Preview login protection does not prevent build code from reading secrets.

Credential-free previews may fail to build or boot; backend-connected PR previews are unavailable. Do not work around that by copying staging credentials, using mock authentication on Vercel, or pointing previews at staging services. Any future functional previews need disposable databases, authentication, queues and credentials isolated from shared environments, with teardown and independent review. Standalone routing still uses / with no base path.

Containment and rollout (authorized operator)​

The repository checker detects drift; it cannot prevent a deployment or revoke old credentials. Repository merge does not establish operational containment. The response owner records rollout status and coordination privately in INF-847; this page specifies the required procedure, not the current live configuration.

  1. Contain before changing variables. At the hosting control plane, prevent untrusted branch builds and deployments for each connected project. Include manual/API deploy permissions and integration-triggered builds. A branch-editable build command or vercel.json is not a trust boundary. If trusted-branch-only execution cannot be enforced, suspend the affected project's deployments while containing it. Record the operator and exact external control used. Merge-check continuity: before the cut-over, the repository administrator and response owner must inventory both develop required status checks and main required deployments, including the release/* and hotfix/* PR heads that must satisfy them. These heads are non-develop Preview scopes too; containment can block releases and incident hotfixes as well as feature PRs. Agree which checks replace deployments for branches whose builds are suspended or cannot run credential-free. Record the owner, cut-over date and restoration criteria in INF-847. Update the affected repository branch-protection configuration (including .github/branch-protection/develop.json) and both live target rulesets together through explicit human approval; required CI build, test and review checks remain. This procedure does not itself authorize a ruleset change or bypass. Required Vercel contexts mean a deployment completed, so suspended builds cannot satisfy them. Keep the cut-over blocked until that approved transition is ready, or verify every required deployment succeeds credential-free. Never restore shared credentials to satisfy a deployment check.
  2. Inventory names and access, never values. Discover every Vercel project connected to the repository, including the agents zone if deployed, docs and MCP. The checked manifest currently lists six projects and does not prove inventory completeness. Inspect generic Preview, non-develop branch entries, custom environments, team shared variables and integration-injected credentials. Inspect build credentials, caches/artifacts and OIDC/cloud grants separately; the project variable listing does not certify those surfaces. Keep evidence access controlled.
  3. Preserve trusted staging deliberately. Confirm develop branch protections and deploy permissions prevent unreviewed code from obtaining staging access. Provision required staging values explicitly for Preview branch develop; preserve production targets on multi-target entries. Validate staging from the trusted branch before removing its inherited generic values. Trust means this repository's protected develop commit and verified deployment provenance, not merely a branch named develop. Keep Vercel Git Fork Protection enabled and do not authorize untrusted fork PR deployments, including same-named develop branches. Require the same upstream commit provenance for manual/API deployments. The variable listing does not contain repository identity or prove fork isolation. Vercel documents an authorization gate, not a guarantee that an authorized fork cannot receive branch-scoped values. The operator must verify these controls without exposing credentials; if repository provenance cannot be enforced, keep the project's deployments suspended or move staging to a separately controlled project before enabling branch builds. Never copy production secrets to staging. If staging validation fails, keep untrusted builds disabled while repairing develop-scoped configuration. Verify the database project, role and migration/repair consumers for staging's DIRECT_URL separately. A trusted staging migration credential may intentionally differ from the runtime DATABASE_URL; the retired generic-Preview runtime-role comparison is not a staging rule. Record the approved role and environment identifiers without values. Names-only metadata cannot establish credential suitability, and this procedure does not authorize reading or changing values.
  4. Remove untrusted access. Remove all generic and non-develop Preview entries and revoke or detach integration/shared grants that inject access there. Do not delete unrelated production or development targets of a multi-target entry. Do not assume changing an environment variable removes it from an existing deployment or cached artifact.
  5. Respond to historical exposure. The security response owner inventories old deployments, build logs and artifacts privately; contain or remove affected deployments and invalidate caches under the approved response procedure. Rotate/revoke exposed credential classes at their authority (database roles, JWT/signing and service-role keys, API keys, mail/integration tokens, cloud grants), coordinating trusted consumers and sessions. Record verification and outstanding unknowns without publishing values or exploit evidence. Merely deleting Preview entries is not credential revocation.
  6. Verify before resuming. From a trusted checkout, run npm run check:vercel-env -- --preview-isolation-only --strict --json for metadata-only evidence; it never pulls values. It checks every listed Preview entry, not only effective generic names, and fails on sensitive and provider-managed entries too. Separately verify inventory coverage, custom/integration/system access, trusted branch enforcement, old deployment containment and credential response. Record project names, scope classes, timestamp and pass/fail only. An authorized isolated canary may record variable names and access-denied outcomes, never values or attempts against shared tenant data. Keep builds disabled if evidence is incomplete.

Nightly drift checks​

The environment checker uses supported Vercel CLI API GET requests to resolve and validate each project and its owning team, then reads all project variable scopes with decryption disabled. It strips returned value fields before reporting. Unexpected response shapes, pagination or non-success HTTP statuses are acquisition failures (exit 2), not clean inventories. Metadata-only mode never pulls values. Full mode selects validated project/team IDs in a private temporary directory for value comparisons; its use requires the operator's authorization to acquire those values. Raw provider JSON is not names-only evidence and must not be copied into reports.

The nightly workflow remains strict: a red isolation check is actionable drift. Do not suppress findings, add a waiver or restore shared credentials to silence its PT drift report. Triage other drift separately and record verification evidence and operator sign-off in the remediation task. Repository tests alone do not establish operational containment.

Resumption permits only credential-free untrusted builds after the external boundary is verified; functional backend previews remain unavailable. Rollback means keeping previews disabled and repairing trusted staging configuration, never restoring shared credentials to untrusted scopes. The normal full check:vercel-env -- --strict still verifies trusted environment requirements and value lockstep; its comparisons pull values privately and are distinct from the metadata-only check.

RLS-bypass startup guard (assertNonBypassRole)​

Each app's src/instrumentation.ts calls assertNonBypassRole() (from @constellation-platform/db) on server startup. PostgreSQL silently disables every Row-Level Security policy — even with FORCE ROW LEVEL SECURITY set on every table — when the connected role has rolsuper = true or rolbypassrls = true. The guard is the fail-fast tripwire for that: if DATABASE_URL is ever repointed at a SUPERUSER/BYPASSRLS role, the app refuses to boot instead of silently serving cross-tenant data. Production connects as the non-bypass constellation_app runtime role, so the guard passes; it only trips on a regression.

VarValueEffect
CONSTELLATION_ALLOW_BYPASS_RLS1Fail-closed opt-out — attested CI only. Skips the guard ONLY on an attested ephemeral GitHub Actions runner: it takes effect only when CI=true and GITHUB_ACTIONS=true and DATABASE_URL is a loopback host (localhost/127.0.0.1/::1) and no Vercel signal (VERCEL/VERCEL_ENV) is present. Every deployment tier fails that attestation — SaaS (Vercel), Dedicated Cloud, and On-Prem (Docker/K8s) set neither CI var and use a remote database — so a stray value here is ignored on any deployment and the guard always enforces. (Vercel is additionally an absolute veto, even under full attestation.) Set it only where a privileged/bypass role is used deliberately (the CI end-to-end tests serving the apps as a superuser). Any other value, or unset, always enforces.

Local dev needs nothing: .env.local.example already points the apps at constellation_app. Migration scripts legitimately connect as a privileged role and never call the guard.

Speed Insights — pinned paths, and where the samples are expected to land​

Every telemetry-enabled zone mounts the platform adapter in its root layout; no app imports @vercel/* directly (apps/agents carries no telemetry at all):

import { WebVitals } from '@constellation-platform/telemetry';

<WebVitals />;

WebVitals takes no props — it owns both transport paths. Project Tracker also mounts <WebAnalytics />, which pins /_vercel/insights/script.js and /_vercel/insights the same way. One test in packages/platform/telemetry guards that all four layouts mount the adapter and that no app reaches the vendor SDK (PLT-1106).

Neither prop is optional: dropping either stops collection, and the two fail differently. The v2 SDK defaults to a randomised /<hash>/… path for both the script and the collector. Under the multi-zone rewrite the browser resolves those against the root zone's host, where the script path 404s and the collector path falls through to the Next.js catch-all and answers HTML with status 200 — so sendBeacon reports success, DevTools shows a green request, and Vercel records nothing.

A missing scriptSrc is at least loud — the 404 surfaces as a console error, which is how INF-54 found it. A missing endpoint is the dangerous one: it fails silently and green, with no console error and no failing gate, and the only symptom is a dashboard nobody is watching. Both halves reached production once each and were fixed by hotfix (INF-54, #543 and #545).

Sub-zone samples are expected under the ROOT zone's Vercel project

Directory's rewrites cover /projects/*, /catalog/*, /wiki/* and /agents/* — but not /_vercel/*. Both the script request and the vitals beacon therefore reach Directory's deployment, so Directory's dashboard is where sub-zone field data is expected to land, and the first place to look for it. That follows from the routing, but which project Vercel finally credits has not yet been confirmed against a deploy; treat it as the expected outcome rather than an established one until someone checks.

If it holds, it is dataset location and mixing, not data loss: the beacon carries the page path, so /wiki/* and /projects/* routes stay separable within that project — filter by path. Giving each zone its own project would need a zone-owned absolute endpoint (cross-origin, so CORS) or the SDK's dsn prop, and is a cross-app decision rather than a per-zone one.

Security response headers​

Each zone sends its own security response headers from its own next.config.ts headers() (PLT-357; the design is .ai/specs/SPEC-plt-357-security-response-headers.md). Phase 1 sends:

HeaderValue
X-Frame-OptionsSAMEORIGIN
Content-Security-Policyframe-ancestors 'self', this one directive, enforced
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policyturns off the camera, microphone, geolocation, payment, USB and other features no zone uses
Strict-Transport-Securitymax-age=63072000, with no includeSubDomains and no preload
Content-Security-Policy-Report-Onlythe zone's full policy

The root zone leaves the paths it proxies to their zones. Directory's rule skips /projects, /catalog, /wiki, /agents, /support and /api/support, bare or with a subpath, which is the same proxied-prefix exclusion as its middleware matcher (static assets are not excluded). Each sub-zone sends its own headers under its basePath. Under next start a response proxied through an external rewrite never carries the proxying zone's config headers, while Vercel's proxy may add them; skipping the proxied paths keeps both runtimes alike and stops two zones sending different values of one header. A sub-zone is also reachable without the root zone (its *.vercel.app host, its previews), which only its own headers cover.

A route's own value for one of these headers loses. Next applies the config headers before the route runs and adds a route's header only when the response does not already have it. A route that needs a different value is therefore excluded from that header's zone-wide rule, and its zone's config sends the route's own value on exactly those paths instead. The two values never differ, and a response the route answers without the header (a redirect, an error, a 404 below it) still carries it. The zone's test pins both. Project Tracker's GitHub connect routes are the case today: every response on them carries their own stricter CSP and Referrer-Policy: no-referrer.

The CSP is report-only, and nothing collects the reports. Violations appear in the browser console only; the policy blocks nothing. Enforcing it, and deciding how violations are collected, is PLT-1917.

HSTS keeps the expiry production already has. Vercel's edge already sends max-age=63072000 on the production domain. A browser replaces its stored expiry with each new header, so a shorter value would shorten the policy browsers already hold. Adding includeSubDomains or preload is PLT-1918's decision; remember that only max-age=0 clears a stored policy.

ZonePhase 1 headers
DirectorySent (PLT-357); connect-src also names the Sentry DSN host
AgentsSent (PLT-357); connect-src also names the Sentry DSN host
CatalogSent (PLT-357); connect-src also names the Sentry DSN host
Project TrackerSent (PLT-357); the GitHub connect routes send their own stricter CSP and Referrer-Policy, /api/docs gets no Report-Only policy, and the Report-Only policy also allows the Sentry DSN host, blob: images and Supabase frames
WikiSent (PLT-357); connect-src also names the Sentry DSN host

Vercel project wiring​

The four Vercel projects share a build root (the monorepo) but each has its own Root Directory set to apps/<name> so Vercel runs each build from inside that workspace. installCommand and buildCommand are left blank on three of them (Vercel auto-detects); the docs project overrides them to use Turborepo, see apps/docs/vercel.json. All projects use the framework default output directory (.next for the three Next apps, build for Docusaurus) — apps/docs/vercel.json declares outputDirectory: "build" explicitly, but it matches the Docusaurus default.

Supabase asymmetric session admission (PLT-1999)​

The source supports explicit SUPABASE_SESSION_VERIFICATION_MODE=jwks-es256 with one canonical managed Supabase issuer/project origin and fixed ES256/audience/session claims. Absent mode preserves existing legacy Production behavior. New staging selects asymmetric mode explicitly, legacy off, without a session HMAC or dummy credential. API-key/internal trust remains independent; PT internal minting requires its designated receiver-paired key.

Blank optional environment keys count as unset; nonempty active keys still require the profile's minimum length and separation checks. An enabled legacy transition still requires its valid fixed deadline and designated key. The browser retries the recognised session-verification-unavailable response with the existing backoff, preserving a signed-in session and keeping the first load pending. Unrelated gateway errors do not enter that retry path.

All new profile variables participate in Turbo build inputs. After separate source/configuration approval, use a fresh git-based build with the exact reviewed revision; an old deployment or redeploy is not proof of changed configuration. Do not change Production or restore Preview deployments as part of source preparation.

ProjectAdmission evidence before a separately approved rollout
DirectoryExplicit issuer/profile, independent API/internal pairings where used, principal/tenant/callback and actual transports
Platform / PTSame profile, internal sender/Directory receiver pairing, browser/session handoff
CatalogCookie/local/hybrid bearer, callback and principal/tenant coverage
WikiSame profile plus classification/tenant enforcement; no KB-job privilege exception
PT MCPInventory actual bridge/OAuth consumers and grant/liveness contracts; direct session N/A only with source evidence
DocsSource/build evidence whether authentication is consumed; N/A requires evidence, not a fabricated session key

Each row names the owner, exact source head, runtime profile, actual auth proxy/caller/transport, deployment and alias/ref. Historical deployment metadata is not current admission. Preview remains disabled during preparation. Generic/non-develop Preview must hold no configured variables; trusted staging variables are explicitly develop-scoped.

A separately authorized private check uses a disposable owned identity and records only pass/fail for ES256 alg, bounded kid, issuer/audience/role, top-level tenant_id/roles, dates/TTL, project pairing and signer claims-hook compatibility. Test cookie SDK, bearer-local and bearer+SDK separately; neither cookie success nor local JWKS success proves hybrid identity acceptance. Mismatch returns to architecture review. Never publish the token, claims, identifiers, credential hashes or raw diagnostics.

JWKS limits:3s acquisition,64KiB decoded body,16keys,300s successful TTL,240s refresh-ahead,30s all-attempt cooldown,64 dependent waiters,8issuer states. No stale/error/secret fallback. Unavailable verification returns503 without sign-out; invalid session returns401. Provider-edge10min plus app5min is an approximate key-visibility budget, not instant revocation; normal rotation requires separately confirmed publication and at least20min propagation. Restart cannot purge provider/other-instance caches.

Legacy transition is off by default; any separately approved window has a fixed UTC deadline <=24h ahead at config build, checked per request. Expiry/cold start leaves ES256 usable and never slides the window. Recovery requires a compatible source+configuration pair and may keep staging paused. Issuer downgrade, Production HMAC copy and indefinite legacy are not recovery. Source tests/reviews, human merge approval, live verification/configuration/deployment approval and INF-847 containment/credential/authority/history/full-strict/named sign-off remain separate gates; INF-801 is still gated.