Merge: t-paliad-305 — Slice B.4 destructive drop: paliad.deadline_rules retired, INSTEAD OF triggers on view (mig 140, snapshot pre_140 same-TX) (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 19:54:27 +02:00
11 changed files with 484 additions and 453 deletions

View File

@@ -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/
@@ -356,13 +355,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")

View File

@@ -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);

View File

@@ -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 $$;

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}()
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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)