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.
245 lines
8.4 KiB
Go
245 lines
8.4 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
// Slice 11b orphan-resolution flow (t-paliad-192).
|
|
//
|
|
// Slice 10 (mig 089) staged the legacy paliad.deadlines rows that the
|
|
// fuzzy-match backfill couldn't bind uniquely to a deadline_rule. This
|
|
// file surfaces those rows to the admin rule-editor UI so a human can
|
|
// pick the right rule from the candidate list and write rule_id back
|
|
// onto the deadline.
|
|
//
|
|
// The methods sit on RuleEditorService because the orphan flow is part
|
|
// of the same admin surface and shares the same audit semantics — the
|
|
// resolved_rule_id + resolved_at pair on the staging row IS the audit
|
|
// trail. No new DB trigger needed; the staging table doubles as the
|
|
// log of the legal-review pass per mig 089's COMMENT.
|
|
|
|
// ErrOrphanAlreadyResolved is returned when a resolve call hits a row
|
|
// whose resolved_at is already non-NULL. 409 Conflict in the handler so
|
|
// the editor can re-fetch and show the picker the other admin made.
|
|
var ErrOrphanAlreadyResolved = errors.New("orphan already resolved")
|
|
|
|
// ErrOrphanCandidateMismatch is returned when the editor picks a rule
|
|
// that is not in the staging row's candidate_rule_ids set. The list of
|
|
// candidates is the matcher's output and the only legal choice — to
|
|
// pick anything else, an admin should patch the deadline directly.
|
|
var ErrOrphanCandidateMismatch = errors.New("rule_id not in candidate set")
|
|
|
|
// OrphanCandidate is one suggested rule from the fuzzy matcher with the
|
|
// fields the editor needs to render the pick chip.
|
|
type OrphanCandidate struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
|
Name string `db:"name" json:"name"`
|
|
NameEN string `db:"name_en" json:"name_en"`
|
|
}
|
|
|
|
// Orphan is one row from paliad.deadline_rule_backfill_orphans hydrated
|
|
// with its candidate rule rows (joined from paliad.deadline_rules so
|
|
// the UI doesn't need a second round-trip per row).
|
|
type Orphan struct {
|
|
ID uuid.UUID `json:"id"`
|
|
DeadlineID uuid.UUID `json:"deadline_id"`
|
|
Title string `json:"title"`
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
ProceedingCode *string `json:"proceeding_code,omitempty"`
|
|
Reason string `json:"reason"`
|
|
CandidateCount int `json:"candidate_count"`
|
|
CandidateIDs []uuid.UUID `json:"candidate_ids"`
|
|
Candidates []OrphanCandidate `json:"candidates"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ProjectTitle *string `json:"project_title,omitempty"`
|
|
}
|
|
|
|
// ListOrphans returns unresolved staging rows newest-first. The fuzzy
|
|
// matcher inserted at most ~25 rows so a flat list is fine; pagination
|
|
// can be added later if the table ever grows past a screen.
|
|
func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
|
|
type row struct {
|
|
ID uuid.UUID `db:"id"`
|
|
DeadlineID uuid.UUID `db:"deadline_id"`
|
|
Title string `db:"title"`
|
|
ProjectID *uuid.UUID `db:"project_id"`
|
|
ProceedingCode *string `db:"proceeding_code"`
|
|
Reason string `db:"reason"`
|
|
CandidateCount int `db:"candidate_count"`
|
|
CandidateIDs pq.StringArray `db:"candidate_rule_ids"`
|
|
CreatedAt time.Time `db:"created_at"`
|
|
ProjectTitle *string `db:"project_title"`
|
|
}
|
|
var rows []row
|
|
if err := s.db.SelectContext(ctx, &rows, `
|
|
SELECT o.id, o.deadline_id, o.title, o.project_id, o.proceeding_code,
|
|
o.reason, o.candidate_count, o.candidate_rule_ids, o.created_at,
|
|
p.title AS project_title
|
|
FROM paliad.deadline_rule_backfill_orphans o
|
|
LEFT JOIN paliad.projects p ON p.id = o.project_id
|
|
WHERE o.resolved_at IS NULL
|
|
ORDER BY o.created_at DESC`); err != nil {
|
|
return nil, fmt.Errorf("list orphans: %w", err)
|
|
}
|
|
|
|
// Collect every candidate UUID, fetch the rule rows in one shot, then
|
|
// fan back out per orphan. Avoids N+1 SELECTs when the matcher
|
|
// produced ambiguous (≥ 2 candidates) hits.
|
|
idSet := map[uuid.UUID]bool{}
|
|
for _, r := range rows {
|
|
for _, sid := range r.CandidateIDs {
|
|
id, err := uuid.Parse(sid)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
idSet[id] = true
|
|
}
|
|
}
|
|
candidateByID := map[uuid.UUID]OrphanCandidate{}
|
|
if len(idSet) > 0 {
|
|
ids := make([]uuid.UUID, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
var cs []OrphanCandidate
|
|
uuidStrs := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
uuidStrs[i] = id.String()
|
|
}
|
|
if err := s.db.SelectContext(ctx, &cs, `
|
|
SELECT id, rule_code, name, name_en
|
|
FROM paliad.deadline_rules_unified
|
|
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
|
|
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
|
|
}
|
|
for _, c := range cs {
|
|
candidateByID[c.ID] = c
|
|
}
|
|
}
|
|
|
|
out := make([]Orphan, 0, len(rows))
|
|
for _, r := range rows {
|
|
cids := make([]uuid.UUID, 0, len(r.CandidateIDs))
|
|
cs := make([]OrphanCandidate, 0, len(r.CandidateIDs))
|
|
for _, sid := range r.CandidateIDs {
|
|
id, err := uuid.Parse(sid)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
cids = append(cids, id)
|
|
if c, ok := candidateByID[id]; ok {
|
|
cs = append(cs, c)
|
|
}
|
|
}
|
|
out = append(out, Orphan{
|
|
ID: r.ID,
|
|
DeadlineID: r.DeadlineID,
|
|
Title: r.Title,
|
|
ProjectID: r.ProjectID,
|
|
ProceedingCode: r.ProceedingCode,
|
|
Reason: r.Reason,
|
|
CandidateCount: r.CandidateCount,
|
|
CandidateIDs: cids,
|
|
Candidates: cs,
|
|
CreatedAt: r.CreatedAt,
|
|
ProjectTitle: r.ProjectTitle,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ResolveOrphan binds the orphan's deadline to the picked rule_id and
|
|
// flips resolved_at + resolved_rule_id on the staging row. Both writes
|
|
// land in the same tx; if either fails, the orphan stays open so the
|
|
// editor can retry.
|
|
//
|
|
// reason is captured into paliad.audit_reason so any future audit trigger
|
|
// on paliad.deadlines picks it up. As of Slice 11b there is no trigger
|
|
// on deadlines (see mig 089 COMMENT), but the session setting is cheap
|
|
// to maintain and future-proofs the call site.
|
|
func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUID, ruleID uuid.UUID, reason string) error {
|
|
if strings.TrimSpace(reason) == "" {
|
|
return ErrAuditReasonRequired
|
|
}
|
|
|
|
type orphanCheck struct {
|
|
DeadlineID uuid.UUID `db:"deadline_id"`
|
|
ResolvedAt *time.Time `db:"resolved_at"`
|
|
CandidateIDs pq.StringArray `db:"candidate_rule_ids"`
|
|
}
|
|
var oc orphanCheck
|
|
err := s.db.GetContext(ctx, &oc,
|
|
`SELECT deadline_id, resolved_at, candidate_rule_ids
|
|
FROM paliad.deadline_rule_backfill_orphans
|
|
WHERE id = $1`, orphanID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return fmt.Errorf("%w: orphan %s", ErrRuleNotFound, orphanID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("load orphan %s: %w", orphanID, err)
|
|
}
|
|
if oc.ResolvedAt != nil {
|
|
return ErrOrphanAlreadyResolved
|
|
}
|
|
inSet := false
|
|
for _, sid := range oc.CandidateIDs {
|
|
id, parseErr := uuid.Parse(sid)
|
|
if parseErr == nil && id == ruleID {
|
|
inSet = true
|
|
break
|
|
}
|
|
}
|
|
if !inSet {
|
|
return ErrOrphanCandidateMismatch
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
if err := setAuditReasonTx(ctx, tx, reason); err != nil {
|
|
return err
|
|
}
|
|
|
|
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 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 sequencing_rule_id: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.deadline_rule_backfill_orphans
|
|
SET resolved_at = $1,
|
|
resolved_rule_id = $2
|
|
WHERE id = $3 AND resolved_at IS NULL`,
|
|
now, ruleID, orphanID,
|
|
); err != nil {
|
|
return fmt.Errorf("mark orphan resolved: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit resolve: %w", err)
|
|
}
|
|
return nil
|
|
}
|