DO NOT AUTO-PUSH TO PROD. Every SQL file in workers/drizzle/ must be
reviewed by a human before being applied to any live Neon database. The
db:migrate script must never be wired into CI/CD pipelines or automated
deploy hooks.
workers/src/db/schema.ts is the single source of truth for the Postgres
schema. Drizzle-kit generates versioned SQL migration files from it; those files
live in workers/drizzle/ and form the auditable history of every schema change.
The hand-written bootstrap db/schema-v4-neon.sql is kept in sync for
documentation and disaster-recovery purposes, but it is not the primary
migration vehicle for existing databases.
Journaled idx 3 — encodes decision D4 / requirement #4: sites.default_language → DROP DEFAULT + SET NOT NULL (mandatory at provisioning, no silent 'en' default). Generated 2026-06-26 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe to apply as-is because there are 0 sites in Skyfall-V2; if ever applied against a DB that has site rows with a NULL default_language, backfill them first (e.g. UPDATE sites SET default_language = 'en' WHERE default_language IS NULL) before SET NOT NULL. See docs/architecture/LOCALIZATION-AND-PROVISIONING.md.
Journaled idx 4 — reconciles a pre-existing drift: sites.supported_languages was declared text[] in schema.ts but the column was still jsonb. DROP DEFAULT → SET DATA TYPE text[] USING translate(col::text,'[]','{}')::text[] (the USING transform may not contain a subquery; values are BCP-47 language codes so the bracket→brace rewrite is safe; NULL passes through) → restore the {"en"} default. Generated 2026-06-29 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 sites; the cast is exercised on real multi-element + NULL rows in migrations-cascade.test.ts.
workers/drizzle/0005_dom_nodes_parent_cascade.sql
Journaled idx 5 — Phase C: adds the dom_nodes.parent_id → dom_nodes.id ON DELETE CASCADE self-FK so deleting a node deletes its subtree at the DB level. Prepended orphan pre-clean (NULLs parent_ids that point at a missing row, breaks parent_id = id self-loops) so the FK is satisfiable on existing data — non-destructive (rows kept, only dangling links nulled). Generated 2026-06-29 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 dom_nodes. Prerequisite before applying: the batch-create path must order creates parent-before-child across the sink’s chunk boundary — within one multi-row INSERT order is fine (immediate FK sees sibling rows), but a child in an earlier/batch request than its parent would now be rejected (lands with the Phase C2 route work).
Journaled idx 6 — Phase D: dom_nodes.positioninteger → double precision so sibling order is a FRACTIONAL rank. A move now sets the moved node’s position to a midpoint between its neighbours and touches no other row (deletes the renumber-every-sibling / RP-1 surface). int→double is an implicit cast (no USING, no data loss; existing integer ranks stay valid). Generated 2026-06-30 (offline). ✅ Applied to prod 2026-06-30 (MCP). Safe at 0 dom_nodes.
Journaled idx 7 — Phase 6 data-model: new constraints table (site-scoped guideline store) + last_author text NOT NULL DEFAULT 'human' provenance stamp on components/component_styles/design_tokens/dom_nodes. Additive/non-breaking. Renumbered from 0004 on merge (2026-06-30): master had independently taken idx 4/5/6 for the supported_languages/cascade/position migrations, so this Phase-6 migration was regenerated as idx 7 against master’s 0006 snapshot (drizzle-kit check clean). ✅ Applied to prod 2026-06-30 (MCP). ⚠️ Index is relative to master@14ba1af — if another branch lands a journaled migration on master before this branch merges, regenerate (bump to the next free index).
db/migrations/20260629_rls_neon_authorize.sql
Non-journaled sidecar — Phase 6 hardening: tenant RLS via Neon RLS Authorize. Creates membership helpers (current_app_user_id/is_member_of_site/…), ENABLE ROW LEVEL SECURITY + auth.user_id()-based policies on all tenant tables, and GRANTs to the authenticated role. Idempotent and INERT under the BYPASSRLS service role (applying it does not change current runtime behaviour). ⚠️ PREREQUISITES (live/owner): Neon RLS Authorize enabled on the project (the authenticated role + pg_session_jwt must exist for the GRANTs/auth.user_id()) and the Neon Auth JWKS registered as the auth provider. Enforcement activates only when the Worker routes through the tenant connection — set RLS_ENFORCED=true (and optionally NEON_AUTHENTICATED_DATABASE_URL) AFTER verifying the policies live (see the migration’s §4 verification block).
workers/drizzle/0010_targets_import_source.sql
Journaled idx 10 — adds nullable targets.import_source text (currently used value: 'bondlayer'). Import provenance stamp so the editor can gate Bondlayer-specific UI (reviewing/editing imported triggers) to only the targets that actually came from a Bondlayer import, instead of showing it on every site. Purely additive (ADD COLUMN, no default, no backfill needed — existing rows read as NULL). Generated 2026-07-06 (offline, pnpm db:generate). ✅ Applied to prod 2026-07-07 (MCP) — the live-Neon CI integration suite (test/integration.test.ts) runs against this same Skyfall-V2 database and started failing with column "import_source" of relation "targets" does not exist once this PR’s schema.ts change landed; applying the migration (and marking it in __drizzle_migrations) fixed it.
workers/drizzle/0011_target_domains.sql
Journaled idx 11 — creates the target_domains table (custom hostname → target map for host-based published serving; B2B2C punch-list §1). target_id FK ON DELETE cascade, globally-unique hostname (one host → one target), cloudflare_hostname_id/status/is_primary for the CF-for-SaaS cert lifecycle, + a unique index on hostname and an index on target_id. Purely additive (CREATE TABLE, no backfill). Generated 2026-07-09 (offline, pnpm db:generate; renamed from the drizzle default). ✅ Applied to prod + recorded in __drizzle_migrations (2026-07-10, with the RLS sidecar, in one transaction). Companion RLS: db/migrations/20260709_target_domains_rls.sql.
db/migrations/20260709_target_domains_rls.sql
Non-journaled sidecar — enables RLS on target_domains + a target-scoped policy (reuses the Clerk-baseline can_access_target() helper) + authenticated grant. ✅ Applied to prod 2026-07-10 alongside 0011. Idempotent; inert under the BYPASSRLS service role. Public host resolution runs on the service connection, so RLS never blocks serving.
workers/drizzle/0012_target_domains_kind.sql
Journaled idx 12 — adds target_domains.kind text NOT NULL DEFAULT 'custom' ('subdomain' | 'custom') so the free auto-provisioned platform subdomain (<slug>.<PLATFORM_ROOT_DOMAIN>) is distinguishable from a user-brought vanity domain (Publish & Custom Domains spec, increment 1). Purely additive (ADD COLUMN with default — existing rows read as 'custom'). Generated 2026-07-09 (offline). ✅ Applied to prod + recorded 2026-07-10.
Journaled idx 13 — adds verification_token, dns_target, last_checked_at, last_check jsonb to target_domains for the custom-domain verification subsystem (spec §8, increment 2): ownership TXT challenge, the CNAME target shown in setup, and the persisted per-check result. Purely additive (all nullable ADD COLUMN). Generated 2026-07-09 (offline). ✅ Applied to prod + recorded 2026-07-10.
workers/drizzle/0014_real_big_bertha.sql
Journaled idx 14 — N8 Phase 3 / N4: two additive columns on targets — breakpoints jsonb (nullable → default breakpoint registry; per-target responsive descriptors [{id,label,bound,px}]) and token_overrides jsonb DEFAULT '{}' (sparse per-target overrides over the SHARED design system’s tokens: { [designTokenId]: { base?, byBreakpoint? } } — the layered-DS model that avoids per-target copies). Purely additive (ADD COLUMN; existing rows read NULL/{}). Renumbered from a pre-merge 0011 after master’s 0011–0013 custom-domain migrations landed; made idempotent (ADD COLUMN IF NOT EXISTS) since the columns already existed on prod from an out-of-band apply. ✅ Columns present on prod + recorded in __drizzle_migrations (2026-07-10).
workers/drizzle/0015_good_hawkeye.sql
Journaled idx 15 — Forms & Email (D72/D72a, FE-1): new forms table (form config: write targets, input bindings, actions, spam, on-success), content_types.kind ('collection'|'form'|'audience', default 'collection'), content_items.review_state (nullable submission-inbox state) + idx_content_items_review. Purely additive. Generated 2026-07-10 (offline). ✅ Applied to prod + recorded 2026-07-11 (MCP), in one transaction with 0016 + the RLS sidecar below.
workers/drizzle/0016_nosy_amphibian.sql
Journaled idx 16 — Forms & Email (D72a, FE-7): project_email_settings (managed/BYO sending config; provider_credential_enc holds ONLY the AES-256-GCM sealed BYO key — never plaintext), email_suppressions (compliance list, unique (project_id,email)), email_events (provider event log). Purely additive. Generated 2026-07-10 (offline). ✅ Applied to prod + recorded 2026-07-11 (MCP).
workers/drizzle/0017_white_thunderbolt.sql
Journaled idx 17 — Print Mode (D74): adds nullable site_pages.page_format jsonb (the fixed-format page box for page_type print/raster pages: preset, trim W/H as CSS absolute lengths, orientation, mirrored margins, bleed, cropmarks, output intent — contract @matriz/schemaPageFormat). Purely additive (ADD COLUMN, nullable, no backfill — web pages read NULL). Renumbered from a pre-merge 0015 after master’s 0015/0016 Forms & Email migrations landed (regenerated against the 0016 snapshot; db:check clean). ✅ Applied to prod + recorded 2026-07-12 (MCP) — ledger row 19 inserted with the file’s sha256 + journal when; verified live (column present as jsonb, ledger contiguous), so pnpm db:migrate is a no-op again. No RLS sidecar needed: a new column on site_pages is covered by its existing row-level policies.
db/migrations/20260710_forms_email_rls.sql
Non-journaled sidecar — RLS for the four Forms & Email tables: enable RLS + is_project_member(project_id) policies + authenticated grants (SELECT-only on email_events). Mirrors the target_domains sidecar. Public ingest/mailer/webhook paths run on the BYPASSRLS service connection, so RLS never blocks a visitor submission or a bounce-driven suppression. Idempotent. ✅ Applied to prod 2026-07-11 alongside 0015/0016.
workers/drizzle/0019_illegal_union_jack.sql
Journaled idx 19 — Marketing (FE-10, D:email-render): email_templates (designer doc: constrained tree jsonb) + email_campaigns (one-off broadcast to an audience). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP) — was UNAPPLIED after the marketing PR merged (deployed code referenced missing tables); caught by the visitor-auth prod-apply audit. RLS sidecar: 20260713_marketing_rls.sql.
workers/drizzle/0020_classy_big_bertha.sql
Journaled idx 20 — Marketing (FE-11, D:email-flows): email_flows (definition) + email_flow_runs (authoritative per-enrollment runtime state). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP) — same audit as 0019. RLS sidecar: 20260713_marketing_rls.sql.
db/migrations/20260713_marketing_rls.sql
Non-journaled sidecar — BACKFILL: the marketing PR shipped 0019/0020 with NO RLS companion, but their builder-CRUD routes use the TENANT (getScopedDB) connection, so under RLS_ENFORCED=true they need policies. Enables RLS + is_project_member(project_id) + authenticated grants on all four marketing tables (they all carry project_id). Mirrors 20260710_forms_email_rls.sql. Send/flow-runner side effects run on the BYPASSRLS service connection. Idempotent. ✅ Applied to prod 2026-07-13 alongside 0019/0020.
Journaled idx 22 — Visitor Auth (D73, VA-4): site_pages.members_only boolean NOT NULL DEFAULT false + site_pages.required_role text (drives the publish-time memberGate R2 customMetadata). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP). No RLS sidecar: new columns on site_pages ride its existing policies.
workers/drizzle/0023_dry_mauler.sql
Journaled idx 23 — Backups (PR #316, editor-feature-gaps): new backups table (per-target snapshot metadata → R2 storage_path; backup_type/label/size_bytes/expires_at, FKs to targets/projects/users, two target_id indexes). Purely additive. ✅ Applied to prod + recorded 2026-07-14 (MCP) — was the “owner: apply 0023 to prod” action flagged in STATUS; applied during the component-review-user-areas merge reconciliation (ledger hash=2b1557af…, when=1783986048503).
workers/drizzle/0024_moaning_trish_tilby.sql
Journaled idx 24 — Member user areas (D:member-user-areas): the ATOMIC submission-quota backstop. Adds nullable content_items.quota_key text + a partial unique indexcontent_items_quota_unique (content_type_id, quota_key) WHERE quota_key IS NOT NULL. The forms ingest stamps `quota_key = ’
db/migrations/20260713_visitor_auth_rls.sql
Non-journaled sidecar — RLS for the visitor-auth tables: site_visitors (glass shape, can_access_target), site_members (data shape, is_project_member) + authenticated grants; member_login_tokens RLS-enabled but deny-closed (service-only, no policy). Public magic-link flow runs on the BYPASSRLS service connection. Idempotent. ✅ Applied to prod 2026-07-13 alongside 0021/0022.
db/migrations/20260720_symbols_rls.sql
Non-journaled sidecar — BACKFILL: the MISSING companion to journaled 0018_demonic_wind_dancer.sql, which added the symbols table (D:symbols-v1) but shipped no RLS sidecar — the one tenant-reachable table added after the Clerk baseline that slipped through. symbols had RLS disabled + no policy + no authenticated grant, so under RLS_ENFORCED=true every tenant write failed with permission denied for table symbols (table-GRANT error, not an RLS-policy violation) — first seen on Bondlayer import (POST /api/import/site/:targetId batches an INSERT into symbols), but the interactive Symbols API (workers/src/routes/symbols.ts, also getScopedDB) hit the same wall. Enables RLS + glass-shape can_access_target(target_id) policy (target-scoped, like dom_nodes/site_font) + full DML grant. Import/publish run on the BYPASSRLS service connection, so RLS never blocks them. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-20 (MCP) — verified live (RLS enabled, tenant_symbols_all policy, authenticated grants SELECT/INSERT/UPDATE/DELETE). No __drizzle_migrations row (non-journaled sidecar).
db/migrations/20260720_backups_rls.sql
Non-journaled sidecar — BACKFILL: the MISSING companion to journaled 0023_dry_mauler.sql, which added the backups table (D:backups-r2) but shipped no RLS sidecar — same defect class as the symbols gap above, found during that audit. workers/src/routes/backup.ts runs create/list/get/delete + the pre-restore auto-backup on the TENANT connection (getScopedDB), so under RLS_ENFORCED=true every backup action failed with permission denied for table backups. backups carries both target_id and project_id but all routes filter by target_id, so it takes the glass-shape can_access_target(target_id) policy (like dom_nodes/site_font/symbols) + full DML grant. The R2 snapshot store/restore runs unaffected. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-20 (MCP) — verified live (RLS enabled, tenant_backups_all policy, authenticated grants SELECT/INSERT/UPDATE/DELETE). No __drizzle_migrations row (non-journaled sidecar).
workers/drizzle/0025_cms_scale_perf_indexes.sql
Journaled idx 25 — CMS scale/perf: a GIN index on content_items.data (jsonb containment queries) + a composite (content_type_id, created_at) btree. Purely additive (two CREATE INDEX). Landed on master but was left unapplied to prod until 2026-07-21 (see reconciliation note below). ✅ Applied to prod + recorded 2026-07-21 (MCP) alongside 0026 (ledger hash=e33e3dfc…, when=1784031066406). No RLS sidecar: rides content_items’ existing project-member policies.
workers/drizzle/0026_zippy_tombstone.sql
Journaled idx 26 — Audit log (EP-12, D:audit-log): new append-only audit_events table (project cascade + target SET NULL so an event outlives its target; FK-less denormalized actor_user_id + actor_label; summary jsonb; three (project|target, created_at)/(project, action) indexes). Purely additive (CREATE TABLE + 2 FKs + 3 indexes). Generated 2026-07-21 (offline, pnpm db:generate). ✅ Applied to prod + recorded 2026-07-21 (MCP) (ledger hash=bf96a69a…, when=1784634586558). Companion RLS: db/migrations/20260721_audit_events_rls.sql.
db/migrations/20260721_audit_events_rls.sql
Non-journaled sidecar — RLS for audit_events (EP-12). Enables RLS + a FOR SELECTis_project_member(project_id) policy + a SELECT-only grant to authenticated. This is the append-only enforcement: the tenant role can READ its project’s events but has no INSERT/UPDATE/DELETE grant, so events can only be written on the BYPASSRLS service connection (recordAudit) and never forged/edited/erased by a viewer. Idempotent; inert under the service role. ✅ Applied to prod 2026-07-21 (MCP) alongside 0026 — verified live (RLS enabled, tenant_audit_events_select SELECT policy, authenticated granted SELECT only; INSERT/UPDATE/DELETE all denied). No __drizzle_migrations row (non-journaled sidecar).
Journaled idx 32 — Repeater dead-surface (D105, D:repeater-dead-surface, PR #375): drops the always-nulldom_nodes.filter_config + sort_config columns (threaded through ~10 files, written by nothing since D29; every threading removed in the same PR). Destructive but content-free: both columns verified all-NULL by the Phase 3b adversarial review before the drop. Generated 2026-07-28 (offline). ✅ Applied to prod + recorded 2026-07-28 (MCP, coordinator session) — one transaction: the two DROPs + the ledger row (hash=92d1df80…, created_at=1785193356630 = the journal when); verified live (0 matching columns, ledger contiguous, head = 0032). No RLS sidecar: dropping columns on dom_nodes rides its existing policies.
workers/drizzle/0033_detail_template_variants.sql
Journaled idx 33 — Detail-template variants (D108, D:detail-variants, PR #378): adds nullable site_pages.detail_collection_id (FK → content_types ON DELETE SET NULL) + site_pages.detail_template_key + content_items.detail_template_key, and idx_site_pages_detail_collection (target_id, detail_collection_id). Purely additive — every existing row reads NULL; derivation stays as the resolution fallback so no behaviour changes until a link is stored. Renumbered from a pre-merge 0032 after D105’s column-drop took that index (regenerated against the 0032 snapshot; db:check clean). ✅ Applied to prod + recorded 2026-07-28 (MCP, coordinator session, owner-instructed) — one transaction: 5 DDL statements + the ledger row (hash=ba0284ce…, created_at=1785211464990 = the journal when); verified live (3 columns + index present, ledger contiguous, head = 0033, 35 rows). No RLS sidecar: new columns on site_pages/content_items ride their existing policies (verified by the Phase 9 adversarial review — no column enumeration in any policy).
workers/drizzle/0051_clean_otto_octavius.sql
Journaled idx 51 — Email sender identities (D:email-sender-identities): adds project_email_settings.sender_identities jsonb NOT NULL DEFAULT '[]' and nullable email_templates.sender_id text. Purely additive. ✅ Applied to prod (Skyfall-V2) + recorded 2026-09-30 (MCP) — one transaction: 2 ADD COLUMN + the ledger row (hash=24706f7f…, created_at=1790775210094 = the journal when); verified live (both columns present, ledger head = id 57). No RLS sidecar: new columns ride the tables’ existing project-member policies.
workers/drizzle/0052_email_library.sql
Journaled idx 52 — Email library (D:email-library): new email_styles + email_sections tables (project-scoped, cascade) and email_templates.tags text[] NOT NULL DEFAULT '{}', with a hand-appended backfill moving tree.tags into the column (and dropping the key). Additive. ✅ Applied to prod (Skyfall-V2) + recorded 2026-10-03 (MCP) — one transaction: the DDL, the (no-op, 0 tagged trees) backfill and the ledger row (hash=ded98bc0…, created_at=1791055584530 = the journal when, ledger id 58); then the RLS sidecar db/migrations/20261003_email_library_rls.sql in a second transaction. Verified live: both tables RLS-enabled with their tenant_*_all policies, authenticated granted SELECT/INSERT/UPDATE/DELETE, all 22 templates carry tags = '{}'. Must stay ahead of the code: email_templates reads select every column.
workers/drizzle/0053_campaign_exclusions.sql
Journaled idx 53 — Campaign exclusions (D:campaign-exclusions): adds email_campaigns.exclude_audience_ids uuid[] NOT NULL DEFAULT '{}'. Additive. ✅ Applied to prod (Skyfall-V2) + recorded 2026-10-04 (MCP) — one transaction: the DDL and the ledger row (hash=8cc68292…, created_at=1791073632087 = the journal when, ledger id 59). Verified live: all 9 existing campaigns carry '{}'. Must stay ahead of the code: campaign reads select every column.
workers/drizzle/0054_style_doctor_indexes.sql
Journaled idx 54 — Style Doctor (D:style-doctor): 4 indexes (dom_nodes(style_id) partial, dom_nodes(target_id, md5(style_overrides::text)) partial on non-empty bodies, component_styles(target_id), component_styles(target_id, md5(styles::text))) + hand-appended security_invoker view style_declarations. Additive, no column, no backfill. ✅ Applied to prod (Skyfall-V2) + recorded 2026-10-05 (MCP) — one transaction with 0055 + its RLS sidecar (ledger rows hash=019f19ac…/created_at=1791209854773); dry-run first inside a transaction that aborted on purpose (nothing persisted), then applied; verified live (view with security_invoker, 4 indexes, 82,931 declarations readable through the view).
workers/drizzle/0055_style_health_dismissals.sql
Journaled idx 55 — Style Doctor (D:style-doctor): new style_health_dismissals table (target_id cascade, finding_key, dismissed_by, created_at; PK (target_id, finding_key)) — the “won’t fix” memory. Purely additive. ✅ Applied to prod + recorded 2026-10-05 (MCP) (ledger hash=2f3ecfc4…/created_at=1791212697566, head after = this), with its RLS sidecar db/migrations/20261005_style_health_dismissals_rls.sql (glass shape, can_access_target) in the same transaction — verified live (table, RLS enabled, 1 policy, authenticated DML grants). Must stay ahead of the code: dismiss/list read this table.
workers/drizzle/0056_campaign_recipients.sql
Journaled idx 56 — Campaign delivery ledger (D:campaign-delivery-ledger, PR #1008): new email_campaign_recipients table (project_id + campaign_id cascade, email, status sent/failed/suppressed, via resend/manual, error, sent_at; unique (campaign_id, email), index on project_id) — who each campaign has reached, so a send can continue with “the next N people not yet reached”. Purely additive. ✅ Applied to prod (Skyfall-V2) + recorded 2026-10-08 (MCP) in one transaction with its RLS sidecar db/migrations/20261008_campaign_recipients_rls.sql (tenant SELECT policy + SELECT-only grant; writes are service-connection only) and the SCHEMA-QUALIFIED ledger row (drizzle 59 → 60 rows, hash=acff6c3f…/created_at=1791456205912 = the journal when; public untouched at 36). Pre-checked on prod: ledger head was idx 55, table absent, is_project_member + authenticated present. Verified live afterwards (8 columns, PK + unique + project index, 2 FKs, RLS enabled, 1 SELECT policy, authenticated SELECT only, 0 rows, 9 campaigns untouched). Applied BEFORE the Worker that reads/writes it, so deploying the code is safe.
workers/drizzle/0061_chief_goliath.sql
Journaled idx 61 — Motion Studio (D:motion-studio, D:motion-timeline-json-v1, D:motion-render-jobs): new motion_compositions (project_id + target_id cascade, name, role composition/scene/template, timeline jsonb = TimelineV1, version int for If-Match, versions jsonb index of R2 snapshots, created_by/updated_by, timestamps, archived_at; index (target_id, role)) and motion_render_jobs (project_id + target_id cascade, composition_id cascade, page_id set null, kind video/image/pdf, state, progress, attempt, next_attempt_at, error/host/digest/cost/output/review/share columns; indexes (target_id, created_at), (state, next_attempt_at), (project_id, created_at), unique share_token_hash). Purely additive. ⏳ Not applied to prod yet — apply WITH its RLS sidecar db/migrations/20261018_motion_rls.sql in one transaction, before the Worker that reads them.
db/migrations/20261018_motion_rls.sql
Non-journaled sidecar — RLS for the two Motion Studio tables (0061): ENABLE RLS, one FOR ALL policy each, target-led glass shape with both scope columns in WITH CHECK (can_access_target(target_id) AND project_id = target_project(target_id), D:rls-scope-columns), and SELECT/INSERT/UPDATE/DELETE to authenticated. Covered by cross-tenant-rls.pglite.test.ts (catalog-driven) and cross-tenant-schema-guard.test.ts. ⏳ Not applied to prod yet.
workers/drizzle/0059_wave2_platform.sql
Journaled idx 59 — Wave 2 platform tables (D:wave2-platform-schema): target_manifests (append-only manifest versions, unique (target_id, version)), platform_resources (external resource ownership, unique (provider, kind, external_id), target_id SET NULL), target_secrets (secret NAMES + hub-reported status/fingerprints; reserved sealed_value/sealed_key_id NULL by CHECK — values never in this DB), usage_events (append-only metering, partial unique dedupe key). All project_id cascade; CHECKs mirror packages/schema/src/platform.ts. Purely additive. Numbered after 0057/0058 (draft PR #1084, already applied to prod), which this branch carries byte for byte, so it must merge AFTER #1084; renumber by regenerating if another 0059 lands first. ⏳ NOT APPLIED — owner applies, in one transaction with its RLS sidecar and the schema-qualified ledger row.
db/migrations/20261018_wave2_platform_rls.sql
Non-journaled sidecar — RLS for the four wave-2 tables: both scope columns checked on write (D:rls-scope-columns shape). target_manifests SELECT+INSERT (append-only), target_secrets full DML, platform_resources and usage_events SELECT only (written on the service connection). Idempotent; inert under the service role. ⏳ NOT APPLIED — with 0059.
workers/drizzle/0060_github_connector.sql
Journaled idx 60 — GitHub connector storage (D:github-connector-pr-writer): github_installations (installation id PK, project_id SET NULL until/after linking, GitHub’s installed_at, status active/suspended/deleted, who linked), github_connections (repo id PK = one owner across tenants, project_id cascade, target_id SET NULL, installation FK cascade, branch_policy jsonb normalized by normalizeBranchPolicy), github_deliveries (durable webhook replay guard, pruned after 7 days by the handler). CHECKs on status, ids and repo names. Purely additive. Numbered after 0059 (this branch is based on the wave 2 branch), so it merges AFTER 0059; renumber by regenerating if another 0060 lands first. ⏳ NOT APPLIED — owner applies, in one transaction with its RLS sidecar and the schema-qualified ledger row.
db/migrations/20261019_github_connector_rls.sql
Non-journaled sidecar — RLS for the three connector tables: github_installations and github_connections SELECT-only for the tenant role (is_project_member(project_id); ownership rows, written on the service connection like platform_resources), github_deliveries RLS on with no policy and no grant (deny-all, service only). Idempotent; inert under the service role. ⏳ NOT APPLIED — with 0060.
db/migrations/20260915_crm_connections_rls.sql
Non-journaled sidecar — BACKFILL: the MISSING companion to journaled 0042_nervous_venom.sql, which added the project_crm_connections table (FE-11 follow-up, D:crm-connections) but shipped no RLS sidecar — same defect class as the symbols (2026-07-20) and backups (2026-07-20) gaps. project_crm_connections had RLS disabled + no policy + no authenticated grant, so under RLS_ENFORCED=true every tenant query from routes/crm-settings.ts (getScopedDB) failed with NeonDbError: permission denied for table project_crm_connections — first surfaced in prod logs on GET /api/crm-connections returning HTTP 500. Enables RLS + is_project_member(project_id) policy (FOR ALL) + full DML grant to authenticated. No BYPASSRLS service-connection carve-out needed: flow-engine’s stepAddToCrm reads a sealed credential off the flow STEP itself, not off this table. Idempotent; inert under the service role. ✅ Applied to prod (Skyfall-V2) 2026-10-01 (MCP) — verified live: relrowsecurity=true, policy tenant_project_crm_connections_all (FOR ALL, is_project_member(project_id)), authenticated granted SELECT/INSERT/UPDATE/DELETE. No __drizzle_migrations row (non-journaled sidecar).
Non-journaled sidecar — NOT APPLIED, owner-apply (D:delete-completeness). Drops the legacy sites_organization_id_organizations_id_fk on targets.project_id — the pre-rename duplicate with ON DELETE SET NULL that was never dropped when the Split-Glass targets_project_id_projects_id_fk (ON DELETE CASCADE) was added. Deleting a project fires both; if the SET NULL side wins, the target SURVIVES with project_id = NULL — alive, unowned, and outside every tenant guard that keys on the project claim. 0 targets were in that state when audited (2026-08-10), so this is a latent hazard, and deleteProjectCompletely now removes targets explicitly before the project row so the app path cannot reach it either — this is defence in depth for hand-run SQL and restore scripts. Idempotent (DROP CONSTRAINT IF EXISTS) and non-destructive: it drops a DUPLICATE, never the only FK. Drizzle cannot generate it (it does not know the legacy constraint exists) — hence a sidecar. The file carries verify-first queries; run them before and after.
Non-journaled, legacy-prod-only sidecar (moved out of workers/drizzle/ 2026-06-26 so that dir holds ONLY journaled migrations). Idempotent ADD COLUMN IF NOT EXISTS ALTERs that bring an old prod (bootstrapped from schema-v4-neon.sql before v4.1) up to the 0000 baseline. A fresh drizzle bootstrap does NOT need it (the token cols are inside the 0000 snapshot). Already applied to the live Skyfall-V2 DB; kept for disaster-recovery of legacy DBs only.
✅ LEDGER RECONCILED 2026-07-21 (MCP session). Applying EP-12 (audit log) surfaced a one-migration
gap: the drizzle.__drizzle_migrations ledger sat at high-water 0024 (id-27, when=1784028787197)
while journaled 0025_cms_scale_perf_indexes had landed on master but was never applied to prod
(its two content_items indexes were physically absent — verified before touching anything). Both
0025 and the new 0026_zippy_tombstone (audit_events) were applied to Skyfall-V2round-dust-84750148 in ONE transaction via Neon MCP (db:migrate can’t run from the agent sandbox —
outbound TCP :5432 is blocked; MCP runs over HTTPS, the documented prod path). Dry-run first: an
ephemeral branch (ep12-migrate-test, a copy of prod) received the identical transaction and was
verified (table shape, RLS/policy/grant, indexes, ledger) before the prod apply, then deleted. The
transaction: 0025’s 2 indexes → 0026’s audit_events (table + 2 FKs + 3 indexes) → two
drizzle.__drizzle_migrations rows (0025e33e3dfc…/1784031066406, 0026bf96a69a…/1784634586558)
→ the 20260721_audit_events_rls.sql sidecar. __drizzle_migrations now holds 28 rows, high-water
1784634586558 (0026); RLS verified (SELECT policy, authenticated SELECT-only — INSERT/UPDATE/DELETE
denied = append-only); pnpm db:migrate is a no-op again. No app data touched (both migrations additive,
audit_events starts empty).
✅ PROD BASELINED 2026-06-30 (MCP session). Neon project Skyfall-V2round-dust-84750148 now has
__drizzle_migrations with 9 rows: 0000 + catchup-sidecar + 0001_capture_217_218_224 + 0002_capture_233_token_meta
0003, 0004, 0005, 0006, and 0007, all applied. The legacy yjs_snapshots
(old state text shape) was dropped and recreated to the #224 R2-pointer shape. pnpm db:migrate against
this DB is now a no-op until a genuinely new journaled migration is generated.
✅ LEDGER RECONCILED 2026-07-10 (MCP session). After 2026-06-30 the ledger drifted (later
migrations were applied out-of-band without recording), leaving the last real row at 0007. It was
reconciled to reality on Skyfall-V2round-dust-84750148: rows for 0008–0010 (already applied)
plus 0011–0014 (target_domains + RLS sidecar + breakpoints/token_overrides, applied 2026-07-10)
were inserted with each file’s exact sha256 hash + journal when timestamp. __drizzle_migrations
now holds 16 rows (0000–0014 + the legacy pre-0000 catchup row at id 2), so pnpm db:migrate
sees a contiguous, fully-applied history and is a no-op. 0014 was made idempotent
(ADD COLUMN IF NOT EXISTS) since its columns pre-existed from the out-of-band apply.
Also applied 2026-06-26: db/migrations/20260626_token_category_taxonomy.sql (non-journaled sidecar) —
folded the seed’s legacy animation category into transition and installed a CHECK pinning
design_tokens.category to the 12 canonical categories (pivot §21a #6b, Strategy A).
✅ LEDGER RECONCILED 2026-07-13 (MCP session). Applying visitor auth surfaced a gap: the ledger
sat at 0018 (24→20 rows) while 0019/0020 (the merged marketing PR) were never applied to
prod — deployed marketing code was referencing missing tables. Reconciled on Skyfall-V2round-dust-84750148 in three transactions: (1) schema DDL for 0019+0020 (marketing) and
0021+0022 (visitor auth), all additive; (2) the RLS sidecars 20260713_marketing_rls.sql
(backfill — the marketing PR shipped none) and 20260713_visitor_auth_rls.sql; (3) four
__drizzle_migrations rows (0019–0022) inserted with each file’s exact sha256 hash + journal
when. __drizzle_migrations now holds 24 rows, high-water 0022; RLS verified (7 tables
enabled, 6 tenant policies, member_login_tokens deny-closed); pnpm db:migrate is a no-op again.
Worker secret MEMBER_SESSION_SIGNING_KEY set on skyfall-api the same session (256-bit, generated
server-side).
ℹ️ Separation of concerns (2026-06-26):workers/drizzle/ now contains ONLY journaled drizzle
migrations (0000, 0001_capture_217_218_224, 0002_capture_233_token_meta) + meta/. All hand-authored,
non-journaled SQL sidecars live in db/migrations/ (the catchup, the taxonomy CHECK, the slots backfill).
drizzle-kit only ever applies the journaled set; the sidecars are applied manually (psql / MCP) when needed.
Legacy-prod apply order (only for an old schema-v4-neon-bootstrapped DB):0000 → catchup sidecar →
0001_capture_217_218_224 → 0002. A fresh DB just runs drizzle-kit migrate (0000→0001→0002).
✅ CI guards (2026-06-26):.github/workflows/db-guard.yml enforces this convergence on every PR/push
touching the schema — two zero-dependency gates: db:check (drizzle-kit check — migration history is
consistent) and a drift gate (db:drift → db:generate must produce nothing, else schema.ts changed
without a committed migration). See workers/package.jsondb:check / db:drift. A from-scratch
apply-verify is documented as an opt-in job in the workflow (needs a NEON_API_KEY secret — drizzle-kit migrate uses the websocket-only Neon driver, so it can’t target a plain Postgres container).
| db/schema-v4-neon.sql | Hand-maintained bootstrap for fresh databases |
| db/seed-design-tokens-phase-4.2.sql | 152-token seed data (requires is_base column) |
This database has two __drizzle_migrations tables, and only one of them is
real. Every hand-apply that writes the wrong one silently wedges the next
pnpm db:migrate.
table
read by
drizzle.__drizzle_migrations
✅ drizzle-kit migrate — the authoritative ledger
public.__drizzle_migrations
❌ nothing. Inert clutter from pre-2026-07-26 sessions
drizzle-kit passes migrationsSchema: undefined when the config carries no
migrations block (workers/drizzle.config.ts carries none), and
drizzle-orm’s pg-core/dialect.js then defaults it to 'drizzle'. An
unqualifiedINSERT INTO "__drizzle_migrations" in an MCP/psql session lands
in public under the default search_path — i.e. nowhere. Always schema-
qualify: drizzle."__drizzle_migrations".
The cursor is the single row with the maximum created_at, not a per-hash
lookup (see Ledger drift
for the migrator source). So one missing row doesn’t just skip itself — it makes
db:migrate re-run that migration and every migration after it.
Symptom:pnpm db:migrate aborts with ... already exists on DDL you know
is live. Diagnosis: compare the two tables —
SELECT'drizzle'ASschema, count(*), max(created_at) FROM drizzle."__drizzle_migrations"
UNION ALL
SELECT'public', count(*), max(created_at) FROM public."__drizzle_migrations";
If public is ahead, a previous session wrote to the phantom. As of
2026-09-14 the healthy shape is drizzle = 44 rows / max(created_at)
1789342797653 (idx 40) against public = 36 rows / 1787579337232 (idx 36,
frozen since the 2026-09 incident below).
Repair: write the missing rows into drizzle."__drizzle_migrations" using
each file’s true sha256(file bytes) and its journal when (from
workers/drizzle/meta/_journal.json) — the exact pair drizzle-orm itself
inserts. Never type a hash by hand; never invent a timestamp.
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
Then re-run pnpm db:migrate and confirm it is a no-op (row count unchanged
across two consecutive runs).
This has bitten twice. 2026-07-26: the doc itself had been reconciling the
phantom, leaving idx 30 unrecorded (§Ledger drift). 2026-09: 0036’s DDL
(collection_access) was applied live but recorded in public, so the real
ledger’s cursor stayed at idx 35 — db:migrate would have re-run 0036, died
on already exists, and never reached 0037. That is why the
idx_assets_storage_path index sat unapplied from 30 August to 3 September: the
command meant to apply it could not get past the migration before it. Both rows
(36 + 37) were then written into drizzle.__drizzle_migrations by the recipe
above; 0038–0040 (2026-09-14) went to the same table.
Baselining drizzle against an already-migrated production database
The Neon prod instance was bootstrapped from db/schema-v4-neon.sql and then
had several ALTER TABLE statements applied live (before this migration system
existed). The baseline migration 0000_medical_stryfe.sql encodes the full
current schema — running it on prod would try to create tables that already
exist and fail.
To tell drizzle-kit “this database is already up to date with migration N”, you
must insert a row into the __drizzle_migrations table for each migration file
you want to mark as already applied.
Prerequisites: you are connected to the Neon prod database via the Neon MCP
tool and the drizzle.__drizzle_migrations table does not yet exist.
⚠️ Every statement below is schema-qualified on purpose — an unqualified
__drizzle_migrations resolves to public, which drizzle-kit never reads.
See “Two ledger tables” above.
Then insert that hash (and the Unix-ms timestamp of when the baseline was
generated — use Date.now() or any recent value):
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
VALUES ('<hash-from-above>', <timestamp-ms>)
ON CONFLICT DO NOTHING;
Step 3 — Apply the catch-up migration to prod (if not already applied)
db/migrations/20260626_catchup_token_model_sidecar.sql contains only
ADD COLUMN IF NOT EXISTS and CREATE INDEX IF NOT EXISTS statements. It is
safe to run on the live prod database:
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
VALUES ('<hash-from-above>', <timestamp-ms>)
ON CONFLICT DO NOTHING;
After both rows are inserted, pnpm db:migrate will correctly skip the baseline
on future runs and apply genuinely new journaled migrations.
Step 4 — Apply the captured-drift migration (0001_capture_217_218_224)
This is the journaled drizzle idx-1 migration (#217 + #218 + #224 columns/tables).
It is NOT idempotent (plain ADD COLUMN / CREATE TABLE), so it must run exactly
once, against a prod that is at the 0000 baseline + the catchup sidecar but does
not yet have these columns. Since prod was last touched live before #217/#218/#224,
that should hold — but verify each target column/table is absent first (e.g.
SELECT column_name FROM information_schema.columns WHERE table_name='dom_nodes' AND column_name='component_instance';).
Once 0000 is marked applied in drizzle."__drizzle_migrations" (Step 2) and the catchup
ran (Step 3, non-journaled), pnpm db:migrate will apply 0001_capture_217_218_224
as the next journaled migration. Review the SQL, confirm the absence checks, then apply.
The catch-up migration assumes source_component_id (on design_tokens) was
added as a bare nullable uuid with no FK constraint, matching what schema.ts
declares. If prod actually has a FK constraint on that column pointing to
components(id), the ADD COLUMN IF NOT EXISTS will be a no-op (column
exists) and no harm is done — but the FK would not be created. Verify with:
SELECT conname, contype FROM pg_constraint
WHERE conrelid ='design_tokens'::regclass AND contype ='f';
workers/drizzle/0018_demonic_wind_dancer.sql — Journaled idx 18, two additive
changes from claude/editor-ui-review-179yrg:
(a) site_font.axes jsonb — variable-font axes parsed from the uploaded file’s fvar
table at upload ([{tag,label,min,max,def,step?}]; NULL → static/unknown);
(b) new symbols table (D:symbols-v1) — user-created reusable components; master = a
real dom_nodes subtree (master_node_id), instance roots carry
reusable_component_id + instance_status; CREATE TABLE + 2 FKs + target index.
Merge history: originally shipped as 0017_parched_nekra + 0018_workable_chameleon
and applied to prod 2026-07-12 (MCP; Workers CI runs against the live DB, so the additive
column had to land before merge — the two 500s in deferred-features/integration were
Drizzle selecting the not-yet-existing axes column). Print Mode independently claimed
idx 17 (0017_white_thunderbolt), so on the master merge both files were dropped and
REGENERATED as this single idx-18 migration against the print-mode 0017 snapshot
(identical DDL, clean snapshot chain). Prod ledger reconciled the same day (MCP): the two
superseded rows deleted, one row inserted with this file’s sha256 + journal when —
verified contiguous, pnpm db:migrate is a no-op.
workers/drizzle/0030_orange_justin_hammer.sql — Journaled idx 30, one
additive change from claude/bondlayer-importer-binding-review-8apy4y (D:assets-target-id):
assets.target_id uuid (nullable, FK → targets(id)ON DELETE SET NULL) plus its
index. Lets GET /api/import/status/:targetId, POST /api/import/retry/:targetId and the
Render finalize-assets callback count and re-drive one site’s assets instead of the whole
project’s. SET NULL rather than CASCADE is deliberate — deleting a target must not
delete bytes a sibling site still renders; the row reverts to a project-level library
asset. Applied to prod (Neon Skyfall-V2 / round-dust-84750148) on 2026-07-26 via
the Neon MCP session that shipped PR #356.
db/migrations/20260726_assets_target_id_backfill.sql — non-journaled sidecar for the
above. Every asset row predating idx 30 has target_id IS NULL, so the new target-scoped
queries matched ZERO of them: a site’s import read 0/0 and its unmaterialized assets could
never be re-driven. Attributes an asset to its project’s target only when that project has
exactly ONE target (the only inference that is always correct); multi-target projects are
left NULL rather than guessed. Idempotent (touches only NULLs), additive.
Applied to prod 2026-07-26, same session, and verified: 1134/1134 attributed, 0
unattributed, 1 distinct target.
sha256 eef59094a26de4911f50f1652d839c605220ff5fa92e0d1172665c17d8e2af9a.
⚠️ Neither of the two rows above was recorded in __drizzle_migrations at the time — and,
as it turned out, the doc’s own reconciliation for that predates a bigger correction; see
Ledger drift below.
⚠️ Update 2026-07-26 (verification session): the target these 1134 rows were attributed
to was deleted before a re-import landed. assets.target_id is ON DELETE SET NULL, so all
1134 rows reverted to target_id IS NULL — orphaned again, independent of this backfill.
See the resolution below.
workers/drizzle/0031_font_metrics.sql — Journaled idx 31 (D:text-trim,
custom-font extension): global content-addressed font_metrics table (PK = lowercase
sha256 hex of the font file bytes; family, metrics jsonb, source, created_at) +
site_font.metrics_hash text (nullable, indexed, deliberately NOT an FK — a dangling
hash renders as “no metrics”, and an FK would turn the benign PATCH-before-PUT ordering
race into a hard failure). Purely additive, no backfill. Rows are immutable by
construction: the route upserts ON CONFLICT DO NOTHING (first write wins) and the
tenant role holds no UPDATE/DELETE grant. ✅ Applied to prod + recorded in
__drizzle_migrations (Neon Skyfall-V2 / round-dust-84750148) 2026-07-27 (MCP),
in one transaction with the RLS sidecar below; ledger row inserted with the file’s
sha256 (2896cc7d…) + journal when (1785150255418); verified live (table + column +
index present, ledger contiguous at id 35, authenticated grants exactly SELECT,INSERT).
db/migrations/20260727_font_metrics_rls.sql — non-journaled RLS sidecar for the
above: ENABLE RLS + world-read SELECT policy (global non-sensitive data, the
components-catalog shape) + INSERT gated on clerk_user_id() IS NOT NULL; grants
SELECT+INSERT only to authenticated (no UPDATE/DELETE = deny-closed immutability, the
audit_events precedent). Idempotent; inert under the BYPASSRLS service role.
✅ Applied to prod 2026-07-27 alongside 0031, same transaction.
db/migrations/20260726_assets_source_url_backfill.sql — NOT APPLIED. Resolved as
inert / superseded, 2026-07-26 (verification session, approved). Written to reconstruct
assets.metadata.sourceUrl from cdn_url so POST /api/import/retry/:targetId could
reach the 1134 rows above. Before it was ever applied, the target those rows belonged to
was deleted (see the update note above), setting their target_id to NULL — and
retry is target-scoped, so restoring sourceUrl on an orphaned row would not have made
it reachable anyway. This is item (c) from the file’s own decision tree (“orphaned rows
exist → that is a separate cleanup with its own tradeoff”), not (a).
Live-DB check confirmed all 1134 rows matched the sidecar’s WHERE clause exactly
(pending = orphaned = origin_cdn = 1134, have_source = materialized = 0) — same
pre-#356 stranded shape as always — and confirmed none were referenced by any dom_nodes
or content_items in the owning project (its only surviving target is an unrelated, empty
test target) and none had ever been materialized into R2 (materialized = 0, so no R2
objects to reconcile). Deleted rather than patched:
DELETEFROM assets
WHERE target_id ISNULL
AND project_id ='org_3GVE1A4LDgOu2RIH3cRXV3bXaqR'
AND metadata->>'pendingMaterialization'='true'
AND cdn_url ~ '^https?://'
AND cdn_url NOTLIKE'%/api/assets/serve/%';
Verified: assets table is now empty (0 rows) — those 1134 were the only rows in it.
The sidecar file matches 0 rows on prod as of 2026-07-26 and is not deleted — kept as
protection in case the same stranded-row shape recurs for a different project (it’s
idempotent and additive, so an empty match is a safe no-op). If a future retry/import run
ever needs it, re-run the before-state query in the file header first — do not assume
it still matches nothing.
Ledger drift — __drizzle_migrations was behind the journal (RECONCILED 2026-07-26)
Every prior write-up here (including the “12 rows / 31 journal entries” premise reported
when PR #356 was reviewed) assumed __drizzle_migrations lives in the public schema,
because that’s where earlier hand-run MCP reconciliation sessions had been writing it. It
doesn’t. drizzle-orm’s migrator (drizzle-orm/pg-core/dialect.{ts,cjs}, the code
drizzle-kit migrate actually calls) defaults migrationsSchema to drizzle, and this
project’s drizzle.config.ts never overrides it. So prod has carried two independent
__drizzle_migrations tables — public.__drizzle_migrations (a phantom ledger nothing
reads) and drizzle.__drizzle_migrations (the real one) — and this doc had been reconciling
the phantom one.
The real migrator’s “already applied?” check is also not a per-hash lookup. From
PgDialect.migrate:
const dbMigrations = await session.all(
sql`select id, hash, created_at from ${schema}.${table} order by created_at desc limit 1`
);
const lastDbMigration = dbMigrations[0];
// ...
if (!lastDbMigration||Number(lastDbMigration.created_at) <migration.folderMillis) {
// run this migration's SQL, then insert a row for it
}
It fetches only the single latest row and re-runs every journal entry whose when is
greater than that row’s created_at. Individual hashes are written for audit purposes but
never re-checked — the row with the maximum created_at is the entire cursor.
Querying drizzle.__drizzle_migrations directly (Neon MCP, round-dust-84750148,
production branch) showed 31 rows already present, covering idx 0000–0029 by
created_at high-water mark. The only journal entry newer than the latest row (0029,
created_at 1784903723416) was 0030_orange_justin_hammer (when 1784999614729) — so
pnpm db:migrate tried to re-run it and failed on column "target_id" of relation "assets" already exists (that DDL had genuinely already run against prod, per the D:assets-target-id
entry above — it was just never given a ledger row in the schema that matters).
Verified via information_schema that idx 0011–0030’s objects (the target_domains
table, targets.breakpoints/token_overrides, every table/column added through 0030) all
already exist on prod — so this was a pure ledger gap, not a missing-DDL situation, exactly
as 0012–0029’s rows already on file implied.
Fix applied (2026-07-26, MCP, approved):
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
-- idx 30, 0030_orange_justin_hammer.sql, hash generated from the file (never typed by hand)
Verified:pnpm --filter ./workers db:migrate against prod now reports “migrations
applied successfully” with zero new rows inserted (drizzle.__drizzle_migrations count
unchanged at 32 across two consecutive runs) — a true no-op. db:check and db:drift both
clean.
Loose ends from this reconciliation (harmless, not re-touched)
public.__drizzle_migrations now holds 32 stray rows (12 pre-existing + 20 inserted
during this session before the schema mistake was caught, covering idx 0011–0030 by
the old, wrong procedure below). Nothing reads this table, so it’s inert clutter, not a
bug — left as-is rather than deleted without a separate ask. A future session could drop
it entirely; flagging here so nobody mistakes it for the live ledger again.
drizzle.__drizzle_migrations has three garbled historical rows at created_at1784644005837, 1784644005838, 1784903723416 (nominally idx 0027–0029): the first
carries idx 0028’s hash under idx 0027’s timestamp, the second has a hash matching no
current journal file, and the third has idx 0025’s hash with its last hex digit flipped
(...415e → ...415f) — a plausible hand-typed reconciliation typo from before this
session, i.e. exactly the mistake “generate, never type a hash by hand” exists to prevent.
Harmless to db:migrate (only the max created_at matters, not per-row hash correctness),
so left unfixed rather than issuing speculative UPDATEs; worth a cleanup pass if the
ledger’s audit trail ever needs to be trustworthy row-by-row.
The two non-journaled sidecars in this session (20260726_assets_target_id_backfill.sql,
20260726_assets_source_url_backfill.sql) are unaffected by any of the above — drizzle-kit
only manages the journaled set; sidecars are tracked in this document, not in either ledger.
Bondlayer importer live-DB verification checklist (blocked on deploy — see STATUS.md)
PR #360 (D89–D94) fixed the asset-pipeline bugs from #356, with a real-Postgres/real-R2 e2e
test (workers/test/node/import-materialization-e2e.test.ts) — but that test uses a
filesystem-backed R2 and PGlite, not the actual deployed Worker + Cloudflare R2 + Neon. This
session tried to close that gap by triggering a live Bondlayer re-import against prod
(Skyfall-V2/round-dust-84750148) and running the checks below, but the live API turned out
to be running pre-#356 code — see the 2026-07-26 entry at the top of STATUS.md for why
(GitHub Actions billing outage; Deploy API to Cloudflare Workers hasn’t run since PR #347).
The 124 asset rows that import produced were deleted (stuck pendingMaterialization, no
target_id, no sourceUrl, unreachable by retry — the pre-#360 bug reproducing itself on the
currently-live code). None of the checks below have been run against genuinely post-#360
code yet. Once billing is fixed and a real deploy goes out, re-run the Bondlayer import and
run these against the new target (<newTargetId>):
Original-name R2 keys.storage_path should be ${projectId}/<source path>/<name>.<ext>
— no sha256 prefix, no UID, always inside the ${projectId}/ prefix:
Both should be 0. Also check site_font.source_url/font_face_css.
Re-import is idempotent — the single most important check, and the originally reported
bug. Record count(*) of assets, dom_nodes, content_items for the target, re-run the
SAME import, confirm counts are unchanged and no new R2 objects appeared.
SELECTfilename, mime_type, storage_path FROM assets
WHERE target_id ='<newTargetId>'ANDfilenameLIKE'%.json';
Collection bindings resolve — no content_type_id pointing at nothing:
SELECTcount(*) FROM dom_nodes WHERE target_id ='<newTargetId>'
AND data_binding->'data_source'->>'content_type_id'IS NOT NULL
AND data_binding->'data_source'->>'content_type_id'NOTIN
(SELECT id::textFROM content_types WHERE target_id ='<newTargetId>');
Expect 0.
If any of these fail against genuinely post-#360 code, that’s a real regression — report it
with the query output rather than reshaping the check to pass.
workers/drizzle/0033_detail_template_variants.sql — Journaled idx 33
(D:detail-variants, Phase 9.1/9.2; renumbered from 0032 on merge — master
independently took idx 32 for the Phase 3b constraint-model migration, so this was
regenerated against master’s 0032 snapshot, db:check clean): three additive
columns, one FK, one index.
site_pages.detail_collection_id uuid NULL REFERENCES content_types(id) ON DELETE SET NULL
site_pages.detail_template_key text NULL store the page↔collection detail-template
link that was previously only DERIVED (/:slug path segment + a content_item root
binding); content_items.detail_template_key text NULL is the per-item template choice.
idx_site_pages_detail_collection (target_id, detail_collection_id) backs the
per-publish/per-session template-index build. Purely additive — every existing row reads
NULL, so derivation remains the fallback and publish output is byte-identical until an
author (or the importer) stores a link. No RLS sidecar needed: new columns on
site_pages/content_items ride those tables’ existing policies. ✅ Applied to prod +
recorded (verified live 2026-08-22: site_pages.detail_collection_id present, ledger row
hash=ba0284ce… matches the file).
workers/drizzle/0034_atier_fts_content_items.sql — Journaled idx 34
(D190, A-tier full-text search): adds content_items.fts tsvector as a STORED
generated column (jsonb_to_tsvector('simple', data, '["string"]')) plus GIN index
idx_content_items_fts. Purely additive; no RLS sidecar (rides content_items’
existing policies). ⚠️ This entry was backfilled 2026-08-22 during the doc reset —
the migration shipped without its ledger row (the gap this file exists to prevent).
✅ Applied to prod + recorded (verified live 2026-08-22: content_items.fts
present, ledger row hash=a5095620… matches the file).
workers/drizzle/0035_concerned_korath.sql — Journaled idx 35
(D:import-auto-optimizer): new optimizer_runs table — per-target Import
Auto-Optimizer analysis runs (status, report jsonb, error, timestamps;
target_id FK ON DELETE CASCADE + idx_optimizer_runs_target). Purely additive
(CREATE TABLE). Generated 2026-08-22 (offline, pnpm db:generate).
✅ Applied to prod + recorded 2026-08-22 (MCP) — one transaction: table + FK +
index + the RLS sidecar below + ledger row (hash=50ae4d98…,
created_at=1787412128013 = the journal when); verified live (table present,
RLS enabled, 1 policy, ledger head = 0035). Companion RLS:
db/migrations/20260822_optimizer_runs_rls.sql.
workers/drizzle/0036_furry_krista_starr.sql — Journaled idx 36
(D:collection-access): new collection_access table — ONE row per
collection (content_type_id is the PK, FK → content_types ON DELETE
CASCADE), carrying project_id (FK → projects ON DELETE CASCADE),
mode ('inherit'|'restricted', default 'inherit'), rules jsonb
(default '[]'), updated_by (FK → users), timestamps, and
idx_collection_access_project. Purely additive (CREATE TABLE + 3 FKs + 1
index; no backfill). Zero rows on apply is the intended state — absence of
a row means INHERIT, i.e. today’s “every project member reaches every
collection”, so applying this migration changes no behaviour for anyone until
an admin restricts a collection through
PUT /api/content-types/:id/access. Generated 2026-08-24 (offline,
pnpm --filter ./workers db:generate); filename is drizzle’s own (not
renamed — the journal is a generated artifact). ✅ Applied to prod + recorded
(ledger row 42, hash=82444b21…, created_at=1787579337232) — found already
applied during the 2026-09-14 prod audit; this row had lagged the apply.
Companion RLS: db/migrations/20260824_collection_access_rls.sql.
db/migrations/20260824_collection_access_rls.sql — Non-journaled
sidecar — RLS for collection_access: enable RLS + a FOR SELECTis_project_member(project_id) policy + a SELECT-only grant to
authenticated (the audit_events shape, not the usual GRANT ALL). This
table decides who may reach a collection, so a tenant-role UPDATE grant would
let any project member rewrite the rules that gate them. Both policy routes
and every enforcement read run on the SERVICE connection instead — the latter
deliberately, because under RLS an invisible policy row would read as “no
policy” (= inherit) and the gate would fail OPEN. Idempotent; inert under the
service role. ✅ Applied to prod 2026-09-14 (MCP) — the table had been
live since 0036 with RLS DISABLED and no grant (the exact defect class the
symbols/backups backfills document); caught by the 2026-09-14 prod audit and
applied then. Verified live (RLS enabled, tenant_collection_access_select,
authenticated SELECT only).
db/migrations/20260822_optimizer_runs_rls.sql — Non-journaled sidecar —
RLS for optimizer_runs: glass shape (can_access_target(target_id) FOR ALL
policy, like dom_nodes/backups/symbols) + full DML grant to authenticated
(POST /run writes and GET /runs reads both run on the tenant connection).
Shipped WITH the table, per the defect class the symbols/backups/font_metrics/
token_sets backfills document. Idempotent; inert under the service role.
✅ Applied to prod 2026-08-22 alongside 0035.
workers/drizzle/0037_assets_storage_path_index.sql — Journaled idx 37
— one statement: CREATE INDEX "idx_assets_storage_path" ON "assets" USING btree ("storage_path"). Purely additive (no column, no constraint, no backfill) and
no RLS sidecar — an index rides assets’ existing policies.
Why:storage_path was the one asset lookup key without an index
(project_id, hash and target_id all had one). The anonymous serving
routes — workers/src/routes/assets-public.ts (GET /api/asset-pub/:key, the
URL baked into every published page) and its CDN twin
workers/src/routes/assets-cdn.ts — authorize a request by looking the row up
on storage_path alone and reading its metadata.public flag. That is one
lookup per asset per uncached request, on a table that already carries
thousands of rows per project (D253), and every one of them was a seq scan.
Single-column, not a composite — measured, not asserted. There are two
anonymous doors, and only one of them has a project in its predicate:
platform origin — GET /api/asset-pub/:key, storage_path = $1 alone.
published host — the D:published-host-assets short-circuit
(servePublicAsset(c, key, { scopeTargetId })), whose scope is an inner join
assets → targets ON targets.project_id = assets.project_id with
targets.id = $2. That door landed on master 2026-08-30 as 85b9a0c0
(PR #633); it is cited by slug because the decision has not claimed its
D<n> yet. This index is correct before and after it.
EXPLAIN (ANALYZE, BUFFERS) on 30 000 rows (real planner, via PGlite — the
engine the *.pglite.test.ts suites use), buffers on the assets access:
index
platform door
published-host door
(none)
seq scan, 232
232
(storage_path)
1
5
(storage_path, project_id)
1
5
(project_id, storage_path)
62
5
A project_id-led composite is 62× worse on the hot path: the platform
door leaves the leading column unconstrained, so the scan degenerates to a full
index walk. A storage_path-led composite ties the plain index on buffers
while costing ~14% more index (2288 kB vs 2008 kB) — storage_path already
carries a ${projectId}/ prefix, so it is near-unique alone and the second
column buys no selectivity. The scoped door’s targets side is a primary-key
lookup, so it needs no new index of its own.
The two publish.ts flag statements (project_id = $1 AND storage_path IN (…)) run once per publish and are served by this index or by
idx_assets_project.
Generated 2026-08-30 (offline, pnpm --filter ./workers db:generate); filename
is drizzle’s own --name. ✅ Applied to prod + recorded (ledger row 43,
hash=302c2552…, created_at=1788094088129) — found already applied during
the 2026-09-14 prod audit; this row had lagged the apply. On a re-apply, consider
CREATE INDEX CONCURRENTLY run by hand instead of the journaled statement —
plain CREATE INDEX takes a write lock on assets for the duration. At the
current row count that is sub-second, so the journaled form is fine; the
concurrent form is the safer choice if the table has grown. If you do run it
concurrently, record the ledger row for this file anyway so the journal and
__drizzle_migrations stay converged.
workers/drizzle/0038_site_auth_providers.sql — Journaled idx 38
— creates site_auth_providers (D:member-google-oidc): one row per
(target_id, provider) holding a site’s OWN OAuth client for member
sign-in — client_id, client_secret_enc (sealed-box ciphertext ONLY, never
plaintext), client_secret_hint (last 4), enabled, created_by. FKs cascade
from projects and targets; unique (target_id, provider); project_id
index. Purely additive (new table, no backfill).
Generated 2026-09-02 (offline, pnpm --filter ./workers db:generate --name site_auth_providers). ✅ Applied to prod + recorded 2026-09-14 (MCP) —
ledger row 44 (hash=8c315576…, created_at=1788380216920 = the journal
when), in one transaction with its sidecar and 0039/0040; verified live
(table present, RLS enabled, tenant_site_auth_providers_all, authenticated
grants SELECT/INSERT/UPDATE/DELETE).
db/migrations/20260902_site_auth_providers_rls.sql — Non-journaled
sidecar — RLS for site_auth_providers: enable RLS + FOR ALL policy
tenant_site_auth_providers_all on can_access_target(target_id) (the
target_domains pattern) + guarded grants to authenticated. Shipped WITH the
table. Idempotent; inert under the BYPASSRLS service role, which is the
connection the public sign-in flow reads the row on (single own-target lookup,
like the magic-link path). ✅ Applied to prod 2026-09-14 (MCP) alongside
0038.
workers/drizzle/0039_canvas_comments.sql — Journaled idx 39
(D:canvas-comments): new canvas_comments table — preview-canvas review
pins that feed the designer’s To-do panel. Columns: project_id (FK →
projects CASCADE, denormalized for a project-wide inbox), target_id (FK →
targets CASCADE, the glass-shape tenant column), page_id (FK →
site_pages CASCADE — a remark about a deleted page has nothing to point at),
node_id (nullable, FK → dom_nodesSET NULL — deleting the pinned
element must NOT delete the remark; the pin re-places itself through the
anchor’s page-relative fallback), anchor jsonb (CanvasCommentAnchor in
@matriz/schema: node-relative x/y + page-relative page_x/page_y +
viewport_width/lang context), body, FK-less author_id + author_label
(the audit_events precedent — the users projection is webhook-fed and may
lag a new member’s first session), status ('open'|'resolved', default
open), resolved_by/resolved_at, timestamps; indexes on
(target_id, page_id), (target_id, status), (node_id). Purely additive
(CREATE TABLE + 4 FKs + 3 indexes; no backfill — the table starts empty).
Generated 2026-09-13 (offline, pnpm --filter ./workers db:generate; renamed
from drizzle’s 0039_cooing_agent_zero, journal tag updated to match).
✅ Applied to prod + recorded 2026-09-14 (MCP) — ledger row 45
(hash=3b41ee76…, created_at=1789304644007); verified live (table + 3
indexes present, RLS enabled, tenant_canvas_comments_all, authenticated
grants; GET /api/comments?target_id=… returns 200 on prod).
db/migrations/20260913_canvas_comments_rls.sql — Non-journaled
sidecar — RLS for canvas_comments: enable RLS + FOR ALL policy
tenant_canvas_comments_all on can_access_target(target_id) (the
backups/optimizer_runs pattern) + guarded SELECT, INSERT, UPDATE, DELETE
grants to authenticated — workers/src/routes/comments.ts runs all four
verbs on the tenant connection. Per-row ownership (only the author or a
content:write holder may edit/delete a comment) is an application rule in
the route, not a policy. Shipped WITH the table. Idempotent; inert under the
BYPASSRLS service role. ✅ Applied to prod 2026-09-14 (MCP) alongside 0039.
workers/drizzle/0040_tired_darkhawk.sql — Journaled idx 40 — four
additive columns on forms: opens_at / closes_at (timestamptz, nullable —
a submission window), closed_message (text, nullable) and derive jsonb NOT NULL DEFAULT '[]' (derived-field rules). Purely additive (ADD COLUMN; the
only NOT NULL column carries a default, so existing rows need no backfill).
Renumbered to idx 40 on merge behind 0039. No RLS sidecar: new columns on
forms ride its existing tenant_forms_all policy. ✅ Applied to prod +
recorded 2026-09-14 (MCP) — ledger row 46 (hash=afcad249…,
created_at=1789342797653); verified live (forms has 19 columns).
workers/drizzle/0041_worthless_agent_brand.sql — Journaled idx 41
(D:delete-end-to-end). Creates target_deletions,
the per-site deletion debt: asset_manifest / domain_manifest (captured
before assets.target_id‘s SET NULL and target_domains’ CASCADE destroy the
links), phase + phase_cursor (the resume point), counters, run_after
(the cron’s retry clock) and warnings. One FK — project_id ON DELETE
CASCADE, so a whole-project delete discards its per-target debts (that path
purges <projectId>/ wholesale). Two indexes: (status, run_after) is the
only query the cron makes, (target_id) for lookups. Purely additive
(CREATE TABLE, no backfill, starts empty). Generated 2026-09-15 (offline,
pnpm db:generate); db:check and db:drift both clean.
✅ Applied to prod + recorded 2026-09-15 (MCP) — Skyfall-V2round-dust-84750148, in ONE transaction: the file’s 4 DDL statements → the
sidecar below (ENABLE RLS + guarded REVOKE) → the ledger row, written
SCHEMA-QUALIFIED into drizzle."__drizzle_migrations" per “Two ledger tables”
above (hash=1cc1c2a5…, created_at=1789420253954 = the journal when for
idx 41, per the 0026/0033 precedent). db:migrate cannot run from the
agent sandbox — outbound TCP :5432 is blocked — so MCP over HTTPS, the
documented prod path.
The two-ledger diagnosis query was run afterwards and confirms the row landed
in the REAL ledger, not the phantom: drizzle 44 → 45 rows, high-water
1789342797653 → 1789420253954; public untouched at 36 rows /
1787579337232, still frozen at idx 36 exactly as that section records. So
pnpm db:migrate is a no-op rather than wedged.
Dry-run first, as 0025/0026 established: ephemeral branch
migrate-0041-dryrun (br-round-boat-as9yqiyj), a HEAD copy of prod,
received the byte-identical transaction and was verified before prod was
touched. Both runs verified the same nine facts: 21 columns, 3 indexes (2 +
PK), 1 FK, relrowsecurity = t, 0 policies, 0 authenticated grants,
ledger 45 rows, high-water 1789420253954, table empty.
Pre-state checked before anything ran: target_deletions absent, ledger 44
rows at high-water 1789342797653 (= 0040), i.e. contiguous with no gap to
backfill — unlike the 0025 case. pnpm db:migrate is a no-op again.
⚠️ Ordering note, now moot for prod but true for any other database: the
deployed Worker NEEDS this table — DELETE /api/targets/:id inserts the debt
row in the same request that deletes the target. Code ahead of migration would
500 every site delete on a missing relation (the 0010 failure shape). The
route therefore detects SQLSTATE 42P01 and falls back to the old in-request
cascade, so an un-migrated database degrades instead of breaking.
db/migrations/20260915_target_deletions_rls.sql — Non-journaled
sidecar, shipped WITH 0041. RLS for
target_deletions, in the service-only, deny-closed shape
member_login_tokens established: ENABLE ROW LEVEL SECURITY, no policy,
no grant (plus a guarded REVOKE ALL against an out-of-band grant).
Deny-closed is the design, not an omission. Both writers are already on the
SERVICE (BYPASSRLS) connection — the delete route uses getServiceDB (it did
before this table existed, because the cascade reads rows tenant policies were
never written for) and the 15-minute cron has no request and so no tenant JWT
at all. Meanwhile a row names R2 storage paths and custom hostnames, and rows
for EVERY project share the table: the cron selects across all of them by
run_after, never by project. A tenant-readable version would expose one
project’s member-upload keys to another’s builder. The project_id FK is
there to cascade the debt away, not to scope reads.
Verified on a fully-migrated PGlite database (2026-09-15) before the prod run:
applies clean, is idempotent, and leaves relrowsecurity = t with 0 policies
and 0 authenticated grants. The file carries those verification queries.
✅ Applied to prod 2026-09-15 (MCP) in the same transaction as 0041,
and verified live with the same three queries — plus authenticated confirmed
to EXIST on this database, so the guarded REVOKE ALL was a real operation
rather than a skipped branch. No __drizzle_migrations row (non-journaled).
workers/drizzle/0043_breezy_silk_fever.sql — Journaled idx 43
(D:page-exclusion, Wave A). Adds site_pages.exclude_from_publish boolean NOT NULL DEFAULT false — page-level “exclude this page from publish”: the
publish route skips a page-root whose row has this set (never rendered,
never written to R2, no per-item fan-out for a detail-template root) and
drops it from sitemap.xml; the homepage (page_path = '/') can never be
set to true, enforced server-side in the PATCH .../site-pages/:pageId
route before the write. A separate column rather than reusing status:
'draft' already carries an unrelated WIP meaning, and folding this into it
would silently change behavior for existing sites that already use draft/
published. Purely additive (ADD COLUMN with a default — every existing row
reads false, byte-identical publish behavior until a page opts in).
Generated 2026-09-21 (offline, pnpm db:generate).
⚠️ NOT yet applied to prod. Verified only via the PGlite migrated-
snapshot test suite (pnpm ci:local/pnpm --filter ./workers test:node),
per this repo’s db:migrate-is-owner-only convention — no MCP/live-Neon
apply was run for this change. No RLS sidecar needed: a new column on
site_pages rides its existing policies (same reasoning as 0022’s
members_only/required_role pair).
workers/drizzle/0044_text_slot_canonical.sql — Journaled idx 44
(D:text-slot-canonical). A custom (drizzle-kit generate --custom)
migration: no table/column change, so 0044_snapshot.json is a byte copy of
0043’s and db:drift/db:check stay green. It installs a plpgsql
BEFORE INSERT OR UPDATE trigger on dom_nodes
(dom_nodes_text_slot_mirror(), plus the matriz_is_text_jsonb(jsonb)
helper) that keeps the legacy content column a MIRROR of
slot_values->'text'->'value' for plain text nodes (component_id IS NULL,
text slot has a value and no non-null binding): an INSERT or a slot-value
change copies slot → content (slot wins when both change); a content-only
UPDATE copies content → slot. Only text-shaped values move (a JSON string,
or a non-empty all-strings locale bag). Rows outside that scope are returned
untouched. Idempotent (CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS). Generated 2026-09-23.
Why a trigger and not an API guard: the incident it closes was DIRECT SQL
(an agent over Neon wrote content alone on 19 nodes — invisible on the
page, because every renderer shows the slot). No request reached the Worker.
Tests: workers/test/node/text-slot-mirror-trigger.pglite.test.ts.
⚠️ NOT yet applied to prod. It only acts on rows as they are written; it
does not reconcile rows that ALREADY diverge (the 19 nodes above, whose
content holds the intended text and whose slot is stale). Repair those
first — UPDATE dom_nodes SET slot_values = jsonb_set(slot_values, '{text,value}', content) WHERE id IN (…) for the known ids, run BEFORE or
AFTER this migration (after it, a SET content = content no-op does not
fire the mirror; write the slot explicitly). A blanket slot→content backfill
would be wrong for exactly those rows.
workers/drizzle/0045_design_rules.sql — Journaled idx 45
(D:design-md, plans/DESIGN-MD-SPEC.md §5.1). Creates design_rules — a
target’s RELATIONAL design rules (rule_id from the kind registry in
@matriz/schema/design-rules, params jsonb validated per kind, status
candidate|enforced|rejected, source mined|human, support/n,
exceptions jsonb, why/note, timestamps); FK target_id → targets
ON DELETE CASCADE; unique (target_id, rule_id) (also the index the emit
pass’s one lookup uses). Adds pending_intents.adjustments jsonb NOT NULL DEFAULT '[]' — what the emit policy pass fixed or flagged. Purely additive
(CREATE TABLE + ADD COLUMN with a default; no backfill — a target with
no rule rows behaves exactly as before). Generated 2026-09-23 (offline,
pnpm --filter ./workers db:generate --name design_rules); db:check and
db:drift clean.
✅ Applied to prod 2026-09-23 (Neon MCP, owner-approved session). It was
NOT applied when #744 merged, so for about 40 minutes the deployed Worker
failed on design_rules / pending_intents.adjustments (AI proposals and the
Insertion Queue). Verified live afterwards: the Dev MCP read_constraints
returns { constraints, designRules } instead of relation "design_rules" does not exist. Lesson: the Worker deploys on merge, so a PR that ships a
migration is not mergeable until the migration is on prod.
db/migrations/20260923_design_rules_rls.sql — Non-journaled
sidecar, shipped WITH 0045. RLS for design_rules: enable RLS +
FOR ALL policy tenant_design_rules_all on can_access_target(target_id)
(the constraints/canvas_comments glass shape) + guarded SELECT, INSERT, UPDATE, DELETE grants to authenticated (routes/design-rules.ts runs on
the tenant connection). The new pending_intents column rides that table’s
existing policy. Idempotent; checked on a migrated PGlite database (applies
twice clean, relrowsecurity = t, one policy). ✅ Applied to prod 2026-09-23, in the same transaction as 0045.
workers/drizzle/0046_symbol_variant_axes.sql — Journaled idx 46
(D:component-variants). Adds symbols.variant_axes jsonb NOT NULL DEFAULT '[]' — a symbol’s variant axes: [{ id, name, defaultValueId, values: [{ id, name, deltas: { [masterNodeId]: { styles?, styleOverrides?, styleId? } } }] }]
(per-node style patches keyed by the master node id every instance node’s
component_instance.lineageId points at). The instance side needs no column:
the selection (component_instance.variantAxes) and the override ledger
(component_instance.overriddenFields / overriddenAttributes) live in the
existing dom_nodes.component_instance jsonb. Purely additive (ADD COLUMN
with a default; every existing symbol reads [] and syncs exactly as before).
Generated 2026-09-23 (offline, pnpm --filter ./workers db:generate, file
renamed from the drizzle default with the journal tag updated); db:check
clean. No RLS sidecar: a new column on symbols rides its existing
tenant_symbols_all policy (20260720_symbols_rls.sql). Verified via the
PGlite migrated-snapshot suite (workers/test/node/symbols-variants.test.ts).
✅ Applied to prod 2026-09-23, before the Worker that uses it deployed.
Verified via Neon: ledger row id 52 (hash=e0cdff8d…, created_at=1790165312436)
directly after 0045’s (id 51), and the column is present.
workers/drizzle/0047_assets_tier.sql — Journaled idx 47
(D:one-cdn-url). Adds assets.tier text NOT NULL DEFAULT 'member' — the
explicit visibility tier ('design' public from creation at the one cdn_url;
'member' private unless a published page references it). Purely additive
(ADD COLUMN with a default); the default is member so every existing row
keeps TODAY’s serving rule exactly until the dry-run-first backfill
(Dev MCP classify_asset_tiers) promotes design assets. Generated 2026-09-23
(offline, pnpm --filter ./workers db:generate, renamed from the drizzle
default with the journal tag updated). No RLS sidecar: a new column on
assets rides its existing policies.
✅ Applied to prod 2026-09-23 (MCP, owner-approved), just after the
Worker that reads it deployed (master b99a07e). Verified via Neon: ledger
row hash=19ce28fe…, created_at=1790175757784, and the column is present.
⚠️ Still owed: the classify_asset_tiers backfill — dryRun:true, review,
then dryRun:false.
workers/drizzle/0048_managed_zones.sql — Journaled idx 48
(auto-write DNS for platform-managed Cloudflare zones, 5fb5b6b6). New
managed_zones table (zone_id PK, unique zone_name, project FK cascade,
idx_managed_zones_project) + nullable target_domains.dns_auto jsonb.
Purely additive.
✅ Present on prod. Verified via Neon 2026-09-27: ledger row id 54
(hash=5b1a5681…, created_at=1790333923016). (Row back-filled into this
log on 2026-09-27; the apply itself predates it.)
workers/drizzle/0049_builds_target_pending_unique.sql — Journaled idx 49
(D:publish-content-modes, PR #818). Partial unique index
builds_target_pending_unique ON builds (target_id) WHERE status = 'pending'
— the per-target publish lock: claimPublishLock inserts a pending build
row before rendering, so a second concurrent publish (full or content, incl.
the ContentPublishDebouncerDO alarm) hits 23505 → 409 publish_in_progress.
A pending row older than 15 min is failed by the next claim. Purely
additive; without it the lock degrades to “no lock” (never an error).
✅ Applied to prod 2026-09-27 (MCP), right after #818 merged. Pre-check:
0 pending builds on prod, so the index could not fail. Applied in one
transaction with its ledger row (hash=000d08cb…, created_at=1790525284539),
directly after 0048’s (id 54).
workers/drizzle/0050_symbols_by_reference.sql — Journaled idx 50
(D:symbols-by-reference). symbols.by_reference boolean NOT NULL DEFAULT false — the opt-in flag that makes every renderer draw a symbol’s instances
from its master subtree at render time (@matriz/schema/symbol-reference).
Purely additive; every existing symbol reads false, i.e. copy-based sync
exactly as before. Written only by the Dev MCP convert_symbol_to_reference.
✅ Applied to prod 2026-09-28 (MCP over HTTPS, one transaction with the SCHEMA-QUALIFIED ledger row: drizzle 53 → 54 rows, hash=3e54b522…, created_at=1790628715586; public untouched at 36). Was: apply + record the ledger row before (or
with) the deploy that ships the code reading symbols.by_reference — until
then SELECT … FROM symbols on prod errors on the missing column.