Migrate Orders to Canonical Schema
Concept of the skill
What it is: The safe migration procedure for moving an orders table from Stripe-specific columns to provider-neutral columns.
Mental model: Add the new shape beside the old one, backfill and observe, then remove the old shape only after application reads have moved.
Why it exists: Billing tables sit under live traffic, so schema changes need compatibility windows and explicit validation.
What it is NOT: It is not generic data modeling, a new provider router, or the steady-state RLS query pattern.
Adjacent concepts: Expand-and-contract migrations, backfills, idempotency checks, RLS policy updates.
One-line analogy: It is changing the rails under a moving train by laying the new track beside the old track first.
Common misconception: A database rename is atomic enough by itself; application deploy timing makes old and new column reads coexist.
Coverage
- The four-phase safe migration procedure applied to the orders table: add nullable columns → backfill from existing → validate → drop legacy columns; why collapsing any two phases is unsafe under live traffic
- The canonical column mapping:
stripe_session_id → provider_order_id, stripe_customer_id → provider_customer_id, with a new provider column set to 'stripe' for existing rows
- The RLS policy update — the existing
orders_org_select policy must be updated in the same migration that renames the columns if the policy references them (it does not in this case, but the checklist step prevents future drift)
- Application code audit — grepping for
stripe_session_id and stripe_customer_id in the codebase to find every reference that must be updated before the old columns are dropped
- The dry-run gate —
scripts/migrate-orders.ts runs in --dry-run by default, printing the diff without committing; --apply is the explicit opt-in
Philosophy of the skill
A column rename under live traffic is a non-trivial operation even on a small table. The temptation to write one migration that renames columns atomically and ships them fails because application code reads the old column names until the new application version deploys, and the deployment window is not instant. The four-phase procedure exists because the new column can be null during the window, the application can write both, and the old column can be dropped only after the new application version has been running for a full observation period with zero reads of the old column name in logs.
Workflow
| Phase |
Precondition |
Action |
Success criterion |
| 1. Add nullable columns |
Production schema has no provider or provider_order_id |
Add provider TEXT, provider_order_id TEXT, provider_customer_id TEXT as nullable |
Build passes; existing rows unaffected |
| 2. Backfill |
Phase 1 deployed |
UPDATE orders SET provider = 'stripe', provider_order_id = stripe_session_id, provider_customer_id = stripe_customer_id WHERE provider IS NULL |
SELECT COUNT(*) FROM orders WHERE provider IS NULL returns 0 |
| 3. Validate and update application |
Phase 2 complete |
Search codebase for stripe_session_id and stripe_customer_id references; update to provider_order_id / provider_customer_id; deploy the updated application |
Zero reads of old column names in production logs for 24 hours |
| 4. Drop legacy columns |
Phase 3 observation period complete |
ALTER TABLE orders DROP COLUMN stripe_session_id, DROP COLUMN stripe_customer_id |
Schema matches db/schema.sql; npm run verify passes |
Migration SQL
-- Phase 1: Add nullable canonical columns
ALTER TABLE orders
ADD COLUMN IF NOT EXISTS provider TEXT,
ADD COLUMN IF NOT EXISTS provider_order_id TEXT,
ADD COLUMN IF NOT EXISTS provider_customer_id TEXT;
-- Phase 2: Backfill from Stripe-specific columns
UPDATE orders
SET
provider = 'stripe',
provider_order_id = stripe_session_id,
provider_customer_id = stripe_customer_id
WHERE provider IS NULL;
-- Phase 4 (only after Phase 3 observation period):
-- ALTER TABLE orders
-- DROP COLUMN stripe_session_id,
-- DROP COLUMN stripe_customer_id;
Verification
Do NOT Use When
| Use instead |
When |
postgres-rls-pattern |
The task is the ongoing RLS access pattern, not the one-time migration |
| (a fresh migration skill) |
The task is a different migration with no relation to the 0004 orders canonicalization |
payment-provider-router |
The task is updating the router to use provider_order_id after the migration |
1---2# name: stable skill identifier. Match the skill directory name or the final namespace segment.3# Lowercase letters/numbers with hyphen, slash, or colon separators.4name: migrate-orders-to-canonical-schema5# description: routing-facing summary of what the skill covers and when it activates.6# Include concrete triggers and an explicit negative boundary; keep routing semantics out of prose-only ambiguity.7description: "Use when running migration 0004 that normalizes the orders table from a Stripe-specific shape (stripe_session_id, stripe_customer_id as top-level columns) to a canonical provider-agnostic shape (provider, provider_order_id, provider_customer_id). Covers the four-phase safe migration procedure — add nullable columns, backfill from existing data, validate, drop legacy columns — and the RLS policy update that must accompany the column rename. Do NOT use for unrelated schema migrations (write a fresh skill anchored to that migration's number), for designing a new canonical schema from scratch, or for the ongoing orgQuery access pattern (use postgres-rls-pattern)."89# === v8 Classification (subject + public; polyhierarchy via subjects[]) — see ADR-0017 ===10# subject: primary browse shelf — what the skill teaches. One of twelve closed values:11# backend-engineering / frontend-engineering / software-architecture / data-engineering / agent-ops / ai-engineering /12# quality-assurance / design / reasoning-strategy / software-engineering-method / knowledge-organization / product-domain.13subject: data-engineering14# public: publishability/private-data gate. Boolean.15# true = publishable/shareable; false = private and excluded from public export.16# Project anchoring is carried separately by non-empty `project[]` plus `grounding`.17public: false18# taxonomy_domain: optional hierarchical sub-path within `subject`. Slash-delimited19# lowercase kebab-case segments. rename of the original v8 `domain`. Remove when the flat20# `subject` is sufficient.21taxonomy_domain: engineering/database22# scope: free-text PRD-style statement of what the skill teaches and what it excludes.23# (v8 required; not an enum). Mirrors Coverage + Do NOT Use When at frontmatter level.24scope: "Four-phase orders-table migration for the saas-stripe-postgres example project — canonicalizes Stripe-specific column names to provider-agnostic names with RLS policy update."2526# stability: lifecycle marker. One of:27# experimental (active development) / stable (production-ready) /28# frozen (no further changes expected) / deprecated.29# When `deprecated`, schema's allOf REQUIRES `superseded_by: <real-skill-name>`.30stability: experimental31# license: SPDX license identifier (e.g., MIT, Apache-2.0).32license: MIT33# compatibility: runtime compatibility object. Prefer structured fields34# (`agent_runtimes`, `node_version`) over free-text `notes`.35compatibility:36 agent_runtimes:37 - node38 node_version: ">=20"39 notes: "Postgres >=14; assumes psql or pg driver. Dry-run mode requires a non-production database."40allowed-tools: Read Grep Bash41# keywords: semantic phrases for fuzzy router activation. v8 cap: max 10.42# Keep terms a user would actually type when starting a task in this skill's domain.43keywords:44 - orders table migration45 - canonical schema migration46 - stripe to provider-agnostic47 - column rename migration48 - four-phase migration49 - add nullable backfill validate drop50 - provider_order_id migration51 - rls policy update on migration52 - 0004 orders migration53 - safe column removal54# triggers: explicit-match activation phrases the router fires on literally.55# Use when label-based routing is intended; usually keywords + examples are enough.56triggers:57 - migrate-orders-to-canonical-schema58# paths: glob array of code surfaces this skill governs. Supports gitignore-style59# negation. Each glob should map to ONE canonical skill. Omit if purely conceptual.60paths:61 - "db/migrations/0004_canonicalize_orders.sql"62 - "db/schema.sql"63 - "scripts/migrate-orders.ts"64# examples: 2-5 realistic user prompts the skill SHOULD activate for.65# Written in the user's voice. Improves retrieval recall beyond keywords alone.66examples:67 - "run the 0004 orders migration that renames stripe_session_id to provider_order_id"68 - "safely remove the stripe_customer_id column after backfilling provider_customer_id"69 - "update the RLS policy on orders after the column rename"70 - "verify that every order row has a non-null provider_order_id before dropping the old column"71# anti_examples: near-miss prompts that should route ELSEWHERE.72# Pair with relations.suppresses (or legacy boundary alias) to name the confusable territory's owner.73anti_examples:74 - "design a different migration for the invoices table"75 - "add row level security to a new table"76 - "query the orders table in an application route"77# relations: typed graph edges to sibling skills. Current fields:78# related (adjacency for browse / co-routing expansion) /79# suppresses (exclude listed skills from co-routing when THIS skill wins; write reason80# as "I own this exclusively over X", not "use X instead") /81# boundary (DEPRECATED alias of suppresses, retained for unmigrated skills) /82# verify_with (cross-check; co-loaded as one-hop expansion) /83# depends_on (composition; transitive — A→B→C loads all three) /84# broader / narrower (SKOS-style generalization) /85# disjoint_with (mutual exclusion for incompatible ownership).86relations:87 related:88 - postgres-rls-pattern89 - payment-provider-router90 depends_on:91 - postgres-rls-pattern92 verify_with:93 - postgres-rls-pattern94# grounding: required when `project[]` is non-empty. Declares the truth sources95# the skill anchors to and the failure modes those sources prevent. Omit when the96# skill is universal-knowledge. `subject_matter` replaces v8 `domain_object`.97grounding:98 subject_matter: "Migration 0004 — the four-phase procedure that canonicalizes the orders table from Stripe-specific column names to provider-agnostic column names, with an RLS policy update in the same migration"99 grounding_mode: repo_specific100 truth_sources:101 - path: examples/projects/saas-stripe-postgres/db/migrations/0004_canonicalize_orders.sql102 note: "The migration file — source of truth for column renames and RLS policy updates"103 - path: examples/projects/saas-stripe-postgres/db/schema.sql104 note: "The target canonical schema the migration produces"105 failure_modes:106 - dropping_column_before_backfill_verified107 - rls_policy_not_updated_to_reference_new_column_name108 - application_code_still_references_old_column_after_drop109 - migration_run_without_dry_run_gate_first110 - rollback_path_not_documented_before_irreversible_step111 evidence_priority: repo_code_first112# project: projects this skill is linked to. Array of {handle, role} objects.113# Non-empty project[] anchors the skill to a project and requires `grounding`.114# Suggested role values: source-of-truth, consumer, mirror. Replaces original v8 `workspace_tags`.115project:116 - handle: saas-stripe-postgres117 role: primary118---119120# Migrate Orders to Canonical Schema121122## Concept of the skill123124**What it is:** The safe migration procedure for moving an orders table from Stripe-specific columns to provider-neutral columns.125**Mental model:** Add the new shape beside the old one, backfill and observe, then remove the old shape only after application reads have moved.126**Why it exists:** Billing tables sit under live traffic, so schema changes need compatibility windows and explicit validation.127**What it is NOT:** It is not generic data modeling, a new provider router, or the steady-state RLS query pattern.128**Adjacent concepts:** Expand-and-contract migrations, backfills, idempotency checks, RLS policy updates.129**One-line analogy:** It is changing the rails under a moving train by laying the new track beside the old track first.130**Common misconception:** A database rename is atomic enough by itself; application deploy timing makes old and new column reads coexist.131132## Coverage133134- The four-phase safe migration procedure applied to the orders table: *add nullable columns → backfill from existing → validate → drop legacy columns*; why collapsing any two phases is unsafe under live traffic135- The canonical column mapping: `stripe_session_id` → `provider_order_id`, `stripe_customer_id` → `provider_customer_id`, with a new `provider` column set to `'stripe'` for existing rows136- The RLS policy update — the existing `orders_org_select` policy must be updated in the same migration that renames the columns if the policy references them (it does not in this case, but the checklist step prevents future drift)137- Application code audit — grepping for `stripe_session_id` and `stripe_customer_id` in the codebase to find every reference that must be updated before the old columns are dropped138- The dry-run gate — `scripts/migrate-orders.ts` runs in `--dry-run` by default, printing the diff without committing; `--apply` is the explicit opt-in139140## Philosophy of the skill141142A column rename under live traffic is a non-trivial operation even on a small table. The temptation to write one migration that renames columns atomically and ships them fails because application code reads the old column names until the new application version deploys, and the deployment window is not instant. The four-phase procedure exists because the new column can be null during the window, the application can write both, and the old column can be dropped only after the new application version has been running for a full observation period with zero reads of the old column name in logs.143144## Workflow145146| Phase | Precondition | Action | Success criterion |147|---|---|---|---|148| 1. Add nullable columns | Production schema has no `provider` or `provider_order_id` | Add `provider TEXT`, `provider_order_id TEXT`, `provider_customer_id TEXT` as nullable | Build passes; existing rows unaffected |149| 2. Backfill | Phase 1 deployed | `UPDATE orders SET provider = 'stripe', provider_order_id = stripe_session_id, provider_customer_id = stripe_customer_id WHERE provider IS NULL` | `SELECT COUNT(*) FROM orders WHERE provider IS NULL` returns 0 |150| 3. Validate and update application | Phase 2 complete | Search codebase for `stripe_session_id` and `stripe_customer_id` references; update to `provider_order_id` / `provider_customer_id`; deploy the updated application | Zero reads of old column names in production logs for 24 hours |151| 4. Drop legacy columns | Phase 3 observation period complete | `ALTER TABLE orders DROP COLUMN stripe_session_id, DROP COLUMN stripe_customer_id` | Schema matches `db/schema.sql`; `npm run verify` passes |152153## Migration SQL154155```sql156-- Phase 1: Add nullable canonical columns157ALTER TABLE orders158 ADD COLUMN IF NOT EXISTS provider TEXT,159 ADD COLUMN IF NOT EXISTS provider_order_id TEXT,160 ADD COLUMN IF NOT EXISTS provider_customer_id TEXT;161162-- Phase 2: Backfill from Stripe-specific columns163UPDATE orders164SET165 provider = 'stripe',166 provider_order_id = stripe_session_id,167 provider_customer_id = stripe_customer_id168WHERE provider IS NULL;169170-- Phase 4 (only after Phase 3 observation period):171-- ALTER TABLE orders172-- DROP COLUMN stripe_session_id,173-- DROP COLUMN stripe_customer_id;174```175176## Verification177178- [ ] Phase 1 was deployed to production and verified (build passed, existing rows intact) before Phase 2 ran179- [ ] Phase 2 backfill was run with `--dry-run` first; the dry-run output is committed under `db/migrations/0004-dry-run.log`180- [ ] `SELECT COUNT(*) FROM orders WHERE provider IS NULL` returns 0 before Phase 3 begins181- [ ] Phase 3 application code audit found and updated every reference to `stripe_session_id` and `stripe_customer_id`182- [ ] The observation window (minimum 24 hours) elapsed with zero old-column reads in production logs before Phase 4 was run183- [ ] Phase 4 (DROP COLUMN) is in a separate migration file from Phases 1-2, deployed only after Phase 3 sign-off184- [ ] RLS policies were reviewed after the column rename (even if not updated — the review is recorded in the migration PR)185186## Do NOT Use When187188| Use instead | When |189|---|---|190| `postgres-rls-pattern` | The task is the ongoing RLS access pattern, not the one-time migration |191| (a fresh migration skill) | The task is a different migration with no relation to the 0004 orders canonicalization |192| `payment-provider-router` | The task is updating the router to use `provider_order_id` after the migration |