diff --git a/AGENTS.md b/AGENTS.md index 9da45caa..7da5a0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ Read these before changing the relevant area — start here, then follow links. - `CUJS.md` — **critical user journeys** (the basis for flow tests). - `DEV_INSTANCE_DESIGN.md` — the `CHECKIN_ENV` prod/dev/local model + persona-mint/impersonation (read before touching auth/env). - `DEV_DASHBOARD_DESIGN.md` — dev dashboard + seed/reset macros. +- `PROGRAM_INSTANCE_RESTRUCTURE.md` — the Program → ProgramInstance → Event 3-tier split (phased; Phase 1 = additive schema + backfill, later phases deferred). Read before touching the Program/Event tier or its FKs. - `PRODUCTION_PLAN.md`, `implementation_plan.md`, `MY_PROGRAMS_SCOPING.md`, `ARCHITECT_IDEAS_*.md` — roadmap/scoping notes. **Security** (`docs/security/`) diff --git a/checkin-app/prisma/migrations/20260708040000_program_instances_phase1/migration.sql b/checkin-app/prisma/migrations/20260708040000_program_instances_phase1/migration.sql new file mode 100644 index 00000000..5cf44c18 --- /dev/null +++ b/checkin-app/prisma/migrations/20260708040000_program_instances_phase1/migration.sql @@ -0,0 +1,99 @@ +-- Program → Instance → Event restructure, PHASE 1 (additive/expand only). +-- See docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md (§7 P1) + its re-validation +-- section. This migration ONLY adds: the ProgramInstance table, a nullable +-- Event.instanceId FK, and a 1:1 backfill. It moves NO child FK, drops NO +-- column, renames nothing — every destructive/rename step (P2–P5) is a later +-- release per DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md (expand-contract). +-- +-- Safe for the LIVE DB during the deploy drain window: old code neither reads +-- nor writes the new table/column, and nothing it does read (Program.*, +-- Event.programId, child *.programId) is touched. The backfill only populates +-- new rows/columns. +-- +-- prisma migrate deploy does NOT wrap a migration file in a transaction on +-- Postgres (prisma/prisma#15295), and the DDL + backfill must all land or none +-- (a half-applied backfill would leave the sequence unbumped) — so wrap +-- explicitly (DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md rule 5). +BEGIN; + +-- ── DDL (generated by `prisma migrate diff`, verbatim) ────────────────────── + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "instanceId" INTEGER; + +-- CreateTable +CREATE TABLE "ProgramInstance" ( + "id" SERIAL NOT NULL, + "programId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "leadMentorId" INTEGER, + "startAt" TIMESTAMP(3), + "endAt" TIMESTAMP(3), + "phase" "ProgramPhase" NOT NULL DEFAULT 'PLANNING', + "enrollmentStatus" "EnrollmentStatus" NOT NULL DEFAULT 'CLOSED', + "maxParticipants" INTEGER, + "leadMentorNotificationSettings" JSONB, + "minAge" INTEGER, + "maxAge" INTEGER, + "orgMemberOnly" BOOLEAN, + "shopifyProductId" TEXT, + "shopifyVariantId" TEXT, + "shopifyOrgMemberVariantId" TEXT, + "shopifyNonOrgMemberVariantId" TEXT, + + CONSTRAINT "ProgramInstance_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "ProgramInstance" ADD CONSTRAINT "ProgramInstance_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProgramInstance" ADD CONSTRAINT "ProgramInstance_leadMentorId_fkey" FOREIGN KEY ("leadMentorId") REFERENCES "Person"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "ProgramInstance"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ── Backfill (MB) ─────────────────────────────────────────────────────────── +-- Idempotent (re-runnable if a deploy retries): the INSERT skips programs that +-- already have an instance, the UPDATE only fills still-null instanceIds, and +-- setval is derived from MAX(id) so it never regresses. +-- programInstanceBackfill.integration.test.ts extracts everything between the two +-- bare BACKFILL markers below and runs it twice — keep the markers exact. +-- >>> BACKFILL START + +-- One ProgramInstance per existing Program, id-aliased (instance.id = +-- program.id). The alias is what lets the later FK repoint (P2) be a pure +-- RENAME with no value rewrite: every child *.programId already equals the +-- instance id it will point at. Offering columns are copied; the narrowing +-- overrides (minAge/maxAge/orgMemberOnly) stay NULL = "inherit the definition". +INSERT INTO "ProgramInstance" ( + "id", "programId", "name", "leadMentorId", "startAt", "endAt", "phase", + "enrollmentStatus", "maxParticipants", "leadMentorNotificationSettings", + "shopifyProductId", "shopifyVariantId", "shopifyOrgMemberVariantId", + "shopifyNonOrgMemberVariantId" +) +SELECT + p."id", p."id", p."name", p."leadMentorId", p."startAt", p."endAt", p."phase", + p."enrollmentStatus", p."maxParticipants", p."leadMentorNotificationSettings", + p."shopifyProductId", p."shopifyVariantId", p."shopifyOrgMemberVariantId", + p."shopifyNonOrgMemberVariantId" +FROM "Program" p +WHERE NOT EXISTS (SELECT 1 FROM "ProgramInstance" i WHERE i."id" = p."id"); + +-- Point existing program-bound events at their program's (id-aliased) instance. +-- Program-less events (programId NULL) stay unlinked, as before. +UPDATE "Event" e +SET "instanceId" = e."programId" +WHERE e."programId" IS NOT NULL AND e."instanceId" IS NULL; + +-- Bump the id sequence past the aliased ids, or the next autoincrement insert +-- collides with a backfilled row. Derived from MAX(id) (self-correcting: safe on +-- an empty table → next id 1, and never moves the sequence backward on re-run). +SELECT setval( + pg_get_serial_sequence('"ProgramInstance"', 'id'), + (SELECT COALESCE(MAX("id"), 0) FROM "ProgramInstance") + 1, + false +); +-- >>> BACKFILL END + +COMMIT; diff --git a/checkin-app/prisma/schema.prisma b/checkin-app/prisma/schema.prisma index be3013f0..206e3662 100644 --- a/checkin-app/prisma/schema.prisma +++ b/checkin-app/prisma/schema.prisma @@ -127,6 +127,7 @@ model Person { programVolunteers ProgramVolunteer[] programParticipants ProgramParticipant[] programsLed Program[] @relation("ProgramLeadMentor") + instancesLed ProgramInstance[] @relation("InstanceLeadMentor") feePayments FeePayment[] rsvps RSVP[] rawBadgeLogs RawBadgeLog[] @@ -134,8 +135,8 @@ model Person { eventsConfirmedBy Event[] @relation("EventAttendanceConfirmer") trustedAdultRecordsAsAdult TrustedAdult[] @relation("TrustedAdultPerson") - trustedAdultsDisclosed TrustedAdult[] @relation("TrustedAdultDiscloser") - trustedAdultReviewsDecided TrustedAdultReview[] @relation("TrustedAdultDecider") + trustedAdultsDisclosed TrustedAdult[] @relation("TrustedAdultDiscloser") + trustedAdultReviewsDecided TrustedAdultReview[] @relation("TrustedAdultDecider") // Household-lead lookups (a1): "leads of household X" runs on the hot paths // (check-in notification fan-out, nav badges, broken/unclaimed-household scans). @@ -158,21 +159,21 @@ model ToolStatus { /// @sensitivity:public personId Int /// @sensitivity:public - toolId Int + toolId Int /// @sensitivity:member - level ToolLevel + level ToolLevel person Person @relation(fields: [personId], references: [id]) - tool Tool @relation(fields: [toolId], references: [id]) + tool Tool @relation(fields: [toolId], references: [id]) @@id([personId, toolId]) } model Household { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:public - name String + name String /// @sensitivity:personal line1 String? /// @sensitivity:personal @@ -340,55 +341,55 @@ model OrgMembership { model OrgMembershipProcess { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) // Nullable since Phase 2: a PERSON_BG process is not tied to a household // membership (subjectPersonId is set instead). INITIAL/RENEWAL always set it. /// @sensitivity:public - orgMembershipId Int? + orgMembershipId Int? // onDelete pinned to Restrict to keep the existing constraint (Prisma's default // for an optional relation is SetNull; the live FK is RESTRICT). Avoids drift. - orgMembership OrgMembership? @relation(fields: [orgMembershipId], references: [id], onDelete: Restrict) + orgMembership OrgMembership? @relation(fields: [orgMembershipId], references: [id], onDelete: Restrict) // Set only for PERSON_BG processes — the person being background-checked // (participant, volunteer, or lead). Null for household INITIAL/RENEWAL. /// @sensitivity:public - subjectPersonId Int? - subjectPerson Person? @relation("PersonBgSubject", fields: [subjectPersonId], references: [id]) + subjectPersonId Int? + subjectPerson Person? @relation("PersonBgSubject", fields: [subjectPersonId], references: [id]) /// @sensitivity:public - kind OrgMembershipProcessKind + kind OrgMembershipProcessKind /// @sensitivity:internal - status OrgMembershipProcessStatus + status OrgMembershipProcessStatus /// @sensitivity:internal - stageEnteredAt DateTime? @default(now()) + stageEnteredAt DateTime? @default(now()) /// @sensitivity:public - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) /// @sensitivity:internal - zohoEnvelopeId String? + zohoEnvelopeId String? /// @sensitivity:internal - zohoActionId String? + zohoActionId String? /// @sensitivity:internal - contractSignedAt DateTime? + contractSignedAt DateTime? /// @sensitivity:internal - bgConsentAt DateTime? + bgConsentAt DateTime? /// When the background-check requirement was satisfied for this cycle — either /// two reviewers approved, or a still-valid prior check was detected at intake. /// The single source of truth for "BG cleared", independent of the payment /// track; the membership only goes ACTIVE once this is set AND dues are paid. /// @sensitivity:internal - bgClearedAt DateTime? + bgClearedAt DateTime? /// @sensitivity:internal - shopifyDraftOrderId String? + shopifyDraftOrderId String? /// @sensitivity:personal - shopifyInvoiceUrl String? + shopifyInvoiceUrl String? /// @sensitivity:internal - shopifyOrderId String? + shopifyOrderId String? /// @sensitivity:internal - paidAt DateTime? + paidAt DateTime? /// @sensitivity:internal - certifiedById Int? + certifiedById Int? /// @sensitivity:internal - renewalReminderSentAt DateTime? + renewalReminderSentAt DateTime? /// @sensitivity:internal - isPaymentPlanRequested Boolean @default(false) + isPaymentPlanRequested Boolean @default(false) attestations BackgroundCheckAttestation[] @@ -398,19 +399,19 @@ model OrgMembershipProcess { model BackgroundCheckAttestation { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:public - processId Int - process OrgMembershipProcess @relation(fields: [processId], references: [id]) + processId Int + process OrgMembershipProcess @relation(fields: [processId], references: [id]) /// @sensitivity:public - reviewerId Int - reviewer Person @relation("BgAttestations", fields: [reviewerId], references: [id]) + reviewerId Int + reviewer Person @relation("BgAttestations", fields: [reviewerId], references: [id]) /// @sensitivity:internal - result AttestationResult + result AttestationResult /// @sensitivity:internal - isMarkedVolunteer Boolean @default(false) + isMarkedVolunteer Boolean @default(false) /// @sensitivity:public - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@unique([processId, reviewerId]) } @@ -428,13 +429,13 @@ model VolunteerDesignation { model BoardSettings { /// @sensitivity:public - id Int @id @default(1) + id Int @id @default(1) /// @sensitivity:public - normalDuesCents Int @default(0) + normalDuesCents Int @default(0) /// @sensitivity:public - volunteerDuesCents Int @default(0) + volunteerDuesCents Int @default(0) /// @sensitivity:public - orgMembershipYearBoundary DateTime? + orgMembershipYearBoundary DateTime? /// Shopify variant ID of the membership product everyone checks out through. /// The "Pay with Shopify" link is built from it SERVER-SIDE (ensurePaymentLink /// in lib/membership/payment.ts) as @@ -442,41 +443,41 @@ model BoardSettings { /// receives the derived checkoutUrl, never this raw field — hence internal, not /// public (contrast Program.shopify*VariantId, which the browser builds from). /// @sensitivity:internal - orgMembershipVariantId String? + orgMembershipVariantId String? /// Discount code (created in Shopify by the board) appended to the checkout /// link for volunteer households so Shopify applies their discount. /// @sensitivity:internal - volunteerDiscountCode String? + volunteerDiscountCode String? /// How long a background check stays valid, in months. 0 = not yet configured /// (the board must set the real Treehouse policy value before renewals re-check). /// @sensitivity:public - bgRecheckMonths Int @default(0) + bgRecheckMonths Int @default(0) /// Overrides the EMAIL_FROM env default as the Resend sender on every outbound /// email when set. Must be on a Resend-verified domain or all mail bounces — /// hence the confirm-to-edit guard in the settings UI. Null = use the env default. /// @sensitivity:public - emailFromAddress String? + emailFromAddress String? /// Reply-To header set on every outbound email when set (the From address is a /// no-reply, so this routes replies to a real inbox). Null = no Reply-To (replies /// go to From). Need not be on the sending domain. /// @sensitivity:public - emailReplyToAddress String? + emailReplyToAddress String? /// @sensitivity:internal shopifyOrgMembershipProductId String? /// @sensitivity:internal - shopifyNormalVariantId String? + shopifyNormalVariantId String? /// @sensitivity:internal - shopifyVolunteerVariantId String? + shopifyVolunteerVariantId String? /// @sensitivity:internal - shopifyPriceSyncedAt DateTime? + shopifyPriceSyncedAt DateTime? /// Grace period (days) a denied scholarship applicant keeps their held seat /// before the grace-period expiry cron auto-withdraws them and releases it. /// NULL = the expiry feature is OFF (a denied hold is only ever released by /// withdrawal or payment; it never auto-expires). /// @sensitivity:public - scholarshipDenialGraceDays Int? + scholarshipDenialGraceDays Int? /// @sensitivity:internal - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt } /// App-wide localization config (singleton, id=1). Distinct from BoardSettings, @@ -524,45 +525,45 @@ model AppSettings { /// attribute — the board-facing explanation — not another name for the record. model TrustedAdult { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// A Trusted Adult belongs to a HOUSEHOLD, not an individual (e.g. "Grandma can /// pick up the Smith kids"). /// @sensitivity:public - householdId Int - household Household @relation(fields: [householdId], references: [id]) + householdId Int + household Household @relation(fields: [householdId], references: [id]) /// The trusted adult, if they are also a Person in the system. /// @sensitivity:public trustedAdultPersonId Int? - trustedAdultPerson Person? @relation("TrustedAdultPerson", fields: [trustedAdultPersonId], references: [id]) + trustedAdultPerson Person? @relation("TrustedAdultPerson", fields: [trustedAdultPersonId], references: [id]) /// @sensitivity:personal - trustedAdultName String + trustedAdultName String /// The trusted adult's phone and/or email. At least one is required (enforced /// in the app, not the DB, since "at least one of two nullable columns" has no /// clean SQL constraint). Both may be present. /// @sensitivity:personal - trustedAdultPhone String? + trustedAdultPhone String? /// @sensitivity:personal - trustedAdultEmail String? + trustedAdultEmail String? /// The family's explanation for the BOARD — what this adult may or may not do, /// restrictions, safeguarding context. Board + the household's own leads only /// (pii tier is the board/family band here; program leads & keyholders never /// receive it). /// @sensitivity:pii - familyContext String + familyContext String /// @sensitivity:internal - origin TrustedAdultOrigin @default(SELF_DISCLOSED) + origin TrustedAdultOrigin @default(SELF_DISCLOSED) /// @sensitivity:internal - disclosedById Int - disclosedBy Person @relation("TrustedAdultDiscloser", fields: [disclosedById], references: [id]) + disclosedById Int + disclosedBy Person @relation("TrustedAdultDiscloser", fields: [disclosedById], references: [id]) /// @sensitivity:public - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) /// @sensitivity:internal - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt /// When the household chose to permanently hide a withdrawn trusted adult from /// their own view. The row is KEPT (board history / audit); the member-facing /// /mine list filters it out. Board & sysadmin views still see it. /// @sensitivity:internal - hiddenAt DateTime? + hiddenAt DateTime? reviews TrustedAdultReview[] @@ -574,54 +575,54 @@ model TrustedAdult { /// stripper can scope a nested review row to the subject (their_own). model TrustedAdultReview { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:public - trustedAdultId Int - trustedAdult TrustedAdult @relation(fields: [trustedAdultId], references: [id]) + trustedAdultId Int + trustedAdult TrustedAdult @relation(fields: [trustedAdultId], references: [id]) /// Denormalized from the parent so the security stripper can scope a nested /// review row to the household (their_households / program / keyholder). /// @sensitivity:public - householdId Int + householdId Int /// @sensitivity:public - kind TrustedAdultReviewKind + kind TrustedAdultReviewKind /// @sensitivity:personal - status TrustedAdultReviewStatus @default(PENDING_BOARD_REVIEW) + status TrustedAdultReviewStatus @default(PENDING_BOARD_REVIEW) /// @sensitivity:internal - decidedById Int? - decidedBy Person? @relation("TrustedAdultDecider", fields: [decidedById], references: [id]) + decidedById Int? + decidedBy Person? @relation("TrustedAdultDecider", fields: [decidedById], references: [id]) /// @sensitivity:internal - decision TrustedAdultDecisionKind? + decision TrustedAdultDecisionKind? /// The board's PRIVATE reasoning. Board/sysadmin only (internal tier). /// @sensitivity:internal - decisionNote String? + decisionNote String? /// The board's note shared OUTWARD on approval — what keyholders & program /// leads need to act on ("Grandma can pick up Bobby"). Required to approve. /// Visible to the family, board, program leads, and keyholders (personal tier). /// @sensitivity:personal - sharedNote String? + sharedNote String? /// @sensitivity:personal - effectiveFrom DateTime? + effectiveFrom DateTime? /// @sensitivity:personal - reviewBy DateTime? + reviewBy DateTime? /// When the 30-day-before-expiry reminder email was sent (once). /// @sensitivity:internal - expiryWarningSentAt DateTime? + expiryWarningSentAt DateTime? /// Proposed edits carried by a MODIFIED resubmission. Held HERE, not on the /// parent, so a still-live prior approval keeps its own facts for the front desk /// until/unless the board approves these. Promoted onto the parent on approval, /// discarded on deny. Same bands as the parent's live facts. /// @sensitivity:personal - proposedName String? + proposedName String? /// @sensitivity:personal - proposedPhone String? + proposedPhone String? /// @sensitivity:personal - proposedEmail String? + proposedEmail String? /// @sensitivity:pii - proposedContext String? + proposedContext String? /// @sensitivity:public - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) /// @sensitivity:internal - updatedAt DateTime @updatedAt + updatedAt DateTime @updatedAt @@index([trustedAdultId]) @@index([status]) @@ -634,15 +635,15 @@ model Corporation { /// @sensitivity:pii primaryEmail String? /// @sensitivity:personal - line1 String? + line1 String? /// @sensitivity:personal - line2 String? + line2 String? /// @sensitivity:personal - city String? + city String? /// @sensitivity:personal - state String? + state String? /// @sensitivity:personal - postalCode String? + postalCode String? leads CorporationLead[] members CorporationMember[] @@ -652,10 +653,10 @@ model CorporationLead { /// @sensitivity:public corporationId Int /// @sensitivity:public - personId Int + personId Int corporation Corporation @relation(fields: [corporationId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([corporationId, personId]) } @@ -664,10 +665,10 @@ model CorporationMember { /// @sensitivity:public corporationId Int /// @sensitivity:public - personId Int + personId Int corporation Corporation @relation(fields: [corporationId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([corporationId, personId]) } @@ -679,7 +680,7 @@ model Program { name String /// @sensitivity:public leadMentorId Int? - leadMentor Person? @relation("ProgramLeadMentor", fields: [leadMentorId], references: [id]) + leadMentor Person? @relation("ProgramLeadMentor", fields: [leadMentorId], references: [id]) /// @sensitivity:public startAt DateTime? /// @sensitivity:public @@ -701,12 +702,12 @@ model Program { /// @sensitivity:public /// Integer cents (e.g. 2599 = $25.99). - orgMemberPriceCents Int? + orgMemberPriceCents Int? /// @sensitivity:public /// Integer cents (e.g. 2599 = $25.99). - nonOrgMemberPriceCents Int? + nonOrgMemberPriceCents Int? /// @sensitivity:public - shopifyProductId String? + shopifyProductId String? /// @sensitivity:public shopifyOrgMemberVariantId String? /// @sensitivity:public @@ -719,39 +720,100 @@ model Program { /// expand-only migration; dropping the legacy pair (contract) is a later /// release once every program has migrated. /// @sensitivity:public - shopifyVariantId String? + shopifyVariantId String? volunteers ProgramVolunteer[] participants ProgramParticipant[] fees Fee[] events Event[] + instances ProgramInstance[] +} + +/// Offering / run tier (PROGRAM_INSTANCE_RESTRUCTURE.md). A Program is the +/// catalog definition ("Woodworking 101"); a ProgramInstance is a concrete run +/// of it ("Fall 2026"). PHASE 1 is ADDITIVE ONLY: this table is created and +/// backfilled 1:1 from Program (one instance per program, id-aliased so +/// instance.id == program.id), but nothing reads it yet — Event.programId and +/// the child ProgramParticipant/ProgramVolunteer/Fee.programId FKs stay +/// authoritative. The FK repoint, read cutover, security-claim swap and UI all +/// land in later phases (§7 P2–P5), deferred out of this release per the +/// expand-contract migration policy. Offering columns are mirrored here (not +/// moved off Program) so the later FK repoint needs no value rewrite. +model ProgramInstance { + /// @sensitivity:public + id Int @id @default(autoincrement()) + /// FK to the parent Program (the definition). @sensitivity:public + programId Int + program Program @relation(fields: [programId], references: [id]) + /// Run label, e.g. "Fall 2026". Backfill seeds it from the parent name. @sensitivity:public + name String + /// @sensitivity:public + leadMentorId Int? + leadMentor Person? @relation("InstanceLeadMentor", fields: [leadMentorId], references: [id]) + /// @sensitivity:public + startAt DateTime? + /// @sensitivity:public + endAt DateTime? + /// @sensitivity:public + phase ProgramPhase @default(PLANNING) + /// @sensitivity:public + enrollmentStatus EnrollmentStatus @default(CLOSED) + /// @sensitivity:public + maxParticipants Int? + /// @sensitivity:personal + leadMentorNotificationSettings Json? + /// Narrowing policy overrides (§3a). NULL = inherit the parent Program's + /// value; a non-null value may only make the band STRICTER, never looser — + /// enforced at write time in a later phase. Backfill leaves these NULL. + /// @sensitivity:public + minAge Int? + /// @sensitivity:public + maxAge Int? + /// @sensitivity:public + orgMemberOnly Boolean? + + /// Shopify sellables mirrored from the parent Program (board-set prices stay + /// on Program). Against current main (#930, single-pool) this is the single + /// base-rate variant plus the legacy two-variant pair that pre-#930 programs + /// still carry — NOT the post-#929 shopifyMemberDiscountId the design doc + /// assumed (that pricing model is unmerged). See the re-validation section. + /// @sensitivity:public + shopifyProductId String? + /// @sensitivity:public + shopifyVariantId String? + /// @sensitivity:public + shopifyOrgMemberVariantId String? + /// @sensitivity:public + shopifyNonOrgMemberVariantId String? + + events Event[] } model ProgramVolunteer { /// @sensitivity:public - programId Int + programId Int /// @sensitivity:public - personId Int + personId Int /// @sensitivity:internal - isCore Boolean @default(false) + isCore Boolean @default(false) - program Program @relation(fields: [programId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + program Program @relation(fields: [programId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([programId, personId]) } model ProgramParticipant { /// @sensitivity:public - programId Int + programId Int /// @sensitivity:public - personId Int + personId Int /// @sensitivity:public - status ProgramParticipantStatus @default(PENDING) + status ProgramParticipantStatus @default(PENDING) /// @sensitivity:personal isPaymentPlanRequested Boolean @default(false) /// @sensitivity:internal - pendingSince DateTime? @default(now()) + pendingSince DateTime? @default(now()) /// Point-in-time snapshot of the household's org-membership status the moment a /// scholarship/payment-plan request was APPROVED (status flipped to ACTIVE). /// Membership changes over time, so this can't be derived later from the live @@ -766,28 +828,28 @@ model ProgramParticipant { /// +1). NOT NULL is the transactional guard every release path checks /// before adjusting Shopify. /// @sensitivity:internal - inventoryHeldAt DateTime? + inventoryHeldAt DateTime? /// Hold-ledger state: stamped when a board member denies a pending /// scholarship/payment-plan request (the seat stays held — see /// inventoryHeldAt — so the applicant may still pay normally). Cleared on /// re-application. Drives the grace-period expiry sweep alongside /// BoardSettings.scholarshipDenialGraceDays. /// @sensitivity:personal - paymentPlanDeniedAt DateTime? + paymentPlanDeniedAt DateTime? - program Program @relation(fields: [programId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + program Program @relation(fields: [programId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([programId, personId]) } model Fee { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:public - programId Int + programId Int /// @sensitivity:public - name String + name String /// @sensitivity:public /// Integer cents (e.g. 2599 = $25.99). nonOrgMemberPriceCents Int @@ -813,8 +875,8 @@ model FeePayment { /// @sensitivity:personal customNote String? - fee Fee @relation(fields: [feeId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + fee Fee @relation(fields: [feeId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([feeId, personId]) } @@ -824,6 +886,9 @@ model Event { id Int @id @default(autoincrement()) /// @sensitivity:public programId Int? + /// Phase-1 additive FK to the offering tier; backfilled = programId (id-alias). + /// programId stays authoritative until the read cutover (§7 P3). @sensitivity:public + instanceId Int? /// @sensitivity:public name String /// @sensitivity:public @@ -837,45 +902,46 @@ model Event { attendanceConfirmedAt DateTime? /// @sensitivity:internal attendanceConfirmedById Int? - attendanceConfirmedBy Person? @relation("EventAttendanceConfirmer", fields: [attendanceConfirmedById], references: [id]) + attendanceConfirmedBy Person? @relation("EventAttendanceConfirmer", fields: [attendanceConfirmedById], references: [id]) /// @sensitivity:internal - postEventEmailSent Boolean @default(false) + postEventEmailSent Boolean @default(false) /// @sensitivity:public recurringGroupId String? - program Program? @relation(fields: [programId], references: [id]) - rsvps RSVP[] - visits Visit[] + program Program? @relation(fields: [programId], references: [id]) + instance ProgramInstance? @relation(fields: [instanceId], references: [id]) + rsvps RSVP[] + visits Visit[] } model RSVP { /// @sensitivity:public - eventId Int + eventId Int /// @sensitivity:public - personId Int + personId Int /// @sensitivity:public - status RSVPStatus + status RSVPStatus /// @sensitivity:internal // DEPRECATED: event-start reminders were removed (calendars cover this). No code // reads or writes this column. Kept for now; drop in a follow-up migration once // this release is fully rolled out (see DEPLOY_MIGRATION_ORDER_OF_OPERATIONS.md). reminderSentAt DateTime? - event Event @relation(fields: [eventId], references: [id]) - person Person @relation(fields: [personId], references: [id]) + event Event @relation(fields: [eventId], references: [id]) + person Person @relation(fields: [personId], references: [id]) @@id([eventId, personId]) } model RawBadgeLog { /// @sensitivity:internal - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:internal - personId Int + personId Int /// @sensitivity:personal - timestamp DateTime @default(now()) + timestamp DateTime @default(now()) /// @sensitivity:personal - location String? + location String? person Person @relation(fields: [personId], references: [id]) @@ -884,7 +950,7 @@ model RawBadgeLog { model Visit { /// @sensitivity:public - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) /// @sensitivity:public personId Int /// @sensitivity:personal @@ -899,13 +965,13 @@ model Visit { associatedEventId Int? person Person @relation(fields: [personId], references: [id]) - event Event? @relation(fields: [associatedEventId], references: [id]) - - @@index([personId, departedAt]) // Active visits lookup in /api/scan + event Event? @relation(fields: [associatedEventId], references: [id]) // Partial unique index "Visit_one_open_per_participant" — at most one open visit // (departedAt IS NULL) per participant. Prisma's DSL can't express a partial // unique index, so it lives in the 20260629180000_visit_one_open_per_participant // raw-SQL migration. DB-level backstop against double check-in races. + + @@index([personId, departedAt]) // Active visits lookup in /api/scan } model AuditLog { @@ -962,14 +1028,14 @@ model Account { model Session { /// @sensitivity:internal - id String @id @default(cuid()) + id String @id @default(cuid()) /// @sensitivity:secret - sessionToken String @unique + sessionToken String @unique /// @sensitivity:internal - userId Int @map("participant_id") + userId Int @map("participant_id") /// @sensitivity:internal expires DateTime - user Person @relation(fields: [userId], references: [id], onDelete: Cascade) + user Person @relation(fields: [userId], references: [id], onDelete: Cascade) } model VerificationToken { diff --git a/checkin-app/src/app/__tests__/programInstanceBackfill.integration.test.ts b/checkin-app/src/app/__tests__/programInstanceBackfill.integration.test.ts new file mode 100644 index 00000000..1f1a3fe5 --- /dev/null +++ b/checkin-app/src/app/__tests__/programInstanceBackfill.integration.test.ts @@ -0,0 +1,183 @@ +import fs from 'fs'; +import path from 'path'; +import prisma from '@/lib/prisma'; + +/** + * Phase-1 backfill (MB) of PROGRAM_INSTANCE_RESTRUCTURE.md against a populated + * schema. The migration itself no-ops at global setup (empty DB), so this test + * seeds real Program/Event rows, then runs the migration's OWN backfill SQL + * (extracted verbatim between its markers — not a reimplementation) and asserts: + * - one ProgramInstance per program, id-aliased (instance.id === program.id) + * - every offering column copied, narrowing overrides left NULL + * - program-bound events linked, program-less events untouched + * - the id sequence bumped past the aliased ids (no collision on a new insert) + * - the whole thing is idempotent (running it twice changes nothing) + */ + +const MIGRATION_SQL = path.join( + __dirname, + '../../../prisma/migrations/20260708040000_program_instances_phase1/migration.sql', +); + +/** Pull the statements between the BACKFILL markers — the exact SQL the deploy runs. */ +function backfillStatements(): string[] { + const raw = fs.readFileSync(MIGRATION_SQL, 'utf8'); + // Drop the remainder of the START marker line (everything up to its newline) + // so any trailing note on that line can never leak into a SQL statement. + const afterStart = raw.split('-- >>> BACKFILL START')[1]; + const block = afterStart.slice(afterStart.indexOf('\n') + 1).split('-- >>> BACKFILL END')[0]; + return block + .split('\n') + .filter(line => !line.trim().startsWith('--')) + .join('\n') + .split(';') + .map(s => s.trim()) + .filter(Boolean); +} + +async function runBackfill(): Promise { + for (const stmt of backfillStatements()) { + await prisma.$executeRawUnsafe(stmt); + } +} + +describe('ProgramInstance phase-1 backfill (MB)', () => { + let householdId: number; + let leadId: number; + let progLegacyId: number; // pre-#930 two-variant program + let progSingleId: number; // #930 single-pool program + let boundEventId: number; + let looseEventId: number; // program-less event + + beforeAll(async () => { + const household = await prisma.household.create({ data: { name: 'Backfill Test HH' } }); + householdId = household.id; + const lead = await prisma.person.create({ + data: { name: 'Backfill Lead', email: `backfill.lead.${Date.now()}@example.com`, householdId }, + }); + leadId = lead.id; + + const legacy = await prisma.program.create({ + data: { + name: 'Backfill Legacy Program', + leadMentorId: leadId, + startAt: new Date('2026-09-01'), + endAt: new Date('2026-12-01'), + phase: 'RUNNING', + enrollmentStatus: 'OPEN', + orgMemberOnly: true, + minAge: 10, + maxAge: 14, + maxParticipants: 12, + leadMentorNotificationSettings: { digest: 'weekly' }, + shopifyProductId: 'prod_legacy', + shopifyOrgMemberVariantId: 'var_org_legacy', + shopifyNonOrgMemberVariantId: 'var_nonorg_legacy', + }, + }); + progLegacyId = legacy.id; + + const single = await prisma.program.create({ + data: { + name: 'Backfill Single Program', + startAt: new Date('2027-01-01'), + endAt: new Date('2027-03-01'), + phase: 'UPCOMING', + enrollmentStatus: 'CLOSED', + minAge: 8, + maxParticipants: 20, + shopifyProductId: 'prod_single', + shopifyVariantId: 'var_single', + }, + }); + progSingleId = single.id; + + const boundEvent = await prisma.event.create({ + data: { name: 'Backfill Bound Event', programId: progLegacyId, startAt: new Date(), endAt: new Date() }, + }); + boundEventId = boundEvent.id; + const looseEvent = await prisma.event.create({ + data: { name: 'Backfill Loose Event', programId: null, startAt: new Date(), endAt: new Date() }, + }); + looseEventId = looseEvent.id; + + await runBackfill(); + }); + + afterAll(async () => { + // instance.programId is ON DELETE RESTRICT, so instances go before programs; + // events' instanceId is ON DELETE SET NULL so instance deletes don't block. + await prisma.event.deleteMany({ where: { id: { in: [boundEventId, looseEventId] } } }); + await prisma.programInstance.deleteMany({ where: { programId: { in: [progLegacyId, progSingleId] } } }); + await prisma.program.deleteMany({ where: { id: { in: [progLegacyId, progSingleId] } } }); + await prisma.person.deleteMany({ where: { id: leadId } }); + await prisma.household.deleteMany({ where: { id: householdId } }); + }); + + it('creates exactly one id-aliased instance per program', async () => { + const legacyInstances = await prisma.programInstance.findMany({ where: { programId: progLegacyId } }); + const singleInstances = await prisma.programInstance.findMany({ where: { programId: progSingleId } }); + expect(legacyInstances).toHaveLength(1); + expect(singleInstances).toHaveLength(1); + // id-alias: instance.id === program.id (what makes the later FK repoint a pure rename) + expect(legacyInstances[0].id).toBe(progLegacyId); + expect(singleInstances[0].id).toBe(progSingleId); + }); + + it('copies every offering column and leaves narrowing overrides NULL', async () => { + const inst = await prisma.programInstance.findUniqueOrThrow({ where: { id: progLegacyId } }); + expect(inst.name).toBe('Backfill Legacy Program'); + expect(inst.leadMentorId).toBe(leadId); + expect(inst.phase).toBe('RUNNING'); + expect(inst.enrollmentStatus).toBe('OPEN'); + expect(inst.maxParticipants).toBe(12); + expect(inst.startAt?.toISOString()).toBe(new Date('2026-09-01').toISOString()); + expect(inst.leadMentorNotificationSettings).toEqual({ digest: 'weekly' }); + // legacy two-variant pair mirrored (the #930 deviation: no shopifyMemberDiscountId exists) + expect(inst.shopifyProductId).toBe('prod_legacy'); + expect(inst.shopifyOrgMemberVariantId).toBe('var_org_legacy'); + expect(inst.shopifyNonOrgMemberVariantId).toBe('var_nonorg_legacy'); + expect(inst.shopifyVariantId).toBeNull(); + // narrowing overrides start NULL = inherit the parent definition + expect(inst.minAge).toBeNull(); + expect(inst.maxAge).toBeNull(); + expect(inst.orgMemberOnly).toBeNull(); + + const single = await prisma.programInstance.findUniqueOrThrow({ where: { id: progSingleId } }); + expect(single.shopifyVariantId).toBe('var_single'); + expect(single.shopifyOrgMemberVariantId).toBeNull(); + expect(single.leadMentorId).toBeNull(); + }); + + it('links program-bound events and leaves program-less events untouched', async () => { + const bound = await prisma.event.findUniqueOrThrow({ where: { id: boundEventId } }); + const loose = await prisma.event.findUniqueOrThrow({ where: { id: looseEventId } }); + expect(bound.instanceId).toBe(progLegacyId); // === programId via id-alias + expect(loose.instanceId).toBeNull(); + }); + + it('bumps the id sequence past the aliased ids (new insert does not collide)', async () => { + const created = await prisma.programInstance.create({ + data: { programId: progSingleId, name: 'Post-backfill Instance' }, + }); + try { + expect(created.id).toBeGreaterThan(Math.max(progLegacyId, progSingleId)); + } finally { + await prisma.programInstance.delete({ where: { id: created.id } }); + } + }); + + it('is idempotent — a second run adds nothing and mutates nothing', async () => { + const before = await prisma.programInstance.count({ + where: { programId: { in: [progLegacyId, progSingleId] } }, + }); + await runBackfill(); + const after = await prisma.programInstance.count({ + where: { programId: { in: [progLegacyId, progSingleId] } }, + }); + expect(after).toBe(before); + // event link unchanged, not doubled or cleared + const bound = await prisma.event.findUniqueOrThrow({ where: { id: boundEventId } }); + expect(bound.instanceId).toBe(progLegacyId); + }); +}); diff --git a/checkin-app/src/security/generated/classifications.ts b/checkin-app/src/security/generated/classifications.ts index 4add8ec7..36432ab3 100644 --- a/checkin-app/src/security/generated/classifications.ts +++ b/checkin-app/src/security/generated/classifications.ts @@ -196,6 +196,25 @@ export const classifications = { shopifyNonOrgMemberVariantId: 'public', shopifyVariantId: 'public', }, + ProgramInstance: { + id: 'public', + programId: 'public', + name: 'public', + leadMentorId: 'public', + startAt: 'public', + endAt: 'public', + phase: 'public', + enrollmentStatus: 'public', + maxParticipants: 'public', + leadMentorNotificationSettings: 'personal', + minAge: 'public', + maxAge: 'public', + orgMemberOnly: 'public', + shopifyProductId: 'public', + shopifyVariantId: 'public', + shopifyOrgMemberVariantId: 'public', + shopifyNonOrgMemberVariantId: 'public', + }, ProgramVolunteer: { programId: 'public', personId: 'public', @@ -229,6 +248,7 @@ export const classifications = { Event: { id: 'public', programId: 'public', + instanceId: 'public', name: 'public', startAt: 'public', endAt: 'public', @@ -347,6 +367,7 @@ export const relations = { programVolunteers: { model: 'ProgramVolunteer', isList: true }, programParticipants: { model: 'ProgramParticipant', isList: true }, programsLed: { model: 'Program', isList: true }, + instancesLed: { model: 'ProgramInstance', isList: true }, feePayments: { model: 'FeePayment', isList: true }, rsvps: { model: 'RSVP', isList: true }, rawBadgeLogs: { model: 'RawBadgeLog', isList: true }, @@ -419,6 +440,12 @@ export const relations = { participants: { model: 'ProgramParticipant', isList: true }, fees: { model: 'Fee', isList: true }, events: { model: 'Event', isList: true }, + instances: { model: 'ProgramInstance', isList: true }, + }, + ProgramInstance: { + program: { model: 'Program', isList: false }, + leadMentor: { model: 'Person', isList: false }, + events: { model: 'Event', isList: true }, }, ProgramVolunteer: { program: { model: 'Program', isList: false }, @@ -439,6 +466,7 @@ export const relations = { Event: { attendanceConfirmedBy: { model: 'Person', isList: false }, program: { model: 'Program', isList: false }, + instance: { model: 'ProgramInstance', isList: false }, rsvps: { model: 'RSVP', isList: true }, visits: { model: 'Visit', isList: true }, }, diff --git a/checkin-app/src/security/scopeBindings.ts b/checkin-app/src/security/scopeBindings.ts index 8175bb18..fd036a12 100644 --- a/checkin-app/src/security/scopeBindings.ts +++ b/checkin-app/src/security/scopeBindings.ts @@ -147,6 +147,12 @@ export const ROW_SCOPE_KEY: Record = { * it as pending). Removed. */ export const OPT_OUT_PENDING_ROUTE = new Set([ + // Phase-1 of PROGRAM_INSTANCE_RESTRUCTURE.md: the table exists + is backfilled + // but has NO read consumers yet. Its `their_program_participants` binding (keyed + // on instancesLed/instancesCoreVolIn) lands with the atomic security-claim swap + // in Phase 3 (§5c/§7 P3) — binding it now would strip nothing, since + // buildCallerContext doesn't populate instancesLed until that phase. + 'ProgramInstance', 'OrgMembershipProcess', // board/admin today; a household-facing status route is plausible 'BackgroundCheckAttestation', // bind their_own at migration, keep notes `internal` 'Corporation', // has leads→participantId; a corp-lead view is plausible diff --git a/docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md b/docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md index 663fd1ec..28ab63fd 100644 --- a/docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md +++ b/docs/designs/PROGRAM_INSTANCE_RESTRUCTURE.md @@ -7,6 +7,61 @@ --- +## Re-validation — 2026-07-08, against post-#930 `main` (Phase 1 implemented) + +This section re-validates the plan against **current `main`** and records what +**Phase 1** actually ships (PR `feat/program-instances-phase1`). The rest of the +doc (§0–§9) is unchanged and still authoritative for the later phases; where it +conflicts with the findings here, **this section wins for Phase-1 scope**. + +### R1. The #929 baseline this doc assumed did NOT land — #930 did instead + +The doc's Shopify columns (§1/§2a/§3) assume **#929** (`SHOPIFY_MEMBER_SEGMENT_PRICING.md`) +shipped first: one `shopifyVariantId` + one `shopifyMemberDiscountId` per program, +member pricing as a segment-gated *automatic discount* whose grain moves down to +the instance. **#929 is unmerged.** What merged is **#930** +(`checkin-app/docs/PROGRAM_CAPACITY_AND_SCHOLARSHIPS.md`), whose Shopify model is: + +- **Single pool:** one `Program.shopifyVariantId` at the base (non-member) rate; Shopify inventory on that variant IS the program's capacity. +- **Member pricing = per-checkout, server-minted single-use discount CODES** (`mintMemberDiscountCode`), **not** a stored discount object. There is **no `shopifyMemberDiscountId` column** anywhere. +- **Legacy two-variant pair still present:** pre-#930 programs keep `shopifyOrgMemberVariantId` / `shopifyNonOrgMemberVariantId` (expand-only; the contract-drop is a later release). + +**Deviation (recorded):** `ProgramInstance` mirrors the Shopify columns that +*actually exist* on `Program` — `shopifyProductId`, `shopifyVariantId`, and the +legacy `shopifyOrgMemberVariantId` / `shopifyNonOrgMemberVariantId` pair. It does +**NOT** carry `shopifyMemberDiscountId` (§3's field), because that column doesn't +exist on `main`. Mirroring the legacy pair (rather than the doc's single-variant +assumption) is what keeps the 1:1 backfill lossless for pre-#930 programs whose +webhook order→program mapping still resolves through those variant ids. If #929 +later lands, the discount-grain-moves-to-instance decision (§2a) is re-instated +then as its own change. + +### R2. Capacity / hold-ledger home is consistent with #930 — no Phase-1 conflict + +- **Capacity.** #930 makes **Shopify** the source of truth; `Program.maxParticipants` seeds the *initial* variant inventory and drives relative delta propagation (`adjustProgramInventory`, `lib/shopify.ts`). The doc puts capacity on the instance (§2). These agree in the end state — one instance = one variant = one inventory pool — but Phase 1 is **additive**: `maxParticipants` is **copied** to the instance, and `Program.maxParticipants` stays authoritative and keeps driving inventory. **Flag for P2/P3:** when reads/writes cut over, `adjustProgramInventory` and the `maxParticipants`→inventory propagation must re-target the instance's variant. +- **Scholarship hold ledger.** #930's `ProgramParticipant.inventoryHeldAt` / `paymentPlanDeniedAt` and `BoardSettings.scholarshipDenialGraceDays` are untouched by Phase 1 — `ProgramParticipant` still FKs `programId` (its move to the instance is P2, §7). **Flag for P2:** the release paths in `lib/program/capacity.ts` (`withdrawAndReleaseHold`, the `orders/paid` webhook, `cron/scholarship-grace-expiry`) that key on `programId` must re-target `instanceId` when the roster FK moves. + +Neither the price columns (§2/§9.4) nor the narrowing-override decisions (§3a/§9.3) +are affected by #930; they stand as written. + +### R3. What Phase 1 ships in this PR (and the precise cut line) + +Phase 1 = the doc's **P1** (§7), nothing more. **Additive / expand only**, safe for +the live DB during the deploy drain window (old code neither reads nor writes the +new surface, and nothing it *does* read is touched): + +- **Schema:** new `ProgramInstance` model (all offering columns mirrored + the nullable narrowing overrides), nullable `Event.instanceId` + FK, `Program.instances` / `Person.instancesLed` back-relations. Every new field carries its `/// @sensitivity` tier; `classifications.ts` regenerated. +- **Migration `20260708040000_program_instances_phase1`:** the generated additive DDL **+ the MB backfill** — one id-aliased `ProgramInstance` per `Program`, `Event.instanceId = programId`, `setval` bump — all in one `BEGIN/COMMIT`, idempotent (skip programs that already have an instance; only fill still-null `instanceId`; `setval` derived from `MAX(id)` so it never regresses). Hand-written, not a Prisma DROP+ADD. +- **Security:** `ProgramInstance` added to `OPT_OUT_PENDING_ROUTE` (it is sensitive+scopable via `programId` but has **no read consumers yet**; its real `their_program_participants` binding lands with the atomic claim swap in P3, §5c). +- **Tests:** `programInstanceBackfill.integration.test.ts` runs the migration's own backfill SQL against seeded rows and asserts the id-alias, column copy, event linkage, sequence bump, and idempotency (second run is a no-op). + +**Deliberately deferred (NOT in this PR):** + +- **No FK repoint** (`ProgramParticipant`/`ProgramVolunteer`/`Fee` `programId`→`instanceId`, P2) — that is a RENAME = a *contract* step, forbidden in this additive release by the coalesce/expand-contract policy. +- **No read cutover / security-claim swap** (`programsLed`→`instancesLed`, `buildCallerContext`, `scopeBindings`, the event→instance→program hop — P3). The claim swap must be atomic with its consumers; splitting it into an additive release would lock lead mentors out. +- **No write/UI flip** (P4) and **no `Program` column drops / `Event.programId` drop** (P5). +- **No admin UI, and no seed change.** The doc's P1 explicitly adds *no read consumers* ("don't read it yet"); a read-only instances page would be the first reader and duplicates data existing program pages already render from `Program`, so it is deferred to P4 with the rest of the read cutover. The backfill is proven by the integration test, and `migration-safety.yml` re-runs it against the seeded `Woodworking 101` program — the seed needs no instance-creation of its own (in P1 no live write path creates instances either, so a seed that mints one would misrepresent the running app). + ## 0. The core thesis P2-3 was filed as a *naming* problem. It is actually a *missing structural layer*.