Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
43de8f9c7b feat(verfahrensablauf): URL state hybrid — filter chips in URL, scenario in localStorage (t-paliad-308, m/paliad#137)
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
Splits /tools/verfahrensablauf persisted state into two namespaces:

URL params (timeline kind — paste-able, shareable, refresh-resistant):
  proceeding, side, target, trigger_date

localStorage `paliad.verfahrensablauf.scenario.*` (per-user tweaks
that should never leak into a shared link):
  event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
  show_hidden

Hydration order: URL wins. localStorage fills the rest. A shared link
reproduces the timeline kind but each user sees their own scenario
state.

Added trigger_date and proceeding to URL (previously DOM-only — a
refresh lost the date and the proceeding tile). Moved event_choices
and show_hidden from URL to localStorage (verbose, per-user). Added
court_id + flag persistence to localStorage (previously DOM-only).

New pure module `views/verfahrensablauf-state.ts` owns the URL +
localStorage contract: URL parsers + encoder (`applyFiltersToSearch`),
scenario read/write helpers, and a `hydrate()` orchestrator that
documents the URL→localStorage order. 31 unit tests pin the contract,
including the "shared link doesn't leak scenario state" invariant.

Anti-patterns explicitly avoided:
- No ?appellant= resurrection (#132 removed it; engine reads from
  the single side picker for role-swap proceedings).
- trigger_date in URL not localStorage (a shared link must reproduce
  the same dated timeline).
- URL→localStorage hydration order is contract; localStorage never
  overrides an explicit URL value.

Project-driven side-fill chip (?project=<id>) still overrides as
before — parseSideFromSearch is called before the project's our_side
is applied so an explicit ?side= still wins.

Build clean: `bun run build`, `bun test` (240 pass / 594 expect calls),
`go test ./...`, `go vet ./...`.
2026-05-26 18:45:00 +02:00
21 changed files with 1249 additions and 758 deletions

View File

@@ -12,6 +12,7 @@ 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/
@@ -339,11 +340,13 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// 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.
// 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)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
@@ -24,10 +23,27 @@ import {
import {
attachEventCardChoices,
reseedChips,
currentChoices,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
type AppealTarget,
type Side,
type StorageLike,
applyFiltersToSearch,
makeMemoryStorage,
parseAppealTargetFromSearch,
parseProceedingFromSearch,
parseSideFromSearch,
parseTriggerDateFromSearch,
readBoolFlag,
readCourtId,
readEventChoices,
writeBoolFlag,
writeCourtId,
writeEventChoices,
} from "./views/verfahrensablauf-state";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
@@ -119,21 +135,13 @@ const ROLE_LABELS: Record<string, RoleLabels> = {
// Proceedings that surface the appeal-target chip group. Currently
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
// can opt in by adding the code here.
//
// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the
// pure URL parser and this page share the same canonical list.
const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl.unified",
]);
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
// in sync with pkg/litigationplanner/types.go AppealTargets).
const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}
@@ -142,16 +150,35 @@ function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
// Scenario storage — real localStorage in the browser, in-memory
// fallback when localStorage throws (private mode, disabled storage,
// etc.). All scenario writes go through this single handle so a
// failure mode is isolated to one try/catch path.
const scenarioStorage: StorageLike = makeScenarioStorage();
function makeScenarioStorage(): StorageLike {
try {
const probe = "__paliad_va_probe__";
window.localStorage.setItem(probe, "1");
window.localStorage.removeItem(probe);
return window.localStorage;
} catch {
return makeMemoryStorage();
}
}
function writeSideToURL(s: Side) {
// URL writers — all four chip params route through this single helper
// so the canonical query-string shape (no empty values, no trailing
// `?`) is enforced in one place.
function applyURLFilters(filters: {
proceeding?: string;
side?: Side;
target?: AppealTarget;
triggerDate?: string;
}): void {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
const nextSearch = applyFiltersToSearch(url.search, filters);
window.history.replaceState(null, "", url.pathname + nextSearch + url.hash);
}
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
@@ -181,26 +208,6 @@ function applyRoleLabels(proceedingType: string) {
}
}
// Slice B1 — appeal-target URL state. Empty string = no target picked
// (the row is hidden because the proceeding isn't an appeal). Any
// other value must be one of APPEAL_TARGETS; unknown values are
// rejected by readAppealTargetFromURL so a stale link can't break
// the engine filter.
function readAppealTargetFromURL(): AppealTarget {
const raw = new URLSearchParams(window.location.search).get("target") || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
function writeAppealTargetToURL(t: AppealTarget) {
const url = new URL(window.location.href);
if (t === "") url.searchParams.delete("target");
else url.searchParams.set("target", t);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Default target on first picker entry into upc.apl. m: Endentscheidung
// is the most-common appeal target; the chip group also defaults
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
@@ -217,54 +224,18 @@ const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
// Per-event-card choices (t-paliad-265). Unbound on this page (no
// project context), so persistence is URL-only via `?event_choices=`.
// Format: comma-separated `submission_code:kind=value` tuples. Same
// idiom as `?side=` + `?appellant=`.
let perCardChoices: EventChoice[] = [];
// project context). Persistence moved from URL → localStorage under
// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these
// are per-user scenario tweaks, not the timeline kind, so a shared
// link should NOT leak them into the recipient's view.
let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage);
function readChoicesFromURL(): EventChoice[] {
const raw = new URLSearchParams(window.location.search).get("event_choices");
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
function writeChoicesToURL(choices: EventChoice[]) {
const url = new URL(window.location.href);
if (choices.length === 0) {
url.searchParams.delete("event_choices");
} else {
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
url.searchParams.set("event_choices", enc);
}
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
// Show-hidden toggle (t-paliad-290 / m/paliad#122). When ON, the
// calculator re-surfaces cards whose submission_code is in the active
// skipRules set; they render faded with a "Wieder einblenden" chip.
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
// the visibility. Default OFF — m's not asking to see hidden by
// default, just to be able to.
function readShowHiddenFromURL(): boolean {
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
}
function writeShowHiddenToURL(on: boolean) {
const url = new URL(window.location.href);
if (on) url.searchParams.set("show_hidden", "1");
else url.searchParams.delete("show_hidden");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
let showHidden = readShowHiddenFromURL();
// Persistence moved from URL → localStorage (t-paliad-308) — it's a
// per-user UX preference, not scenario state worth sharing in a link.
let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden);
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -568,7 +539,7 @@ function syncInfAmendEnabled() {
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
const nextType = btn.dataset.code || "";
@@ -578,20 +549,76 @@ function selectProceeding(btn: HTMLButtonElement) {
if (selectedType !== nextType) clearAnchorOverrides();
selectedType = nextType;
// Persist the picked proceeding to ?proceeding= so a refresh / shared
// link reproduces the same tile. writeURL=false on the load-time
// hydration path so we don't churn history.replaceState when the
// URL already carries the canonical value.
if (opts.writeURL !== false) {
applyURLFilters({ proceeding: selectedType });
}
// Trigger-event label fires from the calc response (root rule).
// Until step 3 renders, fall back to an em-dash placeholder.
lastResponse = null;
syncTriggerEventLabel();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppealTargetRowVisibility();
applyRoleLabels(selectedType);
// Restore flags from localStorage BEFORE the initial calc so the
// first /api/tools/fristenrechner POST already carries the user's
// stored flag state. Court_id is async (populateCourtPicker fetches
// courts from the API) so it restores via the .then() below + a
// follow-up recalc when the picker is ready.
restoreFlagsForProceeding();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
showStep(2);
scheduleCalc(0);
void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => {
if (restoreCourtForProceeding()) scheduleCalc(0);
});
}
// restoreFlagsForProceeding seeds the proceeding-specific flag
// checkboxes from localStorage. Mirrors syncFlagRows in scope — only
// flags currently visible for the active proceeding are meaningful
// (the hidden checkboxes still write to localStorage if toggled, but
// that's impossible because they're not in the DOM as visible
// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend
// gating after the restore.
function restoreFlagsForProceeding(): void {
const flagPairs: Array<[string, string]> = [
["ccr-flag", SCENARIO_KEYS.ccr],
["inf-amend-flag", SCENARIO_KEYS.infAmend],
["rev-amend-flag", SCENARIO_KEYS.revAmend],
["rev-cci-flag", SCENARIO_KEYS.revCci],
];
for (const [domId, storageKey] of flagPairs) {
const cb = document.getElementById(domId) as HTMLInputElement | null;
if (!cb) continue;
cb.checked = readBoolFlag(scenarioStorage, storageKey);
}
syncInfAmendEnabled();
}
// restoreCourtForProceeding tries to apply the localStorage court_id
// to the picker after populateCourtPicker resolves. Returns true iff
// a value actually changed (so the caller can schedule a follow-up
// calc). Skips silently when the picker is hidden, the stored ID isn't
// in the options list (court rotated since last visit), or the picker
// already happens to be on the stored value.
function restoreCourtForProceeding(): boolean {
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
const storedCourtId = readCourtId(scenarioStorage);
if (!courtPicker || !storedCourtId) return false;
const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId);
if (!has) return false;
if (courtPicker.value === storedCourtId) return false;
courtPicker.value = storedCourtId;
return true;
}
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
@@ -606,7 +633,7 @@ function syncAppealTargetRowVisibility() {
row.style.display = visible ? "" : "none";
if (!visible && currentAppealTarget !== "") {
currentAppealTarget = "";
writeAppealTargetToURL("");
applyURLFilters({ target: "" });
syncRadioGroup("appeal-target", "endentscheidung");
}
}
@@ -720,11 +747,11 @@ function showSideRadioCluster() {
// already chosen and we never overwrite. When we do prefill, write the
// derived side to the URL so reload + back/forward round-trip cleanly.
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
if (readSideFromURL() !== null) return;
if (parseSideFromSearch(window.location.search) !== null) return;
const next = ourSideToSide(os);
if (next === null) return;
currentSide = next;
writeSideToURL(next);
applyURLFilters({ side: next });
syncRadioGroup("side", next);
sidePrefilledFromProject = true;
renderSideChip(next);
@@ -793,8 +820,8 @@ function initViewToggle() {
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppealTarget = readAppealTargetFromURL();
currentSide = parseSideFromSearch(window.location.search);
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
syncSideHintVisibility();
@@ -804,7 +831,7 @@ function initPerspectiveControls() {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
applyURLFilters({ side: currentSide });
syncSideHintVisibility();
if (lastResponse) renderResults(lastResponse);
});
@@ -822,7 +849,7 @@ function initPerspectiveControls() {
} else {
currentAppealTarget = "";
}
writeAppealTargetToURL(currentAppealTarget);
applyURLFilters({ target: currentAppealTarget });
scheduleCalc(0);
});
});
@@ -844,28 +871,57 @@ document.addEventListener("DOMContentLoaded", () => {
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
if (dateInput) {
dateInput.addEventListener("change", () => scheduleCalc());
dateInput.addEventListener("input", () => scheduleCalc());
// Hydrate trigger_date from URL on first paint so a refresh /
// shared link reproduces the same dated timeline. URL wins over
// the verfahrensablauf.tsx today-default that the <input> renders
// with. parseTriggerDateFromSearch validates the shape so a
// malformed link silently falls back to the today-default.
const urlDate = parseTriggerDateFromSearch(window.location.search);
if (urlDate) dateInput.value = urlDate;
const persistDate = () => {
applyURLFilters({ triggerDate: dateInput.value });
};
dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); });
dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); });
dateInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
});
}
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
if (courtPicker) courtPicker.addEventListener("change", () => {
writeCourtId(scenarioStorage, courtPicker.value);
scheduleCalc(0);
});
// Flag-checkbox listeners — each flip triggers a fresh calc so the
// timeline re-projects with the new gating. ccr-flag additionally
// enables/disables the nested inf-amend row.
// enables/disables the nested inf-amend row. Each flip also writes
// through to localStorage so the choice survives a reload (URL stays
// clean; flags are scenario state, not filter chips — t-paliad-308).
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked);
syncInfAmendEnabled();
// Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled).
// Mirror that into storage so the next reload doesn't repopulate a
// disabled checkbox as checked.
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const flagStorageKeys: Record<string, string> = {
"inf-amend-flag": SCENARIO_KEYS.infAmend,
"rev-amend-flag": SCENARIO_KEYS.revAmend,
"rev-cci-flag": SCENARIO_KEYS.revCci,
};
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
if (cb) cb.addEventListener("change", () => {
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
scheduleCalc(0);
});
}
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
@@ -909,16 +965,17 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + recalc (the backend reshapes the response — we can't just
// re-render lastResponse since the hidden rows aren't in it when the
// toggle was OFF).
// t-paliad-290 — show-hidden toggle. Hydrated from localStorage at
// module load (showHidden); each flip writes back to localStorage
// and triggers a recalc (the backend reshapes the response — we
// can't just re-render lastResponse since the hidden rows aren't
// in it when the toggle was OFF).
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
if (showHiddenCb) {
showHiddenCb.checked = showHidden;
showHiddenCb.addEventListener("change", () => {
showHidden = showHiddenCb.checked;
writeShowHiddenToURL(showHidden);
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
scheduleCalc(0);
});
}
@@ -926,11 +983,10 @@ document.addEventListener("DOMContentLoaded", () => {
initViewToggle();
initPerspectiveControls();
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
// mutate the in-memory list + URL, then trigger a recalc. The
// popover module owns the popover lifecycle; this page owns the
// recalc + URL plumbing.
perCardChoices = readChoicesFromURL();
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
// the recipient's per-card tweaks. The popover module owns the
// popover lifecycle; this page owns the recalc + storage plumbing.
const timelineEl = document.getElementById("timeline-container");
if (timelineEl) {
attachEventCardChoices({
@@ -941,14 +997,14 @@ document.addEventListener("DOMContentLoaded", () => {
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
);
perCardChoices.push(choice);
writeChoicesToURL(perCardChoices);
writeEventChoices(scenarioStorage, perCardChoices);
scheduleCalc(0);
},
remove: (submissionCode, kind) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
);
writeChoicesToURL(perCardChoices);
writeEventChoices(scenarioStorage, perCardChoices);
scheduleCalc(0);
},
});
@@ -984,8 +1040,31 @@ document.addEventListener("DOMContentLoaded", () => {
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline
// immediately on landing — matches /tools/fristenrechner behaviour.
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
// Pre-select the proceeding tile. URL wins: if ?proceeding= is set
// and points at a known tile, that tile is selected without rewriting
// the URL. Otherwise fall back to the first tile so users see a
// timeline immediately on landing — matches /tools/fristenrechner
// behaviour. The auto-pick does NOT write the URL so the default
// landing stays clean (`?proceeding=` only appears once the user
// makes an explicit choice). (t-paliad-308 / m/paliad#137)
const urlProceeding = parseProceedingFromSearch(window.location.search);
let initialBtn: HTMLButtonElement | null = null;
let urlHit = false;
if (urlProceeding) {
initialBtn = document.querySelector<HTMLButtonElement>(
`.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
);
urlHit = initialBtn !== null;
}
if (!initialBtn) {
initialBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
}
if (initialBtn) {
// writeURL=false when the URL either already carries this code
// (no churn) or has no proceeding (auto-default → don't pollute
// the clean URL). Only an unknown / stale ?proceeding= triggers
// a rewrite so the URL converges on the resolved tile.
const writeURL = urlProceeding !== "" && !urlHit;
selectProceeding(initialBtn, { writeURL });
}
});

View File

@@ -0,0 +1,309 @@
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
//
// The contract:
// 1. URL params (proceeding, side, target, trigger_date) define which
// timeline kind the user is looking at — paste-able, shareable,
// refresh-resistant.
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
// per-user scenario tweaks (event_choices, court_id, flags,
// show_hidden) — these never leak into a shared link.
// 3. On hydrate, URL wins. localStorage fills the rest.
import { describe, expect, test } from "bun:test";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
SCENARIO_PREFIX,
URL_KEYS,
applyFiltersToSearch,
hydrate,
makeMemoryStorage,
parseAppealTargetFromSearch,
parseProceedingFromSearch,
parseSideFromSearch,
parseTriggerDateFromSearch,
readBoolFlag,
readCourtId,
readEventChoices,
readScenario,
writeBoolFlag,
writeCourtId,
writeEventChoices,
} from "./verfahrensablauf-state";
describe("URL parsers — filter chips", () => {
test("parseProceedingFromSearch returns empty string when absent", () => {
expect(parseProceedingFromSearch("")).toBe("");
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
});
test("parseProceedingFromSearch echoes the raw value", () => {
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
});
test("parseSideFromSearch validates the enum", () => {
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
expect(parseSideFromSearch("?side=neither")).toBe(null);
expect(parseSideFromSearch("")).toBe(null);
});
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
for (const t of APPEAL_TARGETS) {
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
}
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
expect(parseAppealTargetFromSearch("")).toBe("");
});
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
});
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
expect(parseTriggerDateFromSearch("")).toBe("");
});
});
describe("URL encoder — applyFiltersToSearch", () => {
test("empty filters preserve the existing query string", () => {
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
});
test("setting a filter writes the canonical key", () => {
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
});
test("setting null / empty / undefined deletes the key", () => {
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
});
test("invalid trigger_date is deleted (never written as-is)", () => {
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
});
test("setting all four filters together emits all four keys", () => {
const out = applyFiltersToSearch("", {
proceeding: "upc.apl.unified",
side: "defendant",
target: "endentscheidung",
triggerDate: "2026-05-26",
});
expect(out).toContain("proceeding=upc.apl.unified");
expect(out).toContain("side=defendant");
expect(out).toContain("target=endentscheidung");
expect(out).toContain("trigger_date=2026-05-26");
});
test("other params (project, view) are preserved", () => {
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
expect(out).toContain("project=abc");
expect(out).toContain("view=timeline");
expect(out).toContain("side=claimant");
});
test("absent keys in the filter object don't touch existing URL values", () => {
// Only updating side — proceeding should be untouched.
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
expect(out).toContain("proceeding=upc.inf.cfi");
expect(out).toContain("side=claimant");
});
});
describe("URL round-trip — encode then parse yields the same value", () => {
test("proceeding", () => {
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
});
test("side", () => {
const enc = applyFiltersToSearch("", { side: "defendant" });
expect(parseSideFromSearch(enc)).toBe("defendant");
});
test("target", () => {
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
});
test("trigger_date", () => {
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
});
});
describe("Scenario localStorage helpers", () => {
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
for (const key of Object.values(SCENARIO_KEYS)) {
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
}
});
test("readEventChoices returns [] on empty storage", () => {
const s = makeMemoryStorage();
expect(readEventChoices(s)).toEqual([]);
});
test("writeEventChoices + readEventChoices round-trip", () => {
const s = makeMemoryStorage();
const choices = [
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
];
writeEventChoices(s, choices);
expect(readEventChoices(s)).toEqual(choices);
});
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
const s = makeMemoryStorage();
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
writeEventChoices(s, []);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
});
test("readEventChoices ignores unknown choice_kind values", () => {
const s = makeMemoryStorage();
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
expect(readEventChoices(s)).toEqual([
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
]);
});
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
const s = makeMemoryStorage();
expect(readCourtId(s)).toBe("");
writeCourtId(s, "UPC-LD-MUC");
expect(readCourtId(s)).toBe("UPC-LD-MUC");
});
test("writeCourtId('') removes the key", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
writeCourtId(s, "");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
});
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
const s = makeMemoryStorage();
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
});
test("readScenario returns all fields defaulted on empty storage", () => {
const s = makeMemoryStorage();
expect(readScenario(s)).toEqual({
eventChoices: [],
courtId: "",
ccr: false,
infAmend: false,
revAmend: false,
revCci: false,
showHidden: false,
});
});
});
describe("Hydration order — URL wins, localStorage fills the rest", () => {
test("URL fills filter chips, localStorage fills scenario state", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
const out = hydrate(
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
s,
);
// URL-sourced
expect(out.proceeding).toBe("upc.inf.cfi");
expect(out.side).toBe("defendant");
expect(out.target).toBe("endentscheidung");
expect(out.triggerDate).toBe("2026-05-26");
// localStorage-sourced
expect(out.courtId).toBe("UPC-LD-MUC");
expect(out.showHidden).toBe(true);
expect(out.ccr).toBe(true);
});
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
const out = hydrate("", s);
expect(out.proceeding).toBe("");
expect(out.side).toBe(null);
expect(out.target).toBe("");
expect(out.triggerDate).toBe("");
expect(out.courtId).toBe("UPC-LD-MUC");
});
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
const s = makeMemoryStorage();
const out = hydrate(
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
s,
);
expect(out.proceeding).toBe("upc.apl.unified");
expect(out.side).toBe("claimant");
expect(out.target).toBe("anordnung");
expect(out.triggerDate).toBe("2026-07-01");
expect(out.courtId).toBe("");
expect(out.eventChoices).toEqual([]);
expect(out.showHidden).toBe(false);
});
test("a shared link doesn't leak the recipient's scenario state in", () => {
// Two storages: m's (loaded with court + flags) and a recipient's
// (empty). The same URL should reproduce filter chips identically
// but leave each user's scenario state untouched.
const mStorage = makeMemoryStorage();
writeCourtId(mStorage, "UPC-LD-MUC");
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
const recipientStorage = makeMemoryStorage();
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
const mView = hydrate(sharedURL, mStorage);
const recipientView = hydrate(sharedURL, recipientStorage);
// Filter chips identical
expect(mView.proceeding).toBe(recipientView.proceeding);
expect(mView.side).toBe(recipientView.side);
expect(mView.triggerDate).toBe(recipientView.triggerDate);
// Scenario state diverges — recipient sees defaults
expect(mView.courtId).toBe("UPC-LD-MUC");
expect(recipientView.courtId).toBe("");
expect(mView.ccr).toBe(true);
expect(recipientView.ccr).toBe(false);
});
});
describe("URL key constants match the documented contract", () => {
test("URL_KEYS uses the spec'd snake_case names", () => {
expect(URL_KEYS.proceeding).toBe("proceeding");
expect(URL_KEYS.side).toBe("side");
expect(URL_KEYS.target).toBe("target");
expect(URL_KEYS.triggerDate).toBe("trigger_date");
});
});

View File

@@ -0,0 +1,263 @@
// /tools/verfahrensablauf URL + scenario-localStorage state contract
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
// two namespaces:
//
// URL params (filter chips — the timeline kind the user is looking
// at; paste-able, shareable, refresh-resistant):
// proceeding, side, target, trigger_date
//
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
// scenario inputs — the noisy parts that don't belong in a URL):
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
// show_hidden
//
// Hydration order: URL wins. On page load, URL fills the filter chips;
// localStorage fills the rest. Filter-chip changes write to URL only.
// Scenario changes write to localStorage only. A shared link from a
// colleague reproduces the timeline kind (proceeding + side + target +
// trigger_date) but never leaks the recipient's court / flag /
// event_choices state in.
//
// All helpers in this module are pure: they take a search string (or a
// StorageLike) and return values, no DOM. The wiring in
// ../verfahrensablauf.ts mounts them onto window.location +
// window.localStorage at runtime.
import type { EventChoice, ChoiceKind } from "./event-card-choices";
// ----- URL params (filter chips) ----------------------------------
export type Side = "claimant" | "defendant" | null;
export const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
export const URL_KEYS = {
proceeding: "proceeding",
side: "side",
target: "target",
triggerDate: "trigger_date",
} as const;
// parseProceedingFromSearch extracts the proceeding code. Returns ""
// if absent. No validation against the proceeding registry — that's
// the caller's job (an unknown code from a stale link should leave
// the first-tile auto-select fallback running).
export function parseProceedingFromSearch(search: string): string {
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
return v ?? "";
}
export function parseSideFromSearch(search: string): Side {
const raw = new URLSearchParams(search).get(URL_KEYS.side);
return raw === "claimant" || raw === "defendant" ? raw : null;
}
export function parseAppealTargetFromSearch(search: string): AppealTarget {
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
// parseTriggerDateFromSearch validates the ISO-date shape so a
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
// only. Round-tripped against Date to reject 2026-02-30 etc.
export function parseTriggerDateFromSearch(search: string): string {
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
const d = new Date(raw + "T00:00:00Z");
if (Number.isNaN(d.getTime())) return "";
if (d.toISOString().slice(0, 10) !== raw) return "";
return raw;
}
// applyFiltersToSearch produces the canonical query string for the
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
// preserved verbatim. Empty values are deleted, never written as
// empty string, so the URL stays clean on the default.
export function applyFiltersToSearch(
search: string,
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
): string {
const params = new URLSearchParams(search);
if ("proceeding" in filters) {
if (filters.proceeding && filters.proceeding !== "") {
params.set(URL_KEYS.proceeding, filters.proceeding);
} else {
params.delete(URL_KEYS.proceeding);
}
}
if ("side" in filters) {
if (filters.side === "claimant" || filters.side === "defendant") {
params.set(URL_KEYS.side, filters.side);
} else {
params.delete(URL_KEYS.side);
}
}
if ("target" in filters) {
if (filters.target && filters.target !== "") {
params.set(URL_KEYS.target, filters.target);
} else {
params.delete(URL_KEYS.target);
}
}
if ("triggerDate" in filters) {
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
params.set(URL_KEYS.triggerDate, filters.triggerDate);
} else {
params.delete(URL_KEYS.triggerDate);
}
}
const s = params.toString();
return s ? `?${s}` : "";
}
// ----- localStorage (scenario state) ------------------------------
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
export const SCENARIO_KEYS = {
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
courtId: `${SCENARIO_PREFIX}.court_id`,
ccr: `${SCENARIO_PREFIX}.ccr`,
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
revCci: `${SCENARIO_PREFIX}.rev_cci`,
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
} as const;
// StorageLike is the tiny subset of the Web Storage API the scenario
// helpers actually use. Lets the tests pass a Map-backed fake without
// pulling in a full localStorage polyfill.
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
// readEventChoices is forgiving: malformed tuples or unknown
// choice_kinds are dropped silently. Same shape as the legacy URL
// codec (comma-separated `submission_code:kind=value`).
export function readEventChoices(storage: StorageLike): EventChoice[] {
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
if (choices.length === 0) {
storage.removeItem(SCENARIO_KEYS.eventChoices);
return;
}
const enc = choices
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
.join(",");
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
}
// readCourtId / writeCourtId — empty string == no court picked. The
// "" value is stored as a removed key, not an empty string entry, so
// reading it back yields null rather than "".
export function readCourtId(storage: StorageLike): string {
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
}
export function writeCourtId(storage: StorageLike, courtId: string): void {
if (courtId === "") {
storage.removeItem(SCENARIO_KEYS.courtId);
return;
}
storage.setItem(SCENARIO_KEYS.courtId, courtId);
}
// Boolean flags — "1" / "0" string encoding, removeItem on default
// (false for flags, also false for show_hidden) so the storage stays
// uncluttered on a fresh page.
export function readBoolFlag(storage: StorageLike, key: string): boolean {
return storage.getItem(key) === "1";
}
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
if (on) storage.setItem(key, "1");
else storage.removeItem(key);
}
// Read all scenario state in one call — convenience for the page's
// load-time hydration. Caller decides whether to apply each field
// (e.g. court_id is proceeding-specific; the page may discard the
// stored value if the active proceeding doesn't expose a court row).
export interface ScenarioState {
eventChoices: EventChoice[];
courtId: string;
ccr: boolean;
infAmend: boolean;
revAmend: boolean;
revCci: boolean;
showHidden: boolean;
}
export function readScenario(storage: StorageLike): ScenarioState {
return {
eventChoices: readEventChoices(storage),
courtId: readCourtId(storage),
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
};
}
// ----- URL → localStorage hydration order -------------------------
// The page's load-time contract: read URL filters, then read
// scenario state from localStorage. URL wins on conflict — but the
// only field that can conflict is none of them today (URL owns
// proceeding/side/target/trigger_date; localStorage owns the rest).
// The order matters for one edge case: if a future field migrates
// from URL → localStorage with overlap, the URL value MUST be honored.
export interface HydratedState extends ScenarioState {
proceeding: string;
side: Side;
target: AppealTarget;
triggerDate: string;
}
export function hydrate(search: string, storage: StorageLike): HydratedState {
const scenario = readScenario(storage);
return {
proceeding: parseProceedingFromSearch(search),
side: parseSideFromSearch(search),
target: parseAppealTargetFromSearch(search),
triggerDate: parseTriggerDateFromSearch(search),
...scenario,
};
}
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
// Not used by the runtime page (which mounts real localStorage), but
// kept here so test files have one well-known import.
export function makeMemoryStorage(): StorageLike {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
};
}

View File

@@ -1,7 +0,0 @@
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
--
-- Drops the view. The underlying paliad.sequencing_rules /
-- procedural_events / legal_sources tables are untouched (they own the
-- data — the view is just a projection).
DROP VIEW IF EXISTS paliad.deadline_rules_unified;

View File

@@ -1,122 +0,0 @@
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
--
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
-- paliad.legal_sources back into the legacy paliad.deadline_rules
-- column shape.
--
-- Why a view instead of rewriting every SELECT in Go:
--
-- - 19 read sites across 11 service files reference
-- paliad.deadline_rules. Rewriting each by hand multiplies the
-- opportunity for off-by-one bugs in the JOIN.
-- - The view has the same column names + types as the legacy table,
-- so the change in Go is a 1-token substitution per query
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
-- with no struct or scanner changes.
-- - When B.4 drops paliad.deadline_rules, this view stays — it
-- becomes the canonical legacy-shape reader for any code that
-- hasn't been migrated to direct sr/pe/ls reads.
--
-- Column mapping (per design §4.2):
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
-- choices_offered, applies_to_target, trigger_event_id,
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
-- published_at, is_active, created_at, updated_at, spawn_label
-- → from paliad.sequencing_rules
-- - submission_code → procedural_events.code
-- - name, name_en, description→ procedural_events
-- - event_type → procedural_events.event_kind (renamed)
-- - concept_id → procedural_events
-- - legal_source → legal_sources.citation (via legal_source_id FK)
--
-- The view is READ-ONLY by default. Writes still go to the underlying
-- tables — RuleEditorService is refactored in the same slice to write
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
-- (no new writes); the dual-write helper from B.2 is decommissioned.
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
-- inherits whatever value sr.primary_party carries; mig 136's backfill
-- set sr.primary_party = dr.primary_party so the canonical four-value
-- vocab is already in place. A later slice can add the same CHECK to
-- sequencing_rules itself.
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
SELECT
sr.id,
sr.proceeding_type_id,
sr.parent_id,
pe.code AS submission_code,
pe.name,
pe.name_en,
pe.description,
sr.primary_party,
pe.event_kind AS event_type,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.alt_rule_code,
sr.anchor_alt,
sr.combine_op,
sr.rule_code,
sr.deadline_notes,
sr.deadline_notes_en,
sr.sequence_order,
sr.is_spawn,
sr.spawn_label,
sr.spawn_proceeding_type_id,
sr.is_bilateral,
sr.is_court_set,
sr.priority,
sr.condition_expr,
pe.concept_id,
ls.citation AS legal_source,
sr.trigger_event_id,
sr.rule_codes,
sr.choices_offered,
sr.applies_to_target,
sr.lifecycle_state,
sr.draft_of,
sr.published_at,
sr.is_active,
sr.created_at,
sr.updated_at
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id;
COMMENT ON VIEW paliad.deadline_rules_unified IS
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
'sequencing_rules + procedural_events + legal_sources. Read-only — '
'writes go directly to the three underlying tables via '
'RuleEditorService. Survives B.4 destructive drop of '
'paliad.deadline_rules; the view will then be the only '
'legacy-shape reader.';
-- Post-apply integrity check: confirm the view's row count matches the
-- live sequencing_rules row count. A mismatch would indicate either a
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
-- whose procedural_event_id is NULL — but that column is NOT NULL on
-- the table so it can't happen). Belt-and-braces.
DO $$
DECLARE
v_view_count int;
v_sr_count int;
BEGIN
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
IF v_view_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
v_view_count, v_sr_count;
END IF;
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
v_view_count;
END $$;

View File

@@ -1,47 +0,0 @@
-- 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

@@ -1,334 +0,0 @@
-- 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

@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
pt.code AS proceeding_code,
pt.name AS proceeding_name,
pt.name_en AS proceeding_name_en
FROM paliad.deadline_rules_unified dr
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'

View File

@@ -264,14 +264,7 @@ 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"`
// 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"`
RuleID *uuid.UUID `db:"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

@@ -55,13 +55,13 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
if proceedingTypeID != nil {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
@@ -100,7 +100,7 @@ func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Contex
}
query, args, err := sqlx.In(
`SELECT dr.id AS rule_id, j.event_type_id
FROM paliad.deadline_rules_unified dr
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concept_event_types j
ON j.concept_id = dr.concept_id
@@ -152,7 +152,7 @@ func (s *DeadlineRuleService) GetRuleTree(ctx context.Context, proceedingTypeCod
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID); err != nil {
return nil, fmt.Errorf("list rules for %q: %w", proceedingTypeCode, err)
@@ -175,10 +175,10 @@ func (s *DeadlineRuleService) GetFullTimeline(ctx context.Context, proceedingTyp
var rules []models.DeadlineRule
err := s.db.SelectContext(ctx, &rules, `
WITH RECURSIVE tree AS (
SELECT * FROM paliad.deadline_rules_unified
SELECT * FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND parent_id IS NULL AND is_active = true
UNION ALL
SELECT dr.* FROM paliad.deadline_rules_unified dr
SELECT dr.* FROM paliad.deadline_rules dr
JOIN tree t ON dr.parent_id = t.id
WHERE dr.is_active = true
)
@@ -196,7 +196,7 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
@@ -264,7 +264,7 @@ func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEve
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE trigger_event_id = $1
AND is_active = true
ORDER BY sequence_order`, triggerEventID); err != nil {
@@ -292,7 +292,7 @@ func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids [
}
query, args, err := sqlx.In(
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id IN (?)
AND is_active = true
ORDER BY proceeding_type_id, sequence_order`, ids)
@@ -327,7 +327,7 @@ func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.
var rules []models.DeadlineRule
if err := s.db.SelectContext(ctx, &rules,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE concept_id = $1
AND is_active = true
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {

View File

@@ -65,13 +65,8 @@ 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, sequencing_rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, 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`
@@ -277,7 +272,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.sequencing_rule_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.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`
@@ -544,11 +539,7 @@ 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)
}
// 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)
appendSet("rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
@@ -594,13 +585,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.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.
// 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.
if input.RuleSet {
if err := syncDeadlineProceduralEventID(ctx, tx, deadlineID); err != nil {
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
return nil, err
}
}

View File

@@ -1,50 +1,392 @@
// Slice B.4 retirement of B.2 dual-write (t-paliad-305 / m/paliad#93).
// 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.
//
// 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.
// Contract:
//
// 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.
// - 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.
//
// The DualWriteDriftReport struct + HasDrift method are retired with
// the loop they served.
// 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.
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// 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).
// 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.
//
// 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 {
// 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 {
if _, err := tx.ExecContext(ctx, `
UPDATE paliad.deadlines d
SET procedural_event_id = (
SET sequencing_rule_id = d.rule_id,
procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.sequencing_rule_id
WHERE sr.id = d.rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
return fmt.Errorf("sync deadline procedural_event_id for %s: %w", deadlineID, err)
return fmt.Errorf("sync deadline dual-links 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,11 +202,14 @@ func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
}
// 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).
// 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)
}
}
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule

View File

@@ -168,7 +168,7 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
COALESCE(timing, 'after') AS timing,
deadline_notes, deadline_notes_en, alt_duration_value, alt_duration_unit,
combine_op, rule_codes
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE trigger_event_id = $1 AND is_active = true
ORDER BY sequence_order`, triggerEventID)
if err != nil {

View File

@@ -1138,7 +1138,7 @@ func personalSheetQueries(actorID uuid.UUID) []sheetQuery {
},
{
SheetName: "ref__deadline_rules",
SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`,
SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`,
},
{
SheetName: "ref__deadline_concepts",
@@ -1518,7 +1518,7 @@ SELECT 'partner_unit_default'::text AS source,
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
@@ -1563,7 +1563,7 @@ SELECT 'partner_unit_default'::text AS source,
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, binding_id`},
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
@@ -1621,7 +1621,7 @@ func orgSheetQueries() []sheetQuery {
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules_unified ORDER BY id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},

View File

@@ -169,7 +169,7 @@ func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*model
var rule models.DeadlineRule
err := c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE id = $1 AND is_active = true`, ruleID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownRule
@@ -200,7 +200,7 @@ func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, subm
var rule models.DeadlineRule
err = c.rules.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
pt.ID, submissionCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -311,7 +311,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe
pt.trigger_event_label_de AS pt_trigger_event_label_de,
pt.trigger_event_label_en AS pt_trigger_event_label_en,
pt.appeal_target AS pt_appeal_target
FROM paliad.deadline_rules_unified dr
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY dr.proceeding_type_id, dr.sequence_order`

View File

@@ -1767,7 +1767,7 @@ func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
ptID, code)
if errors.Is(err, sql.ErrNoRows) {
@@ -1784,7 +1784,7 @@ func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE id = $1`, id)
if err != nil {
return nil, fmt.Errorf("lookup rule by id: %w", err)
@@ -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 sequencing_rule_id = $2
WHERE project_id = $1 AND 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 sequencing_rule_id = $2
WHERE project_id = $1 AND rule_id = $2
ORDER BY created_at ASC
LIMIT 1`, projectID, rule.ID)
switch {

View File

@@ -117,7 +117,7 @@ func (s *RuleEditorService) ListOrphans(ctx context.Context) ([]Orphan, error) {
}
if err := s.db.SelectContext(ctx, &cs, `
SELECT id, rule_code, name, name_en
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE id = ANY($1::uuid[])`, pq.Array(uuidStrs)); err != nil {
return nil, fmt.Errorf("list orphan candidate rules: %w", err)
}
@@ -212,21 +212,20 @@ 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 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`,
`UPDATE paliad.deadlines
SET rule_id = $1,
updated_at = $2
WHERE id = $3`,
ruleID, now, oc.DeadlineID,
); err != nil {
return fmt.Errorf("set deadline sequencing_rule_id: %w", err)
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
}
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_unified
`INSERT INTO paliad.deadline_rules
(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,11 +209,13 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", 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.
// 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
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
@@ -277,13 +279,15 @@ 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_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
`UPDATE paliad.deadline_rules 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.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
// new-table writes — the UPDATE above is already fan-out.
// Slice B.2 dual-write (t-paliad-305).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -317,7 +321,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_unified
`INSERT INTO paliad.deadline_rules
(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,
@@ -338,16 +342,20 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
is_active,
'draft', $2, NULL,
now(), now()
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE id = $2`,
newID, id,
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", 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).
// 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
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -381,7 +389,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_unified
`UPDATE paliad.deadline_rules
SET lifecycle_state = 'published',
published_at = $1,
updated_at = $1
@@ -394,7 +402,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_unified
`UPDATE paliad.deadline_rules
SET lifecycle_state = 'archived',
updated_at = $1
WHERE id = $2 AND lifecycle_state = 'published'`,
@@ -404,9 +412,17 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// 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.
// 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
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
@@ -455,7 +471,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_unified
`UPDATE paliad.deadline_rules
SET lifecycle_state = $1,
published_at = COALESCE(published_at, $2),
updated_at = $2
@@ -466,7 +482,7 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rules_unified
`UPDATE paliad.deadline_rules
SET lifecycle_state = $1, updated_at = $2
WHERE id = $3`,
target, now, id,
@@ -475,8 +491,11 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
// mirrors the lifecycle flip onto sr + pe.
// 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
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
@@ -617,7 +636,7 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
where = "WHERE " + strings.Join(conds, " AND ")
}
query := `SELECT ` + ruleColumns + `
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
` + where + `
ORDER BY proceeding_type_id NULLS LAST, sequence_order
LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
@@ -637,7 +656,7 @@ func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.
func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
var r models.DeadlineRule
err := s.db.GetContext(ctx, &r,
`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
`SELECT `+ruleColumns+` FROM paliad.deadline_rules WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRuleNotFound
}
@@ -696,7 +715,7 @@ func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uu
visited[current] = true
var nexts []sql.NullInt64
q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE proceeding_type_id = $1
AND is_spawn = true
AND spawn_proceeding_type_id IS NOT NULL

View File

@@ -243,7 +243,7 @@ func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissio
var rule models.DeadlineRule
err := s.db.GetContext(ctx, &rule,
`SELECT `+ruleColumns+`
FROM paliad.deadline_rules_unified
FROM paliad.deadline_rules
WHERE submission_code = $1
AND lifecycle_state = 'published'
AND is_active = true
@@ -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, sequencing_rule_id, rule_code, status, completed_at,
warning_date, source, 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 sequencing_rule_id = $2
AND rule_id = $2
AND status = 'pending'
ORDER BY due_date ASC
LIMIT 1`, projectID, ruleID)