Creates the three new tables that split today's paliad.deadline_rules
into its three latent concepts, plus two nullable link columns on
paliad.deadlines for B.2 dual-write.
ADDITIVE ONLY. paliad.deadline_rules is untouched. deadlines.rule_id
stays in place — it remains the authoritative deadline → rule link
until B.3 cutover flips reads and B.4 drops the legacy table.
* paliad.legal_sources — distinct citations (87 rows backfilled).
pretty_de/pretty_en deferred (Go
legalSourcePretty still computes them
on read; future slice backfills).
* paliad.procedural_events — 153 rows from distinct submission_codes
+ 78 synthetic-code rows for the
NULL-submission_code branch (m's pick
via paliadin 2026-05-26: mint
'null.<8hex>' codes so every rule row
has a procedural event, preserving the
NOT NULL FK on sequencing_rules).
* paliad.sequencing_rules — 1:1 with deadline_rules (231 rows). id
inherited from deadline_rules.id so any
existing deadlines.rule_id FK resolves
transitively to the new sequencing_rule
during the dual-write window.
* paliad.deadlines.procedural_event_id, sequencing_rule_id (nullable,
backfilled by JOIN on the inherited id).
Audit-first pattern (mirrors mig 135): PRE pass counts what we're about
to backfill + refuses to run if multi-row submission_codes have crept
back in (B.0 found zero; the assertion guards against a future
re-archival or rule-editor bug). POST pass asserts the four
invariants — procedural_events count, sequencing_rules 1:1,
legal_sources distinct-citation match, FK integrity — and RAISE
EXCEPTIONs on any mismatch so the transaction rolls back cleanly.
Design deviations from §4.1 (documented in the migration header):
- procedural_events.event_kind is NULLABLE. 89 live rules have NULL
event_type today (structural / parent-only rows in the proceeding
tree). Tightening to NOT NULL with 'other' fallback would lose
semantics; a later slice can do it after reclassification.
- legal_sources.pretty_de / pretty_en are NULLABLE. Materialising them
requires the Go-side legalSourcePretty(); deferred to a Go-driven
slice. Read path keeps computing them from the citation in the
meantime.
- submission_drafts is NOT modified (instruction scope is explicit:
tables + deadlines columns only).
Down migration: drops the two deadlines columns first, then
sequencing_rules → procedural_events → legal_sources in FK-safe
order. No data loss possible (deadline_rules is the source of truth
through B.3).
Test: internal/db/migration_136_test.go restates the four
invariants in Go so they survive PL/pgSQL refactors. Skipped without
TEST_DATABASE_URL.
Verified on live (read-only): 153 distinct codes + 78 distinct
synthetic-code candidates = 231 = deadline_rules row count. 87
distinct legal_sources. Zero 8-hex synthetic-code collisions in the
live UUIDs.
Hard-stop: B.2 dual-write requires explicit m greenlight before
RuleEditorService starts writing to the new tables. B.4 destructive
drop additionally requires m's downtime window + a
paliad.deadline_rules_pre_<N> snapshot in the same migration.
135 lines
5.1 KiB
Go
135 lines
5.1 KiB
Go
// Slice B.1 (t-paliad-273) — migration 136 backfill invariants.
|
|
//
|
|
// The dry-run gate (migrate_test.go: TestMigrations_DryRun) catches
|
|
// migrations that crash on apply, but it rolls back inside its own
|
|
// transaction — the post-state assertions in mig 136's PL/pgSQL block
|
|
// run, but a future refactor of those assertions might forget a check
|
|
// or introduce a silent count drift. This test layers a Go-side
|
|
// invariant check on top so the contract is restated in test code,
|
|
// outside the PL/pgSQL block, against the resulting tables.
|
|
//
|
|
// Skipped without TEST_DATABASE_URL, same pattern as
|
|
// internal/services/submission_codes_shape_test.go.
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"testing"
|
|
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
// TestMigration136_BackfillInvariants applies every embedded migration
|
|
// (which lands mig 136 along the way) and then asserts the four
|
|
// invariants the B.1 design + B.0 findings nailed down:
|
|
//
|
|
// 1. procedural_events row count = (distinct submission_codes in
|
|
// deadline_rules) + (deadline_rules with NULL submission_code).
|
|
// Codes-bearing branch is 1:1 per the B.0 audit (no multi-row
|
|
// codes since the _archived_litigation.* removal); the NULL
|
|
// branch gets one synthetic procedural_event per rule.
|
|
// 2. sequencing_rules row count = deadline_rules row count (1:1).
|
|
// 3. legal_sources row count = distinct legal_source in
|
|
// deadline_rules (NULL excluded).
|
|
// 4. every sequencing_rules row's procedural_event_id resolves to a
|
|
// procedural_events row (NOT NULL FK already enforces this at the
|
|
// DB level — this test catches a future relaxation of the FK).
|
|
// 5. no two synthetic codes collide (covered by the UNIQUE on
|
|
// procedural_events.code; restated here for documentation).
|
|
//
|
|
// The test is robust against corpus size — it derives all expected
|
|
// counts from the live deadline_rules state, so a scratch DB with 0
|
|
// rules trivially passes, and a prod-shaped scratch DB exercises the
|
|
// real invariants.
|
|
func TestMigration136_BackfillInvariants(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping mig 136 invariant test")
|
|
}
|
|
if err := ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
|
|
conn, err := sql.Open("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
ctx := context.Background()
|
|
|
|
var (
|
|
drTotal, drCodesDistinct, drCodesNull, drLegalDistinct int
|
|
peTotal, srTotal, lsTotal int
|
|
orphanPE, dupSynthetic int
|
|
)
|
|
|
|
mustQ := func(label, q string, dst *int) {
|
|
t.Helper()
|
|
if err := conn.QueryRowContext(ctx, q).Scan(dst); err != nil {
|
|
t.Fatalf("%s: %v", label, err)
|
|
}
|
|
}
|
|
|
|
mustQ("dr_total", `SELECT COUNT(*) FROM paliad.deadline_rules`, &drTotal)
|
|
mustQ("dr_codes_distinct",
|
|
`SELECT COUNT(DISTINCT submission_code) FROM paliad.deadline_rules WHERE submission_code IS NOT NULL`,
|
|
&drCodesDistinct)
|
|
mustQ("dr_codes_null",
|
|
`SELECT COUNT(*) FROM paliad.deadline_rules WHERE submission_code IS NULL`,
|
|
&drCodesNull)
|
|
mustQ("dr_legal_distinct",
|
|
`SELECT COUNT(DISTINCT legal_source) FROM paliad.deadline_rules WHERE legal_source IS NOT NULL`,
|
|
&drLegalDistinct)
|
|
mustQ("pe_total", `SELECT COUNT(*) FROM paliad.procedural_events`, &peTotal)
|
|
mustQ("sr_total", `SELECT COUNT(*) FROM paliad.sequencing_rules`, &srTotal)
|
|
mustQ("ls_total", `SELECT COUNT(*) FROM paliad.legal_sources`, &lsTotal)
|
|
|
|
// Invariant 1: procedural_events = distinct_codes + null_codes
|
|
wantPE := drCodesDistinct + drCodesNull
|
|
if peTotal != wantPE {
|
|
t.Errorf("procedural_events count mismatch: got %d, want %d (distinct codes=%d + null-code rules=%d)",
|
|
peTotal, wantPE, drCodesDistinct, drCodesNull)
|
|
}
|
|
|
|
// Invariant 2: sequencing_rules 1:1 with deadline_rules
|
|
if srTotal != drTotal {
|
|
t.Errorf("sequencing_rules count mismatch: got %d, want %d (1:1 with deadline_rules)",
|
|
srTotal, drTotal)
|
|
}
|
|
|
|
// Invariant 3: legal_sources = distinct legal_source
|
|
if lsTotal != drLegalDistinct {
|
|
t.Errorf("legal_sources count mismatch: got %d, want %d (distinct legal_source)",
|
|
lsTotal, drLegalDistinct)
|
|
}
|
|
|
|
// Invariant 4: every sequencing_rules.procedural_event_id resolves
|
|
mustQ("orphan_pe", `
|
|
SELECT COUNT(*)
|
|
FROM paliad.sequencing_rules sr
|
|
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
|
WHERE pe.id IS NULL`, &orphanPE)
|
|
if orphanPE != 0 {
|
|
t.Errorf("FK integrity violated: %d sequencing_rules row(s) have no resolving procedural_event_id", orphanPE)
|
|
}
|
|
|
|
// Invariant 5: no duplicate synthetic codes
|
|
mustQ("dup_synthetic", `
|
|
SELECT COUNT(*) FROM (
|
|
SELECT code FROM paliad.procedural_events
|
|
WHERE code LIKE 'null.%'
|
|
GROUP BY code
|
|
HAVING COUNT(*) > 1
|
|
) d`, &dupSynthetic)
|
|
if dupSynthetic != 0 {
|
|
t.Errorf("synthetic code uniqueness violated: %d duplicate(s) under 'null.%%' prefix", dupSynthetic)
|
|
}
|
|
|
|
t.Logf("mig 136 invariants OK: deadline_rules=%d, procedural_events=%d (=%d+%d), "+
|
|
"sequencing_rules=%d, legal_sources=%d (distinct legal_source=%d)",
|
|
drTotal, peTotal, drCodesDistinct, drCodesNull, srTotal, lsTotal, drLegalDistinct)
|
|
}
|