Skip to content

Matriz — Database Migration Guide

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.


Path Purpose
workers/src/db/schema.ts Authoritative schema (Drizzle ORM)
workers/drizzle.config.ts Drizzle-kit config (dialect, schema path, output dir)
workers/drizzle/0000_medical_stryfe.sql Baseline migration — full schema from schema.ts (journaled idx 0)
workers/drizzle/0001_capture_217_218_224.sql Journaled idx 1 — real drizzle migration capturing schema drift merged AFTER the 0000 baseline was cut: #217 (dom_nodes.component_instance/instance_status), #218 (site_pages table + content_items workflow columns), #224 (yjs_snapshots reshaped to the R2-pointer model). Generated 2026-06-26; applied to prod 2026-06-26 (MCP).
workers/drizzle/0002_capture_233_token_meta.sql Journaled idx 2 — captures #233’s 4 additive design_tokens meta cols (reference_path, tailwind_class, source_preset, is_inherited). Generated + applied to prod (Skyfall-V2) 2026-06-26 (MCP).
workers/drizzle/0003_sites_default_language_notnull.sql 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.
workers/drizzle/0004_sites_supported_languages_text_array.sql 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).
workers/drizzle/0006_dom_nodes_position_double.sql Journaled idx 6 — Phase D: dom_nodes.position integer → 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.
workers/drizzle/0007_phase6_constraints_last_author.sql 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.
workers/drizzle/0013_target_domains_verification.sql 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/schema PageFormat). 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.
workers/drizzle/0021_visitor_auth_members.sql Journaled idx 21 — Visitor Auth (D73, VA-2): site_visitors (per-site identity, unique (target_id,email)), member_login_tokens (hashed single-use magic-link tokens, PK = sha256, atomic burn), site_members (membership + profile + session_generation). Purely additive. ✅ Applied to prod + recorded 2026-07-13 (MCP). RLS sidecar: 20260713_visitor_auth_rls.sql.
workers/drizzle/0022_site_pages_member_gate.sql 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 index content_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 SELECT is_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).
workers/drizzle/0032_overconfident_fantastic_four.sql Journaled idx 32 — Repeater dead-surface (D105, D:repeater-dead-surface, PR #375): drops the always-null dom_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).
db/migrations/20260824_drop_legacy_target_project_fk.sql 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.
workers/drizzle/meta/_journal.json Drizzle migration journal (do not edit manually)
db/migrations/20260626_catchup_token_model_sidecar.sql 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-V2 round-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 (0025 e33e3dfc…/1784031066406, 0026 bf96a69a…/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-V2 round-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-V2 round-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-V2 round-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.json db: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) |


Day-to-day workflow (generate → review → apply)

Section titled “Day-to-day workflow (generate → review → apply)”

Make your schema change in TypeScript. This is the only file you should edit.

Terminal window
cd workers
pnpm db:generate
# or: pnpm --filter matriz-api db:generate (from repo root)

Drizzle-kit diffs the current schema against the last snapshot and emits a new .sql file in workers/drizzle/. Review that file before proceeding.

Open the new file and verify:

  • Only the intended columns/indexes/constraints changed.
  • No accidental DROP TABLE or DROP COLUMN statements.
  • Any destructive change (column rename, type change) is intentional and safe.
Terminal window
export DATABASE_URL="postgres://<user>:<pass>@<host>/<db>"
cd workers
pnpm db:migrate

5. Test, then apply to production (human step)

Section titled “5. Test, then apply to production (human step)”

Production applies must be done manually via the Neon MCP tool or Neon console, by a human who has reviewed the SQL. Never automate prod migrations.

⚠️ Two ledger tables — read this BEFORE hand-applying anything

Section titled “⚠️ Two ledger tables — read this BEFORE hand-applying anything”

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 unqualified INSERT 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' AS schema, 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)
SELECT '<sha256-of-file-bytes>', <journal-when-ms>
WHERE NOT EXISTS (
SELECT 1 FROM drizzle."__drizzle_migrations" WHERE created_at = <journal-when-ms>
);

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

Section titled “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.

Step 1 — Create the drizzle migrations tracking table (if absent)

CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle."__drizzle_migrations" (
id serial PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);

Step 2 — Mark the baseline migration as already applied

The hash is the SHA256 of the migration file content as computed by drizzle-kit. Rather than computing it manually, run the following from workers/:

Terminal window
node -e "
const fs = require('fs');
const crypto = require('crypto');
const sql = fs.readFileSync('drizzle/0000_medical_stryfe.sql', 'utf8');
console.log(crypto.createHash('sha256').update(sql).digest('hex'));
"

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:

Terminal window
# Via psql:
psql "$DATABASE_URL" -f db/migrations/20260626_catchup_token_model_sidecar.sql
# Or paste the file contents into the Neon MCP execute_sql tool.

After applying, mark it in the tracking table:

Terminal window
node -e "
const fs = require('fs');
const crypto = require('crypto');
const sql = fs.readFileSync('db/migrations/20260626_catchup_token_model_sidecar.sql', 'utf8');
console.log(crypto.createHash('sha256').update(sql).digest('hex'));
"
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.


For a brand-new database:

Terminal window
# 1. Bootstrap the full schema
psql "$DATABASE_URL" -f db/schema-v4-neon.sql
# 2. Run the drizzle-kit baseline migration (creates tables already created above — skip)
# Instead, baseline the tracking table as described above.
# 3. Seed design tokens (requires is_base column from v4.1 bootstrap)
psql "$DATABASE_URL" -f db/seed-design-tokens-phase-4.2.sql

Alternatively, use only the drizzle-kit migration:

Terminal window
export DATABASE_URL="..."
# Prereq: the migrations declare `vector(1536)` embedding columns but do NOT
# create the extension — enable it first (Neon/pgvector image both support it):
psql "$DATABASE_URL" -c 'CREATE EXTENSION IF NOT EXISTS vector;'
cd workers
pnpm db:migrate
# Then seed:
psql "$DATABASE_URL" -f db/seed-design-tokens-phase-4.2.sql

Schema drift notes (observed during migration setup, 2026-06-26)

Section titled “Schema drift notes (observed during migration setup, 2026-06-26)”

The following gaps exist between db/schema-v4-neon.sql (pre-v4.1) and the live prod schema / schema.ts:

Table Column/Index Status
design_systems is_base boolean Was missing from SQL; now added in v4.1
design_systems base_system_id uuid Was missing from SQL; now added in v4.1
design_systems site_id uuid Was missing from SQL; now added in v4.1
design_systems idx_design_systems_site Was missing from SQL; now added in v4.1
design_systems idx_design_systems_base Was missing from SQL; now added in v4.1
design_tokens aliases text[] Was missing from SQL; now added in v4.1
design_tokens source_component_id uuid Was missing from SQL; now added in v4.1
design_tokens idx_design_tokens_aliases_gin Was missing from SQL; now added in v4.1
components required_tokens jsonb Was missing from SQL; now added in v4.1

Columns in schema-v4-neon.sql not in schema.ts

Section titled “Columns in schema-v4-neon.sql not in schema.ts”

The SQL bootstrap contains tables that Drizzle’s schema.ts does not manage (they are legacy or handled outside Workers):

  • site_design_overrides, property_categories, control_types, property_registry, component_variants, reusable_components, dom_nodes_history, draft_sessions, data_sources, runtime_variables, deployed_builds, site_deployments, bondlayer_migrations, bondlayer_collections_raw, bondlayer_records_raw, site_integrations, site_states, site_breakpoints, editor_config, site_custom_code, site_image_preset, skyfall_config, scheduled_publishes
  • Materialized view mv_style_value_frequency + its indexes
  • Trigger functions and triggers (update_updated_at_column, etc.)
  • All seed data

These are intentionally omitted from schema.ts — drizzle-kit will not touch them. They remain managed by db/schema-v4-neon.sql and manual migrations.

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:

DELETE FROM assets
WHERE target_id IS NULL
AND project_id = 'org_3GVE1A4LDgOu2RIH3cRXV3bXaqR'
AND metadata->>'pendingMaterialization' = 'true'
AND cdn_url ~ '^https?://'
AND cdn_url NOT LIKE '%/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)

Section titled “Ledger drift — __drizzle_migrations was behind the journal (RECONCILED 2026-07-26)”

Correction: this document was checking the wrong table

Section titled “Correction: this document was checking the wrong table”

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.

What was actually wrong, and what was fixed

Section titled “What was actually wrong, and what was fixed”

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)
VALUES ('1421cef147cdbe8ee9c170bfda0405cf353dda8c9609c0b859e4ef7b7a5926ec', 1784999614729);
-- 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)

Section titled “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_at 1784644005837, 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)

Section titled “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>):

  1. Original-name R2 keys. storage_path should be ${projectId}/<source path>/<name>.<ext> — no sha256 prefix, no UID, always inside the ${projectId}/ prefix:
    SELECT storage_path, cdn_url, hash, file_size, mime_type
    FROM assets WHERE target_id = '<newTargetId>' LIMIT 30;
    SELECT count(*) FROM assets
    WHERE target_id = '<newTargetId>' AND storage_path ~ '/[0-9a-f]{64}-'; -- expect 0
    Spot-check the objects exist in R2 under those exact keys.
  2. Materialization actually completed (the bug that shipped):
    SELECT count(*) FILTER (WHERE metadata->>'pendingMaterialization' = 'true') AS pending,
    count(*) FILTER (WHERE metadata->>'reImportPending' = 'true') AS failed,
    count(*) FILTER (WHERE width IS NOT NULL) AS with_width,
    count(*) AS total
    FROM assets WHERE target_id = '<newTargetId>';
    pending must be 0. If not, POST /api/import/retry/<newTargetId> should drain it.
  3. No bndlyr.com left anywhere:
    SELECT count(*) FROM dom_nodes
    WHERE target_id = '<newTargetId>' AND to_jsonb(dom_nodes)::text LIKE '%bndlyr.com%';
    SELECT count(*) FROM content_items ci JOIN content_types ct ON ct.id = ci.content_type_id
    WHERE ct.target_id = '<newTargetId>' AND ci.data::text LIKE '%bndlyr.com%';
    Both should be 0. Also check site_font.source_url/font_face_css.
  4. 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.
  5. Lottie .json assets cataloged (_recover_lottie_src):
    SELECT filename, mime_type, storage_path FROM assets
    WHERE target_id = '<newTargetId>' AND filename LIKE '%.json';
  6. Collection bindings resolve — no content_type_id pointing at nothing:
    SELECT count(*) 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' NOT IN
    (SELECT id::text FROM 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 SELECT is_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_nodes SET 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-V2 round-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.