Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.
Pre-flip drift verified clean against prod:
deadline_rules=231 == sequencing_rules=231 == procedural_events=231
legal_sources=87
missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0
* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
Single TX, audit-first:
1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
(precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
2. Final reconciliation UPDATE on paliad.deadlines (no-op when
drift is already 0; defensive against last-minute writes).
3. DROP TRIGGER deadline_rules_audit_aiud.
4. Re-point FKs to sequencing_rules:
- paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
- paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
(the id values are identical — sr.id inherited dr.id at mig 136.)
5. DROP COLUMN paliad.deadlines.rule_id.
6. DROP TABLE paliad.deadline_rules.
7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
paliad.deadline_rules_unified. Triggers route writes into the
three new tables in the same TX, preserving the legacy column
shape on the wire so RuleEditorService SQL only needs a
table-name swap, not a structural rewrite. Synthetic-code mint
expression is byte-identical to mig 136 + the B.2 dual-write
helper. POST assertions confirm the table is gone, the column
is gone, and the snapshot matches.
Trigger design notes (1:N caveat documented in-trigger):
- PE identity columns (code, name, name_en, description, event_kind,
primary_party_default, legal_source_id, concept_id) mirror from
the writing sequencing-rule.
- PE lifecycle columns (lifecycle_state, published_at, is_active)
deliberately do NOT mirror — a draft sequencing-rule cloned from
a published source shares the source's PE; we don't want the
clone's 'draft' lifecycle to leak back onto the source's PE.
Practical bound today (1:1 corpus); explicit comment in-trigger
for the eventual 1:N pattern.
* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
Best-effort restore from the snapshot. Triggers / indexes /
CHECK constraints from historical migrations are NOT replayed;
operator must reapply 078/079/091/095/098/122/128/134/135 to
bring the restored table to working shape. The down path is for
catastrophic recovery, not casual revert.
* internal/services/rule_editor_service.go —
Six syncDualWriteFromDeadlineRule(...) calls removed (the
INSTEAD OF triggers now do the fan-out). Five
INSERT/UPDATE paliad.deadline_rules statements (Create,
UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
flipLifecycle) renamed to paliad.deadline_rules_unified —
trigger handles the routing.
* internal/services/rule_editor_orphans.go — ResolveOrphan no
longer writes deadlines.rule_id (column dropped). Sets
sequencing_rule_id directly + derives procedural_event_id from
the matching sequencing_rules row in the same UPDATE statement.
* internal/services/deadline_service.go — deadlineColumns now
lists sequencing_rule_id (Deadline.RuleID still binds to it via
the db tag rename below). Update path's appendSet("rule_id",…)
flipped to appendSet("sequencing_rule_id",…) and post-write
derivation moved to the renamed syncDeadlineProceduralEventID
helper.
* internal/services/projection_service.go,
internal/services/submission_vars.go — `WHERE rule_id = $X`
reads on paliad.deadlines flipped to sequencing_rule_id.
* internal/models/models.go — Deadline.RuleID db tag changed from
"rule_id" to "sequencing_rule_id". Field name + JSON name kept
for backward compat with the frontend and existing Go callers;
semantic value is identical (same UUID).
* internal/services/dual_write.go — Massively trimmed.
Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
StartDualWriteDriftCheckLoop. All referenced
paliad.deadline_rules which no longer exists.
Kept (renamed): syncDeadlineProceduralEventID — derives
procedural_event_id from sequencing_rule_id after any
DeadlineService.Update that touched the back-link.
* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
bootstrap call (and its `time` import that only that call
needed). Comment notes the retirement.
* internal/services/dual_write_test.go — Removed the final
CheckDualWriteDrift assertion in
TestDualWrite_RuleEditorLifecycle (function deleted). The
per-step asserts against procedural_events / sequencing_rules
/ legal_sources cover the same contract by direct query.
Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
only).
Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
335 lines
16 KiB
PL/PgSQL
335 lines
16 KiB
PL/PgSQL
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
|
|
--
|
|
-- HARD STOPS:
|
|
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
|
|
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
|
|
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
|
|
-- single transaction because the migration runner wraps it; if any
|
|
-- statement fails, the snapshot CREATE TABLE rolls back with the
|
|
-- destructive DROP.
|
|
-- * No data loss: paliad.deadline_rules has been a write-side shadow
|
|
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
|
|
-- + legal_sources current). Drift verified clean before this slice
|
|
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
|
|
-- counts/FKs/lifecycle/is_active).
|
|
--
|
|
-- What this migration does:
|
|
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
|
|
-- trail of the table's final state for forensic + revert paths).
|
|
-- 2. Final reconciliation: catch any deadlines whose
|
|
-- sequencing_rule_id/procedural_event_id columns drifted from the
|
|
-- legacy rule_id (no live drift today — defensive).
|
|
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
|
|
-- gone table; the trigger function itself stays for the historical
|
|
-- paliad.deadline_rule_audit reads).
|
|
-- 4. Re-point FKs that currently target deadline_rules.id over to
|
|
-- sequencing_rules.id. The id values are identical (sequencing_rules
|
|
-- inherited deadline_rules.id during mig 136 backfill), so no data
|
|
-- migration is needed — just the constraint swap. Affects:
|
|
-- - paliad.appointments.deadline_rule_id
|
|
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
|
|
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
|
|
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
|
|
-- custom_rule_text as the human-readable denormalized columns —
|
|
-- they're the safety net for orphaned deadlines per t-paliad-258)."
|
|
-- The new sequencing_rule_id + procedural_event_id columns from
|
|
-- mig 136 are the FK back-links from B.4 forward.
|
|
-- 6. DROP TABLE paliad.deadline_rules.
|
|
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
|
|
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
|
|
-- RuleEditorService keep its existing SQL shape (one INSERT, one
|
|
-- UPDATE per write method) with only a table-name swap. The
|
|
-- triggers project the legacy column shape back to the three new
|
|
-- tables exactly as the dual-write helper did in B.2.
|
|
--
|
|
-- Down: best-effort restore from the snapshot. The original triggers,
|
|
-- indexes, and FKs are NOT recreated — operator must replay historical
|
|
-- migrations 078/079/091/095/098/122 to bring the table back to a
|
|
-- working shape. The down path is for catastrophic recovery, not casual
|
|
-- revert.
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 1. Snapshot — must precede the destructive ops (same TX).
|
|
-- ---------------------------------------------------------------
|
|
|
|
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
|
|
|
|
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
|
|
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
|
|
't-paliad-305) before the destructive DROP. Mirrors precedent '
|
|
'pre_091/093/095/098. Read-only forensic + revert source.';
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 2. Final reconciliation — should be a no-op (drift was 0 going
|
|
-- into this slice). Belt-and-braces against a write that snuck
|
|
-- in between drift-check and this migration.
|
|
-- ---------------------------------------------------------------
|
|
|
|
UPDATE paliad.deadlines d
|
|
SET sequencing_rule_id = d.rule_id,
|
|
procedural_event_id = sr.procedural_event_id
|
|
FROM paliad.sequencing_rules sr
|
|
WHERE sr.id = d.rule_id
|
|
AND d.rule_id IS NOT NULL
|
|
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
|
|
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 3. Drop the deadline_rules audit trigger. The trigger function
|
|
-- (paliad.deadline_rule_audit_trigger) stays defined for any
|
|
-- historical references; mig 079 created it.
|
|
-- ---------------------------------------------------------------
|
|
|
|
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
|
|
-- ---------------------------------------------------------------
|
|
|
|
ALTER TABLE paliad.appointments
|
|
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
|
|
ALTER TABLE paliad.appointments
|
|
ADD CONSTRAINT appointments_deadline_rule_id_fkey
|
|
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
|
|
|
|
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
|
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
|
|
ALTER TABLE paliad.deadline_rule_backfill_orphans
|
|
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
|
|
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
|
|
|
|
-- Drop the deadlines→deadline_rules FK before we drop the column.
|
|
ALTER TABLE paliad.deadlines
|
|
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
|
|
-- ---------------------------------------------------------------
|
|
|
|
ALTER TABLE paliad.deadlines
|
|
DROP COLUMN IF EXISTS rule_id;
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 6. DROP TABLE paliad.deadline_rules. Now that:
|
|
-- - dependent FKs are re-pointed to sequencing_rules,
|
|
-- - the audit trigger is dropped,
|
|
-- - deadlines.rule_id is gone,
|
|
-- nothing references the table anymore. The self-FKs
|
|
-- (deadline_rules.parent_id, .draft_of) drop with the table.
|
|
-- ---------------------------------------------------------------
|
|
|
|
DROP TABLE paliad.deadline_rules;
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
|
|
-- ---------------------------------------------------------------
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
|
|
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
|
DECLARE
|
|
v_legal_source_id uuid;
|
|
v_pe_id uuid;
|
|
v_code text;
|
|
BEGIN
|
|
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
|
|
IF NEW.legal_source IS NOT NULL THEN
|
|
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
|
VALUES (NEW.legal_source,
|
|
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
|
ON CONFLICT (citation) DO NOTHING;
|
|
SELECT id INTO v_legal_source_id
|
|
FROM paliad.legal_sources
|
|
WHERE citation = NEW.legal_source;
|
|
END IF;
|
|
|
|
-- Mint synthetic code when submission_code is NULL — same recipe
|
|
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
|
|
v_code := COALESCE(NEW.submission_code,
|
|
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
|
|
|
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
|
|
-- lifecycle_state / published_at / is_active alone — those track
|
|
-- the procedural-event concept's own lifecycle, not the inserting
|
|
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
|
|
-- rule creates a draft sr that shares the published PE; the PE
|
|
-- should stay 'published'). Identity columns DO update so an
|
|
-- admin editing a draft's name still flips the lawyer-visible
|
|
-- label (1:1 today; revisit when 1:N becomes a real pattern).
|
|
INSERT INTO paliad.procedural_events
|
|
(code, name, name_en, description, event_kind, primary_party_default,
|
|
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
|
|
VALUES
|
|
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
|
|
NEW.primary_party, v_legal_source_id, NEW.concept_id,
|
|
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
|
|
COALESCE(NEW.is_active, true))
|
|
ON CONFLICT (code) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
name_en = EXCLUDED.name_en,
|
|
description = EXCLUDED.description,
|
|
event_kind = EXCLUDED.event_kind,
|
|
primary_party_default = EXCLUDED.primary_party_default,
|
|
legal_source_id = EXCLUDED.legal_source_id,
|
|
concept_id = EXCLUDED.concept_id,
|
|
-- lifecycle_state / published_at / is_active deliberately omitted
|
|
updated_at = now()
|
|
RETURNING id INTO v_pe_id;
|
|
|
|
-- sequencing_rules insert. id is the caller-supplied NEW.id so
|
|
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
|
|
INSERT INTO paliad.sequencing_rules
|
|
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
|
|
duration_value, duration_unit, timing,
|
|
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
|
combine_op, condition_expr, primary_party, sequence_order,
|
|
is_spawn, spawn_label, spawn_proceeding_type_id,
|
|
is_bilateral, is_court_set, priority,
|
|
rule_code, rule_codes, deadline_notes, deadline_notes_en,
|
|
choices_offered, applies_to_target,
|
|
lifecycle_state, draft_of, published_at, is_active,
|
|
created_at, updated_at)
|
|
VALUES
|
|
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
|
|
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
|
|
COALESCE(NEW.timing, 'after'),
|
|
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
|
|
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
|
|
COALESCE(NEW.sequence_order, 0),
|
|
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
|
|
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
|
|
COALESCE(NEW.priority, 'mandatory'),
|
|
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
|
|
NEW.choices_offered, NEW.applies_to_target,
|
|
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
|
|
NEW.published_at, COALESCE(NEW.is_active, true),
|
|
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
|
|
|
|
RETURN NEW;
|
|
END $fn$;
|
|
|
|
CREATE TRIGGER deadline_rules_unified_insert
|
|
INSTEAD OF INSERT ON paliad.deadline_rules_unified
|
|
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
|
|
|
|
|
|
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
|
|
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
|
DECLARE
|
|
v_legal_source_id uuid;
|
|
v_code text;
|
|
BEGIN
|
|
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
|
|
-- A change FROM non-NULL TO NULL clears legal_source_id on the
|
|
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
|
|
IF NEW.legal_source IS NOT NULL THEN
|
|
INSERT INTO paliad.legal_sources (citation, jurisdiction)
|
|
VALUES (NEW.legal_source,
|
|
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
|
|
ON CONFLICT (citation) DO NOTHING;
|
|
SELECT id INTO v_legal_source_id
|
|
FROM paliad.legal_sources
|
|
WHERE citation = NEW.legal_source;
|
|
END IF;
|
|
|
|
v_code := COALESCE(NEW.submission_code,
|
|
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
|
|
|
|
-- Update procedural_events keyed by the existing PE link on
|
|
-- sequencing_rules. lifecycle_state / published_at / is_active on
|
|
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
|
|
-- the INSERT trigger comment for the rationale (a draft sr that
|
|
-- shares its PE with a published peer must not flip the PE to
|
|
-- draft). Identity columns DO mirror so editing name/code from
|
|
-- the admin UI continues to reach the lawyer-visible label.
|
|
UPDATE paliad.procedural_events
|
|
SET code = v_code,
|
|
name = NEW.name,
|
|
name_en = NEW.name_en,
|
|
description = NEW.description,
|
|
event_kind = NEW.event_type,
|
|
primary_party_default = NEW.primary_party,
|
|
legal_source_id = v_legal_source_id,
|
|
concept_id = NEW.concept_id,
|
|
updated_at = now()
|
|
WHERE id = (SELECT procedural_event_id
|
|
FROM paliad.sequencing_rules
|
|
WHERE id = NEW.id);
|
|
|
|
-- Update sequencing_rules (1:1 by id).
|
|
UPDATE paliad.sequencing_rules
|
|
SET proceeding_type_id = NEW.proceeding_type_id,
|
|
parent_id = NEW.parent_id,
|
|
trigger_event_id = NEW.trigger_event_id,
|
|
duration_value = NEW.duration_value,
|
|
duration_unit = NEW.duration_unit,
|
|
timing = NEW.timing,
|
|
alt_duration_value = NEW.alt_duration_value,
|
|
alt_duration_unit = NEW.alt_duration_unit,
|
|
alt_rule_code = NEW.alt_rule_code,
|
|
anchor_alt = NEW.anchor_alt,
|
|
combine_op = NEW.combine_op,
|
|
condition_expr = NEW.condition_expr,
|
|
primary_party = NEW.primary_party,
|
|
sequence_order = NEW.sequence_order,
|
|
is_spawn = NEW.is_spawn,
|
|
spawn_label = NEW.spawn_label,
|
|
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
|
|
is_bilateral = NEW.is_bilateral,
|
|
is_court_set = NEW.is_court_set,
|
|
priority = NEW.priority,
|
|
rule_code = NEW.rule_code,
|
|
rule_codes = NEW.rule_codes,
|
|
deadline_notes = NEW.deadline_notes,
|
|
deadline_notes_en = NEW.deadline_notes_en,
|
|
choices_offered = NEW.choices_offered,
|
|
applies_to_target = NEW.applies_to_target,
|
|
lifecycle_state = NEW.lifecycle_state,
|
|
draft_of = NEW.draft_of,
|
|
published_at = NEW.published_at,
|
|
is_active = NEW.is_active,
|
|
updated_at = now()
|
|
WHERE id = NEW.id;
|
|
|
|
RETURN NEW;
|
|
END $fn$;
|
|
|
|
CREATE TRIGGER deadline_rules_unified_update
|
|
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
|
|
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
|
|
|
|
-- ---------------------------------------------------------------
|
|
-- 8. POST assertions.
|
|
-- ---------------------------------------------------------------
|
|
|
|
DO $$
|
|
DECLARE
|
|
v_snapshot_count int;
|
|
v_view_count int;
|
|
v_dr_table_exists int;
|
|
v_rule_id_col int;
|
|
BEGIN
|
|
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
|
|
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
|
|
IF v_snapshot_count <> v_view_count THEN
|
|
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
|
|
v_snapshot_count, v_view_count;
|
|
END IF;
|
|
|
|
SELECT COUNT(*) INTO v_dr_table_exists
|
|
FROM information_schema.tables
|
|
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
|
|
IF v_dr_table_exists > 0 THEN
|
|
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
|
|
END IF;
|
|
|
|
SELECT COUNT(*) INTO v_rule_id_col
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
|
|
IF v_rule_id_col > 0 THEN
|
|
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
|
|
END IF;
|
|
|
|
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, view=% rows, INSTEAD OF triggers active',
|
|
v_snapshot_count, v_view_count;
|
|
END $$;
|