Skip to main content

Task Breakdown: Downgrade Webhook — Billing-Side Quota Notification

Derived from downgrade-webhook.md · RFC revision 2026-06-30 · Slicing: Horizontal (Phase 1 — Schema & Infrastructure; Phase 2 — Logic & Workers) · Repository: qontak-billing at internal/app/usecase/quota_management/

Key codebase findings (reconnaissance):

  • Job system: gocraft/work + Redis. Delayed jobs via enqueuer.EnqueueUniqueIn(jobName, secondsFromNow, args) — verified create_pi_carry_over_quota.go:30. §5 Q1 resolved.
  • Worker registration: helper/consts/worker.go (constant) → internal/app/consumer/worker_consumer.go (handler) → internal/app/worker/worker_service.go (registerJobWithOptions) → internal/app/service/job_enquerer/job_enqueuer.go (interface + impl).
  • Consumer pattern: internal/app/consumer/<feature>.go + <feature>_test.go. See quota_management_alert.go + create_pi_carry_over_quota.go as reference.
  • Enqueuer interface: IJobEnqueuer in internal/app/service/job_enquerer/job_enqueuer.go.
  • Test command: go test -race ./internal/app/... or make test.
  • Migration naming: YYYYMMDDHHMMSS_<description>.up.sql / .down.sql in db/migrations/.
  • §5 Q3 resolved: downgrade_events.status is the SoT for active/resolved state. Day 0 guard = SELECT WHERE company_id+billing_code AND status='active'. Partial unique index prevents duplicate active rows.

Effort Summary

Phase / AreaBE daysQA daysTotal
Phase 1 — Schema & Infrastructure2.50.53.0
Phase 2 — Logic & Workers4.51.56.0
Grand total7.02.09.0

Confidence: high. Key assumptions: (1) gocraft/work EnqueueUniqueIn verified as the delay mechanism — no new infra needed; (2) downgrade_events.status (active|resolved) is the SoT; partial unique index guards concurrent Day 0 inserts; (3) quota re-check in DowngradeNotificationWorker reads from the same tables used by recalculateComponentQuota.


Phase 1 — Schema & Infrastructure

Task 1.1: [BE] Migration — triggers_downgrade column on billing_components

Add the triggers_downgrade flag to billing_components so the billing service can identify which components trigger the downgrade notification flow.

Status: ✅ Actionable — no external dependency. Pre-deploy: verify 6 backfill codes exist in production (§5 Q7).

What to build

SQL migration that adds triggers_downgrade boolean NOT NULL DEFAULT false to billing_components and backfills the 6 component codes specified in the PRD.

Implementation Plan

ActionFileWhat changes
createdb/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.up.sqlADD COLUMN + backfill UPDATE
createdb/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.down.sqlDROP COLUMN

Implementation Steps

  1. Explore migration conventions — Open db/migrations/20260609100200_add_unlimited_value_to_billing_components.up.sql and note the format: ALTER TABLE ... ADD COLUMN ...; followed by a backfill UPDATE where applicable. Use the same timestamp format (YYYYMMDDHHMMSS).
  2. Write up migration — Create db/migrations/<timestamp>_add_triggers_downgrade_to_billing_components.up.sql:
    ALTER TABLE billing_components
    ADD COLUMN triggers_downgrade boolean NOT NULL DEFAULT false;
    UPDATE billing_components SET triggers_downgrade = true
    WHERE parent_component_code IN (
    'VOICE-RECORDING-2026-01','LOYALTY-2026-03','CUSTOMFIELD-2026-04',
    'LOYALTY-CURRENCY-2026-04','LOYALTY-TIER-2026-04','LOYALTY-RULE-2026-04'
    );
  3. Write down migration — Create .down.sql:
    ALTER TABLE billing_components DROP COLUMN IF EXISTS triggers_downgrade;
  4. Run and verifymake migrate-up. Confirm SELECT parent_component_code, triggers_downgrade FROM billing_components WHERE triggers_downgrade = true; returns exactly 6 rows. Rollback: make migrate-down, confirm column is gone.

Acceptance Criteria

  • billing_components.triggers_downgrade column exists, type boolean NOT NULL DEFAULT false
  • SELECT COUNT(*) FROM billing_components WHERE triggers_downgrade = true = 6
  • make migrate-down rolls back cleanly; column gone
  • Existing rows with triggers_downgrade = false remain unaffected

Effort Estimate

DisciplineDays
Backend0.5
QA0
Total0.5

Assumptions: pure DDL, no application code. QA = 0 — no user-facing behaviour.

Run to Verify

make migrate-up && make migrate-down && make migrate-up

Depends on

— (no dependency)


Task 1.2: [BE] Migration — downgrade_events + downgrade_schedules tables

Create two new tables: downgrade_events as a state machine per (company_id, billing_code) (active|resolved) and downgrade_schedules as the execution record per milestone (scheduled|fired|cancelled).

Status: ✅ Actionable — §5 Q3 resolved: downgrade_events.status is the SoT; partial unique index idx_de_active is the Day 0 guard.

What to build

Migration that creates downgrade_events (state machine active|resolved per company/billing_code) and downgrade_schedules (4 milestone rows per event, lifecycle scheduled→fired/cancelled).

Implementation Plan

ActionFileWhat changes
createdb/migrations/<timestamp>_create_downgrade_tables.up.sqlCREATE TABLE downgrade_events + downgrade_schedules + indexes
createdb/migrations/<timestamp>_create_downgrade_tables.down.sqlDROP TABLE both

Implementation Steps

  1. Explore existing table patterns — Open db/migrations/20260402000000_create_custom_margin_by_packages.up.sql to see the CREATE TABLE format used (uuid PK, timestamptz, CHECK constraints).
  2. Write up migration:
    CREATE TABLE downgrade_events (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    company_id varchar NOT NULL,
    billing_code varchar NOT NULL,
    negative_amount numeric(18,4) NOT NULL,
    status varchar NOT NULL DEFAULT 'active', -- active | resolved
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT chk_de_status CHECK (status IN ('active','resolved'))
    );
    -- at most one 'active' row per (company_id, billing_code) — Day 0 guard
    CREATE UNIQUE INDEX idx_de_active ON downgrade_events (company_id, billing_code) WHERE status = 'active';
    CREATE INDEX idx_de_company ON downgrade_events (company_id, billing_code);

    CREATE TABLE downgrade_schedules (
    id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    downgrade_event_id uuid NOT NULL REFERENCES downgrade_events (id),
    milestone varchar NOT NULL,
    scheduled_at timestamptz NOT NULL,
    status varchar NOT NULL DEFAULT 'scheduled',
    fired_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT chk_ds_milestone CHECK (milestone IN ('week_1','week_2','week_3','month_1')),
    CONSTRAINT chk_ds_status CHECK (status IN ('scheduled','fired','cancelled')),
    CONSTRAINT uq_ds_event_milestone UNIQUE (downgrade_event_id, milestone)
    );
    -- company_id + billing_code not stored here; read via JOIN to downgrade_events
    CREATE INDEX idx_ds_event ON downgrade_schedules (downgrade_event_id, status);
    CREATE INDEX idx_ds_due ON downgrade_schedules (status, scheduled_at);
  3. Write down migration: DROP TABLE IF EXISTS downgrade_schedules; DROP TABLE IF EXISTS downgrade_events;
  4. Run and verifymake migrate-up. Confirm tables and indexes exist. Insert a row with an invalid milestone — expect CHECK violation. Insert a duplicate (event_id, milestone) — expect unique violation. Insert two rows with status='active' for the same pair — expect unique index violation.

Acceptance Criteria

  • downgrade_events table exists with status varchar DEFAULT 'active' + CHECK + updated_at
  • idx_de_active partial unique index exists: UNIQUE (company_id, billing_code) WHERE status = 'active'
  • Inserting two status='active' rows for the same pair → ERROR: unique constraint
  • downgrade_schedules table exists with FK to downgrade_events, CHECK constraints, UNIQUE index; no company_id/billing_code columns
  • idx_ds_event and idx_ds_due indexes exist
  • Insert with invalid milestone → ERROR: check constraint
  • Duplicate (downgrade_event_id, milestone)ERROR: unique constraint
  • make migrate-down rolls back cleanly

Test Strategy

Migration test: run up migration, insert valid rows, test each constraint violation, run down migration. No unit tests needed — DDL-only change.

Effort Estimate

DisciplineDays
Backend1.0
QA0.5
Total1.5

Assumptions: ~1 day including constraint design and rollback verification. QA 0.5 days for constraint smoke tests.

Run to Verify

make migrate-up && make migrate-down && make migrate-up

Depends on

— (independent of Task 1.1)


Task 1.3: [BE] Payload extension + Query extension

Extend NegativeBalanceEvent with TriggerSequence int and add triggers_downgrade to BillingComponentGetComponentCodeRow so the logic layer can distinguish which trigger number this is and gate on the flag.

Status: ✅ Actionable.

What to build

  1. Add TriggerSequence int to NegativeBalanceEvent in internal/app/payload/kafka_event.go
  2. Add triggers_downgrade to the BillingComponentGetComponentCode SQL query + BillingComponentGetComponentCodeRow struct + scan in internal/app/repository/billing_components.sql.go

Implementation Plan

ActionFileWhat changes
extendinternal/app/payload/kafka_event.goadd TriggerSequence int \json:"trigger_sequence"`toNegativeBalanceEvent`
extenddb/queries/billing_components.sqladd bc.triggers_downgrade to BillingComponentGetComponentCode SELECT
extendinternal/app/repository/billing_components.sql.goadd TriggersDowngrade bool field to BillingComponentGetComponentCodeRow + add &i.TriggersDowngrade to scan

Implementation Steps

  1. Read existing struct — Open internal/app/payload/kafka_event.go:10. Note the field naming convention (json:"snake_case").
  2. Add TriggerSequence — Add the field after NegativeAmount:
    TriggerSequence int `json:"trigger_sequence"` // 1=day_0, 2=week_1, 3=week_2, 4=week_3, 5=month_1
    Zero-value (0) is backward-compatible for existing consumers that do not read this field.
  3. Read existing query — Open db/queries/billing_components.sql, find BillingComponentGetComponentCode :many. Note the SELECT field order — the Go scan order must match exactly.
  4. Add triggers_downgrade to SELECT — Append bc.triggers_downgrade to the end of the SELECT list.
  5. Update Go struct — In internal/app/repository/billing_components.sql.go, open BillingComponentGetComponentCodeRow. Add the field:
    TriggersDowngrade bool `json:"triggers_downgrade"`
    Add &i.TriggersDowngrade to rows.Scan(...) in the same position as the SELECT order. Important: scan order must match SELECT order exactly.
  6. Run testsgo test -race ./internal/app/repository/... to confirm no existing tests break from the struct change.

Acceptance Criteria

  • NegativeBalanceEvent.TriggerSequence field exists; compiles without error
  • BillingComponentGetComponentCodeRow.TriggersDowngrade field exists
  • go build ./... clean
  • go test -race ./internal/app/repository/... passes — existing tests unaffected
  • Existing consumers that do not read trigger_sequence remain compatible (zero-value 0)

Test Strategy

go build ./... as smoke test. Existing repository tests cover query compatibility. No new tests needed in this task — logic tests are in Phase 2.

Effort Estimate

DisciplineDays
Backend1.0
QA0
Total1.0

Assumptions: struct and query extension are straightforward; 1 day because scan order in the sqlc-generated file requires care.

Run to Verify

go build ./... && go test -race ./internal/app/repository/...

Depends on

  • Task 1.1 — triggers_downgrade column must exist in the DB before the query can be tested

Phase 2 — Logic & Workers

Task 2.1: [BE] DowngradeNotificationWorker — consumer, enqueuer, registration

Build a new worker invoked at each milestone (week_1..month_1): re-check the current balance from the DB, publish NegativeBalanceEvent with TriggerSequence=2..5 if still negative, or resolve the event and cancel remaining schedules if balance has recovered.

Status: ✅ Actionable — delayed jobs (EnqueueUniqueIn) verified available.

What to build

Four new files + changes to three existing files, following the CreatePIForCarryOverQuota worker pattern:

  1. Worker constant (helper/consts/worker.go)
  2. Consumer handler (internal/app/consumer/downgrade_notification.go)
  3. Enqueuer interface method + implementation (internal/app/service/job_enquerer/)
  4. Worker registration (internal/app/worker/worker_service.go)

Implementation Plan

ActionFileWhat changes
extendhelper/consts/worker.goadd DowngradeNotificationWorker JobName = "downgrade_notification_worker"
createinternal/app/consumer/downgrade_notification.goDowngradeNotificationWorker(*work.Job) error handler
createinternal/app/consumer/downgrade_notification_test.gounit tests for worker handler
extendinternal/app/service/job_enquerer/job_enqueuer.goadd EnqueueDowngradeNotificationWorker to IJobEnqueuer interface
createinternal/app/service/job_enquerer/downgrade_notification.goEnqueueDowngradeNotificationWorker implementation using EnqueueUniqueIn
createinternal/app/service/job_enquerer/downgrade_notification_test.gounit tests for enqueuer
extendinternal/app/worker/worker_service.goregisterJobWithOptions(string(consts.DowngradeNotificationWorker), options, workerConsumer.DowngradeNotificationWorker, pool)
extendinternal/app/consumer/worker_consumer.goadd DowngradeNotificationWorker method signature (if interface)

Implementation Steps

  1. Read reference implementations — Open internal/app/consumer/quota_management_alert.go (simple worker pattern) and internal/app/consumer/create_pi_carry_over_quota.go (delayed worker pattern). Note how payload is extracted from job.Args["data"], the error handling pattern, and the slog fields used.
  2. Add constant — In helper/consts/worker.go, add DowngradeNotificationWorker JobName = "downgrade_notification_worker".
  3. Write failing tests — Create internal/app/consumer/downgrade_notification_test.go with test cases:
    • balance still negative → NegativeBalanceEvent published with the correct TriggerSequence; schedule row updated to fired; downgrade_events row remains active
    • balance resolved → downgrade_events row updated to resolved; all scheduled rows updated to cancelled
    • schedule row already fired/cancelled (race/retry) → no-op
    • Kafka publish fails → row not updated to fired; error returned for job retry
  4. Implement consumer — Create internal/app/consumer/downgrade_notification.go:
    func (d *WorkerConsumer) DowngradeNotificationWorker(job *work.Job) error {
    // extract schedule_id from job.Args["data"]
    // SELECT downgrade_schedules WHERE id=$schedule_id FOR UPDATE
    // if status != 'scheduled': return nil (idempotency guard)
    // SELECT current quota (initialRemaining + additionalRemaining + postpaidRemaining)
    // map milestone string → TriggerSequence int (week_1=2, week_2=3, week_3=4, month_1=5)
    // if balance < 0: publish NegativeBalanceEvent + UPDATE downgrade_schedules status=fired
    // else: UPDATE downgrade_events status=resolved
    // UPDATE downgrade_schedules status=cancelled WHERE downgrade_event_id=X AND status=scheduled
    }
  5. Write enqueuer — Create internal/app/service/job_enquerer/downgrade_notification.go. Use e.enqueuer.EnqueueUniqueIn(string(jobName), secondsFromNow, work.Q{"data": paramsMap, "traceparent": traceparent}). Milestone delays in seconds:
    • week_1: 7*24*3600
    • week_2: 14*24*3600
    • week_3: 21*24*3600
    • month_1: 30*24*3600
  6. Register worker — In internal/app/worker/worker_service.go:registerJob(), add after QuotaManagementAlertWorker:
    registerJobWithOptions(string(consts.DowngradeNotificationWorker), options, workerConsumer.DowngradeNotificationWorker, pool)
  7. Go greengo test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/...

Acceptance Criteria

  • Worker registered: gocraft/work queue accepts downgrade_notification_worker jobs
  • Balance < 0: NegativeBalanceEvent published with TriggerSequence = {2,3,4,5} matching the milestone; schedule row status=fired; downgrade_events row remains active
  • Balance >= 0: downgrade_events row updated to resolved; all status='scheduled' rows for the event updated to cancelled
  • Schedule row already fired/cancelled when worker runs → no-op (idempotency guard)
  • Kafka publish fails → row not updated to fired; job retried by gocraft/work
  • EnqueueDowngradeNotificationWorker uses EnqueueUniqueIn with correct delays (7/14/21/30 days in seconds)
  • go test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/... passes

Test Strategy

Unit test the consumer with mocks for: DB queries (schedule SELECT FOR UPDATE, quota SELECT, schedule/event UPDATEs), Kafka publisher. Key test cases: (a) negative balance → publish + fired; (b) balance resolved → downgrade_events resolved + all scheduled cancelled; (c) FOR UPDATE guard → no-op; (d) publish failure → error returned → worker retries. Enqueuer test: verify EnqueueUniqueIn is called with the correct secondsFromNow for each milestone.

Effort Estimate

DisciplineDays
Backend2.5
QA1.0
Total3.5

Assumptions: reuses gocraft/work EnqueueUniqueIn pattern from create_pi_carry_over_quota.go; quota re-check uses existing query available in recalculateComponentQuota.

Run to Verify

go test -race ./internal/app/consumer/... ./internal/app/service/job_enquerer/... && make lint

Depends on

  • Task 1.2 — downgrade_schedules table must exist
  • Task 1.3 — NegativeBalanceEvent.TriggerSequence struct field must exist

Task 2.2: [BE] compareComponents Day 0 extension

Extend compareComponents in activate_or_update_component_quota.go so that when a negative balance is first detected (no active downgrade_events row), it publishes NegativeBalanceEvent with TriggerSequence=1, inserts a downgrade_events row (status=active) + 4 downgrade_schedules rows, and enqueues 4 delayed workers. When balance recovers and an active row exists, resolve the event and cancel all pending schedules.

Status: ✅ Actionable.

What to build

Modify recalculateComponentQuota (and/or compareComponents) in internal/app/usecase/quota_management/activate_or_update_component_quota.go to add the Day 0 flow and resolve cancellation. Pending Kafka publishes are collected in activateOrUpdateState and processed in publishPendingEvents, following the existing pattern for pendingNegativeBalanceEvents.

Implementation Plan

ActionFileWhat changes
extendinternal/app/usecase/quota_management/activate_or_update_component_quota.goadd pendingDowngradeDay0 to activateOrUpdateState; extend recalculateComponentQuota to collect Day 0 events; extend publishPendingEvents to process downgrade; add publishDowngradeDay0IfNeeded function
extendinternal/app/usecase/quota_management/activate_or_update_component_quota_test.gotest cases for Day 0 flow and resolve cancellation
extendinternal/app/usecase/quota_management/IQuotaManagementUsecase.goadd new method signatures if the interface needs updating

Implementation Steps

  1. Read recalculateComponentQuota and publishPendingEvents — Open activate_or_update_component_quota.go. Understand the pendingNegativeBalanceEvents pattern: events collected inside the loop, published after all transactions commit. The Day 0 downgrade events follow the same pattern.
  2. Write failing tests — Open activate_or_update_component_quota_test.go. Add test cases:
    • bc.TriggersDowngrade=true, balance first goes negative, no active downgrade_events row → downgrade_events INSERT, 4 downgrade_schedules INSERTs, NegativeBalanceEvent(TriggerSequence=1) published, 4 delayed jobs enqueued
    • bc.TriggersDowngrade=true, balance negative but active downgrade_events row exists → skip Day 0 (only original NegativeBalanceEvent published without downgrade flow)
    • bc.TriggersDowngrade=true, balance recovers from negative → UPDATE downgrade_events SET status='resolved' + UPDATE downgrade_schedules SET status='cancelled'
    • bc.TriggersDowngrade=false → no downgrade flow
  3. Add state struct fields — In activateOrUpdateState, add:
    pendingDowngradeDay0 []downgradeDay0Args
    where downgradeDay0Args holds {op, bc, negativeAmount}.
  4. Implement Day 0 guard — In recalculateComponentQuota, after computing the balance: if bc.TriggersDowngrade && newRemaining < 0, query SELECT id FROM downgrade_events WHERE company_id=$1 AND billing_code=$2 AND status='active'. If no row → append to pendingDowngradeDay0. If row exists → skip (flow already active).
  5. Implement resolve cancellation — In compareComponents or recalculateComponentQuota, if bc.TriggersDowngrade && newRemaining >= 0: check for an active downgrade_events row; if found → UPDATE downgrade_events SET status='resolved', updated_at=now() WHERE id=$active_event_id + UPDATE downgrade_schedules SET status='cancelled', updated_at=now() WHERE downgrade_event_id=$active_event_id AND status='scheduled'.
  6. Implement publishDowngradeDay0IfNeeded — In publishPendingEvents, iterate pendingDowngradeDay0 and for each:
    • INSERT INTO downgrade_events (company_id, billing_code, negative_amount, status='active')
    • INSERT INTO downgrade_schedules 4 rows (week_1..month_1, scheduled_at = now() + delay)
    • u.eventPublisher.Publish(ctx, TopicQuotaManagementNegativeBalance, key, NegativeBalanceEvent{TriggerSequence: 1, ...})
    • u.jobEnqueuer.EnqueueDowngradeNotificationWorker(ctx, scheduleID, milestone) × 4
  7. Go greengo test -race ./internal/app/usecase/quota_management/...

Acceptance Criteria

  • First negative balance + triggers_downgrade=true + no active row → 1 downgrade_events INSERT (status=active), 4 downgrade_schedules INSERTs, NegativeBalanceEvent(trigger_sequence=1) published, 4 EnqueueDowngradeNotificationWorker calls with correct delays
  • Negative balance + active downgrade_events row exists → Day 0 flow skipped (no duplicate inserts)
  • Balance recovers + active row exists → downgrade_events set to resolved; all scheduled rows set to cancelled
  • bc.TriggersDowngrade=false → no downgrade flow
  • Day 0 Kafka publish fails → log error; schedule rows already inserted; milestone workers still fire
  • No regression in recalculateComponentQuota for components without triggers_downgrade
  • go test -race ./internal/app/usecase/quota_management/... passes

Test Strategy

Unit test with mocks for DB, Kafka publisher, and jobEnqueuer. Key cases: (a) full happy path Day 0 — verify downgrade_events INSERT, 4 schedule INSERTs, Kafka publish TriggerSequence=1, 4 enqueue calls with correct delays; (b) active-row guard — verify no INSERT; (c) resolve path — verify downgrade_events updated to resolved and schedule bulk cancel; (d) regression — existing quota calculation results unchanged. Open activate_or_update_component_quota_test.go to follow existing mock patterns.

Effort Estimate

DisciplineDays
Backend2.0
QA0.5
Total2.5

Assumptions: pendingNegativeBalanceEvents pattern already exists and can be followed exactly; Day 0 guard is one SELECT; resolve cancellation is two UPDATEs.

Run to Verify

go test -race ./internal/app/usecase/quota_management/... && make lint && make sec

Depends on

  • Task 1.2 — downgrade_events + downgrade_schedules tables must exist
  • Task 1.3 — BillingComponentGetComponentCodeRow.TriggersDowngrade field must exist
  • Task 2.1 — IJobEnqueuer.EnqueueDowngradeNotificationWorker must exist in the interface

Ordering Rationale

  1. Tasks 1.1 and 1.2 can run in parallel — both are pure DDL migrations with no mutual dependency.
  2. Task 1.3 depends on Task 1.1 (the triggers_downgrade column must exist in the DB before the query can be tested), but coding can start in parallel — only the test run needs the migration to be applied first.
  3. Tasks 2.1 and 2.2 can be developed in parallel — 2.1 (worker consumer) and 2.2 (Day 0 extension) are independent; 2.2 only needs the IJobEnqueuer interface which can be mocked. Final integration tests require 2.1 to be complete.
  4. Critical path: 1.2 → 2.1 → 2.2 (schema → worker → Day 0 trigger).
  5. External gates: §5 Q6 (pgcrypto) and §5 Q7 (backfill codes) must be verified before migrations 1.1 and 1.2 are run in production, but do not block local development.

Skipped Stories

ItemReason
Email consumer§5 Q5: the consumer for billing.quota_management.negative_balance (filter trigger_sequence==1 → send email) is outside the scope of qontak-billing. Requires a separate RFC/ticket.
§5 Q6 pgcrypto verificationPre-deploy gate, not a coding task: SELECT 1 FROM pg_extension WHERE extname='pgcrypto'. Resolve before deploying to production.
§5 Q7 backfill codes verificationPre-deploy gate: verify 6 parent_component_code values exist in the production DB before running migration 1.1.
Full verification (RFC Task 7)Not a separate task — each task already has a ### Run to Verify section. Final verification happens at PR review.