From 1129baba7a9a7fc0e6ffe39aa7c2be92563185ce Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 19:53:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(db,services):=20Slice=20B.4=20destructive?= =?UTF-8?q?=20drop=20=E2=80=94=20paliad.deadline=5Frules=20retired,=20INST?= =?UTF-8?q?EAD=20OF=20triggers=20on=20view=20route=20writes=20(mig=20140,?= =?UTF-8?q?=20t-paliad-305=20/=20m/paliad#93)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/main.go | 13 +- .../140_drop_deadline_rules.down.sql | 47 +++ .../migrations/140_drop_deadline_rules.up.sql | 334 +++++++++++++++ internal/models/models.go | 9 +- internal/services/deadline_service.go | 27 +- internal/services/dual_write.go | 396 ++---------------- internal/services/dual_write_test.go | 13 +- internal/services/projection_service.go | 4 +- internal/services/rule_editor_orphans.go | 23 +- internal/services/rule_editor_service.go | 67 ++- internal/services/submission_vars.go | 4 +- 11 files changed, 484 insertions(+), 453 deletions(-) create mode 100644 internal/db/migrations/140_drop_deadline_rules.down.sql create mode 100644 internal/db/migrations/140_drop_deadline_rules.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index d75fd60..6ebc2f9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,7 +12,6 @@ import ( "strconv" "strings" "syscall" - "time" // Embed Go's IANA tz database into the binary so time.LoadLocation works // without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/ @@ -340,13 +339,11 @@ func main() { log.Printf("CalDAV start: %v", err) } reminderSvc.Start(bgCtx) - // Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93). - // Runs every 6 h while the new procedural_events / sequencing_rules / - // legal_sources tables shadow the legacy paliad.deadline_rules - // table. A clean run logs at INFO; drift logs at WARN with the - // full report so a broken dual-write surfaces before the next - // deploy. - services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour) + // Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules + // dropped. The B.2 dual-write drift-check loop is retired — the + // procedural_events / sequencing_rules / legal_sources tables + // are now the source of truth and there is no parallel side to + // compare against. Pre-drop drift was verified clean in mig 140. go func() { <-bgCtx.Done() log.Println("background services: shutdown signal received") diff --git a/internal/db/migrations/140_drop_deadline_rules.down.sql b/internal/db/migrations/140_drop_deadline_rules.down.sql new file mode 100644 index 0000000..c3ac697 --- /dev/null +++ b/internal/db/migrations/140_drop_deadline_rules.down.sql @@ -0,0 +1,47 @@ +-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305 +-- +-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The +-- original triggers (mig 079 audit), indexes, CHECK constraints (mig +-- 135 primary_party), and FK constraints on the new tables are NOT +-- recreated here — restoring the working state requires replaying +-- migrations 078/079/091/095/098/122/128/134/135 against the restored +-- table. +-- +-- Use this only for catastrophic recovery. The normal revert path +-- for B.4 is to re-deploy the previous container image (which still +-- writes via the dual-write helper to a paliad.deadline_rules that no +-- longer exists) — that would crash on first write, so true revert +-- requires this down + a code revert + a snapshot restore. + +-- Drop the INSTEAD OF triggers + functions +DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified; +DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified; +DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger(); +DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger(); + +-- Recreate paliad.deadline_rules from snapshot. +CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140; + +-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints). +ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id); + +-- Re-point the FKs back to deadline_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.deadline_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.deadline_rules(id); + +-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id +-- which inherited deadline_rules.id during mig 136). +ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid; +UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL; +ALTER TABLE paliad.deadlines + ADD CONSTRAINT fristen_rule_id_fkey + FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id); diff --git a/internal/db/migrations/140_drop_deadline_rules.up.sql b/internal/db/migrations/140_drop_deadline_rules.up.sql new file mode 100644 index 0000000..8950e89 --- /dev/null +++ b/internal/db/migrations/140_drop_deadline_rules.up.sql @@ -0,0 +1,334 @@ +-- 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 $$; diff --git a/internal/models/models.go b/internal/models/models.go index 80786bf..f5f7b91 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -264,7 +264,14 @@ type Deadline struct { OriginalDueDate *time.Time `db:"original_due_date" json:"original_due_date,omitempty"` WarningDate *time.Time `db:"warning_date" json:"warning_date,omitempty"` Source string `db:"source" json:"source"` - RuleID *uuid.UUID `db:"rule_id" json:"rule_id,omitempty"` + // Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column + // dropped; the back-link now lives on `sequencing_rule_id` (FK to + // paliad.sequencing_rules). Same UUID values (sequencing_rules.id + // inherited deadline_rules.id during mig 136 backfill), so internal + // Go references to `RuleID` continue to carry the same semantic + // pointer. The JSON name stays `rule_id` for frontend backward-compat + // — B.5 will rename if/when frontend is updated. + RuleID *uuid.UUID `db:"sequencing_rule_id" json:"rule_id,omitempty"` // RuleCode is the legal citation ("RoP.023", "R.151") attached at // save time — see migration 032. Free text by design; survives // changes to paliad.deadline_rules and accepts citations from diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index c219065..70327e1 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -65,8 +65,13 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui return NewPendingApprovalError(rid, role) } +// Slice B.4 (mig 140, t-paliad-305): rule_id column dropped from +// paliad.deadlines. sequencing_rule_id holds the same UUID and is the +// FK to paliad.sequencing_rules. SELECT-column lists below pull +// sequencing_rule_id into the Deadline.RuleID field (db tag adjusted in +// internal/models/models.go). const deadlineColumns = `id, project_id, title, description, due_date, original_due_date, - warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag, + warning_date, source, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag, notes, created_by, created_at, updated_at, approval_status, pending_request_id, approved_by, approved_at` @@ -272,7 +277,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU ar.requester_kind AS requester_kind FROM paliad.deadlines f JOIN paliad.projects p ON p.id = f.project_id - LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.rule_id + LEFT JOIN paliad.deadline_rules_unified r ON r.id = f.sequencing_rule_id LEFT JOIN paliad.approval_requests ar ON ar.id = f.pending_request_id WHERE ` + strings.Join(conds, " AND ") + ` ORDER BY f.due_date ASC, f.created_at DESC` @@ -539,7 +544,11 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU if input.RuleID != nil && input.CustomRuleText != nil { return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput) } - appendSet("rule_id", input.RuleID) + // Slice B.4 (t-paliad-305): rule_id column dropped; the FK + // back-link now lives on sequencing_rule_id. Same UUID value. + // The procedural_event_id mirror is derived in + // syncDeadlineDualLinks below after the primary UPDATE lands. + appendSet("sequencing_rule_id", input.RuleID) var customText *string if input.CustomRuleText != nil { trimmed := strings.TrimSpace(*input.CustomRuleText) @@ -585,13 +594,13 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU if _, err := tx.ExecContext(ctx, query, args...); err != nil { return nil, fmt.Errorf("update deadline: %w", err) } - // Slice B.2 dual-write (t-paliad-305): if rule_id was in the - // patch (auto/custom swap from t-paliad-258), the parallel - // procedural_event_id + sequencing_rule_id columns must follow. - // Call unconditionally — it's a single UPDATE keyed on - // deadlineID and a no-op when rule_id is unchanged. + // Slice B.4 (mig 140, t-paliad-305): rule_id column gone; + // sequencing_rule_id holds the back-link. When the patch updated + // it (auto/custom swap from t-paliad-258), mirror the FK onto + // procedural_event_id so the joined view continues to resolve. + // Idempotent: no-op when sequencing_rule_id is unchanged. if input.RuleSet { - if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil { + if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil { return nil, err } } diff --git a/internal/services/dual_write.go b/internal/services/dual_write.go index f260191..8631b98 100644 --- a/internal/services/dual_write.go +++ b/internal/services/dual_write.go @@ -1,392 +1,50 @@ -// Slice B.2 dual-write (t-paliad-305 / m/paliad#93) — keep paliad's -// new tables (procedural_events / sequencing_rules / legal_sources) in -// lock-step with the legacy paliad.deadline_rules table during the -// dual-write window. Mig 136 (Slice B.1) created the new tables and -// backfilled them once. This file keeps them in sync going forward. +// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93). // -// Contract: +// Mig 140 dropped paliad.deadline_rules and installed INSTEAD OF +// triggers on paliad.deadline_rules_unified that route writes to +// procedural_events + sequencing_rules + legal_sources. The legacy +// dual-write helper (syncDualWriteFromDeadlineRule) and the drift-check +// loop (CheckDualWriteDrift / StartDualWriteDriftCheckLoop) reference +// paliad.deadline_rules, which no longer exists — they would crash on +// first call if kept. // -// - Every RuleEditorService method that mutates paliad.deadline_rules -// calls syncDualWriteFromDeadlineRule(ctx, tx, id) inside the same -// transaction, AFTER the deadline_rules write, BEFORE tx.Commit. -// - The sync is idempotent (INSERT … ON CONFLICT … DO UPDATE) so the -// same call works for Create (new row), UpdateDraft (existing row), -// CloneAsDraft (new row referencing an old row), Publish (lifecycle -// flip), Archive/Restore (lifecycle flip), and the published-peer -// archive that Publish performs as a cascade. -// - The sync re-derives the new-table state from paliad.deadline_rules -// in pure SQL — no struct mapping in Go. The legacy table stays the -// source of truth during B.2 (B.3 flips reads, B.4 drops it). -// - Read paths still read deadline_rules in B.2. The new tables are a -// parallel projection kept consistent for B.3's read cutover; they -// are not yet authoritative. +// Survivor: syncDeadlineProceduralEventID — keeps paliad.deadlines's +// new procedural_event_id column in sync with sequencing_rule_id after +// any UPDATE that touched the latter. Still useful as a "derive from +// canonical pointer" helper. // -// Why a per-row sync instead of a global trigger: -// -// - The deadline_rules audit trigger (mig 079) reads paliad.audit_reason -// to record the rationale on every change. Putting the new-table -// write in the same TX preserves that auditability — set_config is -// transactional and the new writes share the same reason. -// - A Postgres-side AFTER UPDATE trigger on deadline_rules would also -// work but it's harder to test in isolation and harder to revert -// when B.4 drops the source table. A Go-side sync is reversible -// with a code revert; an SQL trigger needs a follow-up migration. -// -// The drift-check job (CheckDualWriteDrift below) runs daily and -// alerts on mismatches. If the sync ever silently misses a row, the -// drift check surfaces it inside one day. -// -// See docs/design-procedural-events-model-2026-05-25.md §5.2 (dual-write -// phase) and docs/design-procedural-events-b0-findings-2026-05-26.md §7. +// The DualWriteDriftReport struct + HasDrift method are retired with +// the loop they served. + package services import ( "context" "fmt" - "log" - "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" ) -// syncDualWriteFromDeadlineRule re-projects the deadline_rules row with -// the given id into legal_sources + procedural_events + sequencing_rules. -// Runs three UPSERT statements in the open transaction. +// syncDeadlineProceduralEventID mirrors paliad.deadlines.sequencing_rule_id +// onto procedural_event_id. Call this within an open transaction AFTER +// any UPDATE that mutates paliad.deadlines.sequencing_rule_id (today's +// callers: DeadlineService.Update on the RuleSet branch, and the +// RuleEditorService orphan-resolve path which sets both columns in one +// statement so doesn't need this helper). // -// Synthetic-code rule (for rows where deadline_rules.submission_code is -// NULL) mirrors mig 136's backfill: 'null.' || first 8 hex chars of the -// uuid (dashes stripped). This must stay byte-identical to the mig 136 -// expression or the lookup join inside the sequencing_rules UPSERT -// misses. -func syncDualWriteFromDeadlineRule(ctx context.Context, tx *sqlx.Tx, id uuid.UUID) error { - // 1. legal_sources — UPSERT the citation (no-op if already present). - // jurisdiction is parsed from the first dot-separated segment; - // 'other' on empty (paranoid fallback, no live rows hit it). - if _, err := tx.ExecContext(ctx, ` - INSERT INTO paliad.legal_sources (citation, jurisdiction) - SELECT dr.legal_source, - COALESCE(NULLIF(split_part(dr.legal_source, '.', 1), ''), 'other') - FROM paliad.deadline_rules dr - WHERE dr.id = $1 AND dr.legal_source IS NOT NULL - ON CONFLICT (citation) DO NOTHING`, id); err != nil { - return fmt.Errorf("dual-write legal_sources for rule %s: %w", id, err) - } - - // 2. procedural_events — UPSERT keyed by code. The code is the - // submission_code if present, else the synthetic 'null.<8hex>' - // minted from the deadline_rules row's id (matches mig 136). - // legal_source_id is resolved by JOIN on legal_sources.citation - // (NULL when the rule has no legal_source). - if _, err := tx.ExecContext(ctx, ` - 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) - SELECT - COALESCE(dr.submission_code, - 'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)), - dr.name, dr.name_en, dr.description, dr.event_type, - dr.primary_party, ls.id, dr.concept_id, - dr.lifecycle_state, dr.published_at, dr.is_active - FROM paliad.deadline_rules dr - LEFT JOIN paliad.legal_sources ls ON ls.citation = dr.legal_source - WHERE dr.id = $1 - 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 = EXCLUDED.lifecycle_state, - published_at = EXCLUDED.published_at, - is_active = EXCLUDED.is_active, - updated_at = now()`, id); err != nil { - return fmt.Errorf("dual-write procedural_events for rule %s: %w", id, err) - } - - // 3. sequencing_rules — UPSERT keyed by id (1:1 inheritance from - // deadline_rules.id). procedural_event_id resolved by JOIN on - // the (real or synthetic) code. All hat-3 mechanics columns copy - // 1:1 from the deadline_rules row's post-write state. - if _, err := tx.ExecContext(ctx, ` - 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) - SELECT - dr.id, pe.id, - dr.proceeding_type_id, dr.parent_id, dr.trigger_event_id, - dr.duration_value, dr.duration_unit, dr.timing, - dr.alt_duration_value, dr.alt_duration_unit, dr.alt_rule_code, dr.anchor_alt, - dr.combine_op, dr.condition_expr, dr.primary_party, dr.sequence_order, - dr.is_spawn, dr.spawn_label, dr.spawn_proceeding_type_id, - dr.is_bilateral, dr.is_court_set, dr.priority, - dr.rule_code, dr.rule_codes, dr.deadline_notes, dr.deadline_notes_en, - dr.choices_offered, dr.applies_to_target, - dr.lifecycle_state, dr.draft_of, dr.published_at, dr.is_active, - dr.created_at, dr.updated_at - FROM paliad.deadline_rules dr - JOIN paliad.procedural_events pe - ON pe.code = COALESCE(dr.submission_code, - 'null.' || substring(replace(dr.id::text, '-', ''), 1, 8)) - WHERE dr.id = $1 - ON CONFLICT (id) DO UPDATE SET - procedural_event_id = EXCLUDED.procedural_event_id, - proceeding_type_id = EXCLUDED.proceeding_type_id, - parent_id = EXCLUDED.parent_id, - trigger_event_id = EXCLUDED.trigger_event_id, - duration_value = EXCLUDED.duration_value, - duration_unit = EXCLUDED.duration_unit, - timing = EXCLUDED.timing, - alt_duration_value = EXCLUDED.alt_duration_value, - alt_duration_unit = EXCLUDED.alt_duration_unit, - alt_rule_code = EXCLUDED.alt_rule_code, - anchor_alt = EXCLUDED.anchor_alt, - combine_op = EXCLUDED.combine_op, - condition_expr = EXCLUDED.condition_expr, - primary_party = EXCLUDED.primary_party, - sequence_order = EXCLUDED.sequence_order, - is_spawn = EXCLUDED.is_spawn, - spawn_label = EXCLUDED.spawn_label, - spawn_proceeding_type_id = EXCLUDED.spawn_proceeding_type_id, - is_bilateral = EXCLUDED.is_bilateral, - is_court_set = EXCLUDED.is_court_set, - priority = EXCLUDED.priority, - rule_code = EXCLUDED.rule_code, - rule_codes = EXCLUDED.rule_codes, - deadline_notes = EXCLUDED.deadline_notes, - deadline_notes_en = EXCLUDED.deadline_notes_en, - choices_offered = EXCLUDED.choices_offered, - applies_to_target = EXCLUDED.applies_to_target, - lifecycle_state = EXCLUDED.lifecycle_state, - draft_of = EXCLUDED.draft_of, - published_at = EXCLUDED.published_at, - is_active = EXCLUDED.is_active, - updated_at = now()`, id); err != nil { - return fmt.Errorf("dual-write sequencing_rules for rule %s: %w", id, err) - } - - return nil -} - -// syncDeadlineDualLinks mirrors a deadline's legacy rule_id back-link -// onto the new procedural_event_id + sequencing_rule_id columns added -// by mig 136. Call this within an open transaction AFTER any UPDATE -// that mutates paliad.deadlines.rule_id (mig 122 introduced rule_id -// as the deadline→rule FK; today's writers are DeadlineService.Update -// and RuleEditorService.ResolveOrphan). -// -// Idempotent: NULL rule_id collapses both new columns to NULL by virtue -// of the subquery returning NULL. Slice B.2 (t-paliad-305). -func syncDeadlineDualLinks(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error { +// Idempotent: NULL sequencing_rule_id collapses procedural_event_id to +// NULL via the subquery returning NULL. Slice B.4 (t-paliad-305). +func syncDeadlineProceduralEventID(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID) error { if _, err := tx.ExecContext(ctx, ` UPDATE paliad.deadlines d - SET sequencing_rule_id = d.rule_id, - procedural_event_id = ( + SET procedural_event_id = ( SELECT sr.procedural_event_id FROM paliad.sequencing_rules sr - WHERE sr.id = d.rule_id + WHERE sr.id = d.sequencing_rule_id ) WHERE d.id = $1`, deadlineID); err != nil { - return fmt.Errorf("sync deadline dual-links for %s: %w", deadlineID, err) + return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err) } return nil } - -// DualWriteDriftReport summarises the comparison between the legacy -// paliad.deadline_rules table and the new procedural_events / -// sequencing_rules tables that B.2's dual-write is meant to keep in -// sync. A zero-drift report (every count delta zero, every join clean) -// is the steady state during the dual-write window; any non-zero field -// is the signal that a write path either bypassed -// syncDualWriteFromDeadlineRule or that an out-of-band mutation -// happened (e.g. raw SQL run by an operator). -type DualWriteDriftReport struct { - // Counts on the legacy and the projected side. - DeadlineRules int `json:"deadline_rules"` - SequencingRules int `json:"sequencing_rules"` - ProceduralEvents int `json:"procedural_events"` - LegalSources int `json:"legal_sources"` - - // Expected (from the legacy side) vs observed (on the new side). - ExpectedPE int `json:"expected_procedural_events"` - ExpectedLegalSources int `json:"expected_legal_sources"` - - // MissingSR — deadline_rules rows with no sequencing_rules row by id. - // OrphanedSR — sequencing_rules rows whose id doesn't exist in - // deadline_rules anymore (would only happen with a deletion path - // that bypasses dual-write). - MissingSR int `json:"missing_sequencing_rules"` - OrphanedSR int `json:"orphaned_sequencing_rules"` - - // MismatchedLifecycle — rows where deadline_rules.lifecycle_state - // disagrees with sequencing_rules.lifecycle_state. Should always be - // zero during dual-write. - MismatchedLifecycle int `json:"mismatched_lifecycle"` - - // MismatchedActive — same shape, for is_active. - MismatchedActive int `json:"mismatched_active"` -} - -// HasDrift returns true if any field signals divergence between the -// legacy and projected sides. Used by the drift-check ticker to decide -// whether to log at WARN (drift) or INFO (clean). -func (r DualWriteDriftReport) HasDrift() bool { - if r.SequencingRules != r.DeadlineRules { - return true - } - if r.ProceduralEvents != r.ExpectedPE { - return true - } - if r.LegalSources != r.ExpectedLegalSources { - return true - } - if r.MissingSR != 0 || r.OrphanedSR != 0 { - return true - } - if r.MismatchedLifecycle != 0 || r.MismatchedActive != 0 { - return true - } - return false -} - -// CheckDualWriteDrift compares the legacy paliad.deadline_rules table -// against the parallel new tables maintained by Slice B.2's dual-write. -// Returns a DualWriteDriftReport — caller decides what to do with -// non-zero drift (log, page, fail healthcheck, etc.). -// -// Read-only. Safe to run against prod. Single query per metric so the -// pool isn't held for a long time. No locks; tolerates concurrent -// writes (counts may shift by one or two during the read, but a -// persistent drift > 0 is the alarm signal). -func CheckDualWriteDrift(ctx context.Context, conn *sqlx.DB) (*DualWriteDriftReport, error) { - var r DualWriteDriftReport - - q := func(label, sql string, dst *int) error { - if err := conn.GetContext(ctx, dst, sql); err != nil { - return fmt.Errorf("drift-check %s: %w", label, err) - } - return nil - } - - if err := q("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &r.DeadlineRules); err != nil { - return nil, err - } - if err := q("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &r.SequencingRules); err != nil { - return nil, err - } - if err := q("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &r.ProceduralEvents); err != nil { - return nil, err - } - if err := q("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &r.LegalSources); err != nil { - return nil, err - } - - if err := q("expected_pe", ` - SELECT - (SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL) - + - (SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL) - `, &r.ExpectedPE); err != nil { - return nil, err - } - if err := q("expected_ls", - `SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`, - &r.ExpectedLegalSources); err != nil { - return nil, err - } - - if err := q("missing_sr", ` - SELECT COUNT(*) FROM paliad.deadline_rules dr - LEFT JOIN paliad.sequencing_rules sr ON sr.id = dr.id - WHERE sr.id IS NULL`, &r.MissingSR); err != nil { - return nil, err - } - if err := q("orphaned_sr", ` - SELECT COUNT(*) FROM paliad.sequencing_rules sr - LEFT JOIN paliad.deadline_rules dr ON dr.id = sr.id - WHERE dr.id IS NULL`, &r.OrphanedSR); err != nil { - return nil, err - } - - if err := q("mismatched_lifecycle", ` - SELECT COUNT(*) FROM paliad.deadline_rules dr - JOIN paliad.sequencing_rules sr ON sr.id = dr.id - WHERE dr.lifecycle_state <> sr.lifecycle_state`, &r.MismatchedLifecycle); err != nil { - return nil, err - } - if err := q("mismatched_active", ` - SELECT COUNT(*) FROM paliad.deadline_rules dr - JOIN paliad.sequencing_rules sr ON sr.id = dr.id - WHERE dr.is_active <> sr.is_active`, &r.MismatchedActive); err != nil { - return nil, err - } - - return &r, nil -} - -// StartDualWriteDriftCheckLoop runs CheckDualWriteDrift on a fixed -// interval for the lifetime of ctx. A clean run logs at INFO level; -// drift logs at WARN level with the full report payload. The first -// check fires after `interval`, not immediately on Start — by the time -// the ticker first fires the process has finished booting and the -// initial backfill + dual-write writes have settled. -// -// Slice B.2 (t-paliad-305). interval should be short enough to surface -// drift before the next deploy (so a broken dual-write doesn't sit -// silent for a week) and long enough to avoid noise (the check holds -// no locks but it does run nine SELECT COUNTs). -// -// Recommended interval: 6h. Override via the caller (cmd/server picks -// the runtime value). -func StartDualWriteDriftCheckLoop(ctx context.Context, conn *sqlx.DB, interval time.Duration) { - if interval <= 0 { - interval = 6 * time.Hour - } - go func() { - t := time.NewTicker(interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - report, err := CheckDualWriteDrift(ctx, conn) - if err != nil { - log.Printf("dual-write drift-check: error: %v", err) - continue - } - if report.HasDrift() { - log.Printf("dual-write drift-check: DRIFT DETECTED — "+ - "deadline_rules=%d sequencing_rules=%d "+ - "procedural_events=%d (expected %d) "+ - "legal_sources=%d (expected %d) "+ - "missing_sr=%d orphaned_sr=%d "+ - "mismatched_lifecycle=%d mismatched_active=%d", - report.DeadlineRules, report.SequencingRules, - report.ProceduralEvents, report.ExpectedPE, - report.LegalSources, report.ExpectedLegalSources, - report.MissingSR, report.OrphanedSR, - report.MismatchedLifecycle, report.MismatchedActive) - } else { - log.Printf("dual-write drift-check: OK — "+ - "deadline_rules=%d sequencing_rules=%d "+ - "procedural_events=%d legal_sources=%d", - report.DeadlineRules, report.SequencingRules, - report.ProceduralEvents, report.LegalSources) - } - } - } - }() -} - diff --git a/internal/services/dual_write_test.go b/internal/services/dual_write_test.go index 97f15f3..d8f20ea 100644 --- a/internal/services/dual_write_test.go +++ b/internal/services/dual_write_test.go @@ -202,14 +202,11 @@ func TestDualWrite_RuleEditorLifecycle(t *testing.T) { t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived") } - // 5. Drift check should return zero drift right after the dance. - report, err := CheckDualWriteDrift(ctx, pool) - if err != nil { - t.Fatalf("CheckDualWriteDrift: %v", err) - } - if report.HasDrift() { - t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report) - } + // Slice B.4 (mig 140, t-paliad-305): the legacy paliad.deadline_rules + // table is gone and so is CheckDualWriteDrift — there's no parallel + // side to compare against. The INSTEAD OF triggers on the view + // guarantee parity by construction (single TX fan-out from one + // SQL write to three target tables). } // TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule diff --git a/internal/services/projection_service.go b/internal/services/projection_service.go index b77af2e..a51f383 100644 --- a/internal/services/projection_service.go +++ b/internal/services/projection_service.go @@ -1805,7 +1805,7 @@ func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, project err := s.db.GetContext(ctx, &count, ` SELECT COUNT(*) FROM ( SELECT 1 FROM paliad.deadlines - WHERE project_id = $1 AND rule_id = $2 + WHERE project_id = $1 AND sequencing_rule_id = $2 AND (completed_at IS NOT NULL OR status = 'completed' OR source = 'anchor') @@ -1843,7 +1843,7 @@ func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, pr var existingID uuid.UUID err := s.db.GetContext(ctx, &existingID, `SELECT id FROM paliad.deadlines - WHERE project_id = $1 AND rule_id = $2 + WHERE project_id = $1 AND sequencing_rule_id = $2 ORDER BY created_at ASC LIMIT 1`, projectID, rule.ID) switch { diff --git a/internal/services/rule_editor_orphans.go b/internal/services/rule_editor_orphans.go index e0328fc..80264bb 100644 --- a/internal/services/rule_editor_orphans.go +++ b/internal/services/rule_editor_orphans.go @@ -212,20 +212,21 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI } now := time.Now().UTC() + // Slice B.4 (mig 140, t-paliad-305): paliad.deadlines.rule_id column + // dropped. Back-link lives on sequencing_rule_id (same UUIDs as + // before — sr.id inherited dr.id at mig 136 backfill). + // procedural_event_id is derived from the same sequencing_rules row. if _, err := tx.ExecContext(ctx, - `UPDATE paliad.deadlines - SET rule_id = $1, - updated_at = $2 - WHERE id = $3`, + `UPDATE paliad.deadlines d + SET sequencing_rule_id = $1, + procedural_event_id = (SELECT procedural_event_id + FROM paliad.sequencing_rules + WHERE id = $1), + updated_at = $2 + WHERE d.id = $3`, ruleID, now, oc.DeadlineID, ); err != nil { - return fmt.Errorf("set deadline rule_id: %w", err) - } - // Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto - // the parallel deadlines.procedural_event_id + sequencing_rule_id - // columns so they don't drift from rule_id. - if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil { - return err + return fmt.Errorf("set deadline sequencing_rule_id: %w", err) } if _, err := tx.ExecContext(ctx, `UPDATE paliad.deadline_rule_backfill_orphans diff --git a/internal/services/rule_editor_service.go b/internal/services/rule_editor_service.go index e7c7787..02ffc98 100644 --- a/internal/services/rule_editor_service.go +++ b/internal/services/rule_editor_service.go @@ -178,7 +178,7 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r // here writes the live shape only — priority + condition_expr // + is_court_set are the new gates. if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.deadline_rules + `INSERT INTO paliad.deadline_rules_unified (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, @@ -209,13 +209,11 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r return nil, fmt.Errorf("insert rule: %w", err) } - // Slice B.2 dual-write (t-paliad-305): project the new row into - // legal_sources / procedural_events / sequencing_rules in the same - // transaction so the parallel tables stay in lock-step with - // deadline_rules through the B.3 read-cutover window. - if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil { - return nil, err - } + // Slice B.4 (mig 140, t-paliad-305): write routes through the + // INSTEAD OF triggers on paliad.deadline_rules_unified, which fan + // out into legal_sources + procedural_events + sequencing_rules. + // No Go-side mirror call needed — the INSERT above already landed + // the parallel rows. if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit create: %w", err) @@ -279,15 +277,13 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch args = append(args, time.Now().UTC()) args = append(args, id) q := fmt.Sprintf( - `UPDATE paliad.deadline_rules SET %s WHERE id = $%d AND lifecycle_state = 'draft'`, + `UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`, strings.Join(sets, ", "), len(args)) if _, err := tx.ExecContext(ctx, q, args...); err != nil { return nil, fmt.Errorf("update rule draft: %w", err) } - // Slice B.2 dual-write (t-paliad-305). - if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil { - return nil, err - } + // Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the + // new-table writes — the UPDATE above is already fan-out. if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit update: %w", err) } @@ -321,7 +317,7 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas newID := uuid.New() if _, err := tx.ExecContext(ctx, - `INSERT INTO paliad.deadline_rules + `INSERT INTO paliad.deadline_rules_unified (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code, name, name_en, description, primary_party, event_type, duration_value, duration_unit, timing, @@ -342,20 +338,16 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas is_active, 'draft', $2, NULL, now(), now() - FROM paliad.deadline_rules + FROM paliad.deadline_rules_unified WHERE id = $2`, newID, id, ); err != nil { return nil, fmt.Errorf("clone rule as draft: %w", err) } - // Slice B.2 dual-write (t-paliad-305): new draft gets its own - // procedural_events + sequencing_rules row. The synthetic-code - // branch fires here when the source rule had NULL submission_code - // (the clone inherits the NULL and mints a fresh 'null.<8hex>' - // derived from newID). - if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil { - return nil, err - } + // Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger + // mints the synthetic 'null.<8hex>' code when submission_code is + // NULL (matching mig 136 + the legacy dual-write helper's + // expression). if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit clone: %w", err) } @@ -389,7 +381,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st now := time.Now().UTC() if _, err := tx.ExecContext(ctx, - `UPDATE paliad.deadline_rules + `UPDATE paliad.deadline_rules_unified SET lifecycle_state = 'published', published_at = $1, updated_at = $1 @@ -402,7 +394,7 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st // Archive the peer this draft was cloned from, if any. if current.DraftOf != nil { if _, err := tx.ExecContext(ctx, - `UPDATE paliad.deadline_rules + `UPDATE paliad.deadline_rules_unified SET lifecycle_state = 'archived', updated_at = $1 WHERE id = $2 AND lifecycle_state = 'published'`, @@ -412,17 +404,9 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st } } - // Slice B.2 dual-write (t-paliad-305): sync both sides — the newly - // published draft AND the cloned-from peer that just flipped to - // archived (if any). - if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil { - return nil, err - } - if current.DraftOf != nil { - if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil { - return nil, err - } - } + // Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via + // the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip + // onto procedural_events + sequencing_rules in the same TX. if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit publish: %w", err) @@ -471,7 +455,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar // timestamp helps audit reads ("when was this rule first live?"). if target == "published" { if _, err := tx.ExecContext(ctx, - `UPDATE paliad.deadline_rules + `UPDATE paliad.deadline_rules_unified SET lifecycle_state = $1, published_at = COALESCE(published_at, $2), updated_at = $2 @@ -482,7 +466,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar } } else { if _, err := tx.ExecContext(ctx, - `UPDATE paliad.deadline_rules + `UPDATE paliad.deadline_rules_unified SET lifecycle_state = $1, updated_at = $2 WHERE id = $3`, target, now, id, @@ -491,11 +475,8 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar } } - // Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip - // onto sequencing_rules + procedural_events. - if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil { - return nil, err - } + // Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger + // mirrors the lifecycle flip onto sr + pe. if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit flip: %w", err) diff --git a/internal/services/submission_vars.go b/internal/services/submission_vars.go index 0acd770..5f237b9 100644 --- a/internal/services/submission_vars.go +++ b/internal/services/submission_vars.go @@ -289,12 +289,12 @@ func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, var d models.Deadline err := s.db.GetContext(ctx, &d, `SELECT id, project_id, title, description, due_date, original_due_date, - warning_date, source, rule_id, rule_code, status, completed_at, + warning_date, source, sequencing_rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag, notes, created_by, created_at, updated_at, approval_status, pending_request_id, approved_by, approved_at FROM paliad.deadlines WHERE project_id = $1 - AND rule_id = $2 + AND sequencing_rule_id = $2 AND status = 'pending' ORDER BY due_date ASC LIMIT 1`, projectID, ruleID)