Compare commits

..

6 Commits

Author SHA1 Message Date
mAi
38ebccc907 feat(services): Slice B.2 dual-write — RuleEditorService writes deadline_rules AND procedural_events / sequencing_rules / legal_sources (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Keeps the parallel new tables (mig 136, Slice B.1) in lock-step with
the legacy paliad.deadline_rules table through every write path on
RuleEditorService. Read paths stay on deadline_rules in B.2 — B.3
flips them and stops legacy writes.

* internal/services/dual_write.go (new) —
  - syncDualWriteFromDeadlineRule(ctx, tx, id): idempotent UPSERT of
    legal_sources + procedural_events + sequencing_rules from the
    just-written deadline_rules row. Pure SQL projection, no Go-side
    struct mapping. Synthetic-code mint expression is byte-identical
    to mig 136 ('null.' || first 8 hex of stripped uuid).
  - syncDeadlineDualLinks(ctx, tx, deadlineID): mirrors a deadline's
    legacy rule_id back-link onto deadlines.procedural_event_id +
    sequencing_rule_id. Handles NULL rule_id naturally (collapses both
    new columns to NULL).
  - CheckDualWriteDrift(ctx, conn): nine read-only count queries +
    integrity joins. Returns DualWriteDriftReport. HasDrift() bool for
    log routing.
  - StartDualWriteDriftCheckLoop(ctx, conn, interval): goroutine ticker
    that runs CheckDualWriteDrift every `interval` (default 6h) for
    the lifetime of ctx. Clean run logs at INFO; drift at WARN with
    full report.

* internal/services/rule_editor_service.go —
  - Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle
    each call syncDualWriteFromDeadlineRule(ctx, tx, id) after the
    deadline_rules mutation, before tx.Commit. Publish syncs BOTH the
    published draft AND the cloned-from peer it just archived as a
    cascade. The audit_reason already set via setAuditReasonTx
    propagates to the new-table writes (same TX, same session).

* internal/services/rule_editor_orphans.go —
  - ResolveOrphan calls syncDeadlineDualLinks after UPDATE
    paliad.deadlines SET rule_id = $1, so the parallel new columns
    follow the legacy back-link.

* internal/services/deadline_service.go —
  - DeadlineService.Update calls syncDeadlineDualLinks when
    input.RuleSet is true (auto/custom rule swap from t-paliad-258).

* cmd/server/main.go —
  - Spawns StartDualWriteDriftCheckLoop alongside CalDAV sync and
    reminder scanner. Inherits bgCtx so the goroutine stops on
    SIGTERM. Interval 6h.

* internal/services/dual_write_test.go (new) —
  - TestDualWrite_RuleEditorLifecycle: Create → UpdateDraft → Publish
    → Archive, asserts the new tables mirror at each step. Final
    CheckDualWriteDrift returns zero drift.
  - TestDualWrite_SyntheticCodeForNullSubmission: rule created with
    submission_code=NULL gets a 'null.<8hex>' procedural_events row
    matching mig 136's mint expression byte-for-byte.

Scope decisions documented in the commit:

- B.2 keeps read paths on deadline_rules. paliadin's "Read paths fall
  back to legacy" reads as "reads stay on legacy as the safety net
  while drift-check validates the new tables". B.3 swaps reads to
  new tables only AND stops writing to deadline_rules — that's a
  separate slice per the design's §5.2/§5.3 split.

- B.2 does NOT modify submission_drafts, projection_service, the
  Fristenrechner calculator, the SubmissionVarsService, the
  Schriftsätze list query, or any other reader. They keep reading
  deadline_rules unchanged. The new tables are populated in parallel
  for B.3's cutover.

- Audit triggers on deadline_rules continue to fire as before. The
  new tables have no audit triggers yet (a later slice can add
  parallel audit rows once the new tables are authoritative).

- Drift-check uses default 6h interval — short enough that a broken
  dual-write surfaces within the same business day, long enough that
  the count-COUNTs don't churn the pool. Override via the caller in
  cmd/server.

Hard rules followed:
- audit_reason set on every TX before any deadline_rules mutation
  (existing pattern; new-table writes share the same reason).
- No destructive op (B.2 is strictly additive in behaviour).
- New helpers idempotent (UPSERT ON CONFLICT DO UPDATE) — safe to
  call twice, safe to re-run after a partial failure.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:49:48 +02:00
mAi
2377f08bd7 Merge: t-paliad-304 — R.109 anchor + columns-view duplicate fix (topo walk + 'both'→ours collapse) (m/paliad#135)
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
2026-05-26 15:54:39 +02:00
mAi
1d704f6e04 fix(litigationplanner): R.109.1/R.109.4 mis-anchor + duplicate 'both' row in columns view (t-paliad-304, m/paliad#135)
Two bugs surfaced on /tools/verfahrensablauf?side=defendant for upc.inf.cfi:

1. Anchor regression for timing='before' children of court-set parents.
   Rules R.109.1 (translation_request) and R.109.4 (interpreter_cost)
   anchor on the oral hearing (parent_id=upc.inf.cfi.oral, IsCourtSet)
   but were computing dates BEFORE the Statement of Claim — 1 month
   resp. 2 weeks before the SoC instead of before the oral hearing.

   Root cause: engine walked rules in sequence_order, and the two
   "before"-timed children carry sequence_order 45/46 (their chronological
   position, before the oral hearing at 50). Their parent had therefore
   not been processed yet when the children were, so courtSet[oral.ID]
   was still empty → parentIsCourtSet=false → the engine fell back to
   the trigger date as the base.

   Fix: walk rules in topological order (parent-first) during the
   compute pass, then restore sequence_order on the output slice so
   the wire shape and the linear timeline view's render order stay
   identical to the legacy behaviour modulo the bug fix.

2. Duplicate "Antrag auf Simultanübersetzung" row in columns view.
   With primary_party='both' and an explicit side pick (?side=defendant),
   the bucketing mirrored the card into both 'Unsere Seite' and
   'Gegnerseite' — the same card on the same row, visible as a
   duplicate.

   Fix: when the user has committed to a perspective (side picked)
   but no appellant axis applies, collapse 'both' rows into ours.
   The '↔ beide Seiten' indicator is suppressed in that path to match
   the existing appellant-collapse semantics (no sibling row to mirror
   to). Legacy mirror behaviour is preserved when side is null.

DB audit ruled out a data-level duplicate: exactly one published+active
row per submission_code in paliad.deadline_rules.

Tests:
  - pkg/litigationplanner/before_court_set_anchor_test.go: synthetic
    rules pinning the conditional-on-court-set-parent contract plus
    the override path (1mo before user-pinned oral).
  - frontend/src/client/views/verfahrensablauf-core.test.ts: two new
    cases pinning the side-collapse routing for party='both'.
2026-05-26 15:54:02 +02:00
mAi
a75731a902 Merge: t-paliad-302 — Verfahrensablauf duration indicator (hover + toggle, +3 lp.TimelineEntry fields) (m/paliad#133)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
2026-05-26 15:45:15 +02:00
mAi
727e01c6c9 Merge: t-paliad-303 — backfill applies_to_target: Schadensbemessung (merits) + Bucheinsicht (order) (mig 138) (m/paliad#134)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 15:44:19 +02:00
mAi
3097df3918 mAi: #133 — Verfahrensablauf duration affordance (hover + toggle)
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
t-paliad-302 / m/paliad#133. Surface each event card's rule duration
("2 Mo. nach") on /tools/verfahrensablauf — by default as a hover
tooltip on the date span, and optionally inline via a new
"Dauern anzeigen" header toggle (localStorage key
paliad.verfahrensablauf.durations-show).

The issue scoped this as pure-frontend on the assumption that the
duration fields were already on the /api/tools/fristenrechner payload.
They were not: lp.TimelineEntry exposed only the computed dueDate, not
the rule's (duration_value, duration_unit, timing) tuple. Added these
as three additive optional fields and populated them in both engine
emission sites (Calculate + CalculateByTriggerEvent) from the rule
row directly. Source values are the base rule fields, not the
post-alt-swap arithmetic — the tooltip reads as a property of the
rule rather than a recap of which branch fired.

Frontend wiring:
- formatDurationLabel() in verfahrensablauf-core builds the
  "<value> <unit> <timing>" string from the existing
  deadlines.event.unit.<unit>.{one,many} + deadlines.event.timing.*
  i18n keys, reused from /tools/fristenrechner's event-mode renderer.
- deadlineCardHtml attaches the label as title= on the date span
  (hover, default) and, when CardOpts.showDurations is on, emits an
  inline <span class="timeline-duration"> in the meta row.
- Court-set / zero-duration rules (trigger event, hearings) skip the
  affordance — durationValue <= 0 short-circuits in
  formatDurationLabel.
- Toggle persisted in localStorage under
  paliad.verfahrensablauf.durations-show, default off; sits next to
  the existing "Hinweise anzeigen" toggle.

bun run build clean, go test ./pkg/litigationplanner/... and
./internal/... clean, bun test src/client/views clean (89/89).
2026-05-26 15:43:30 +02:00
16 changed files with 1358 additions and 12 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/
@@ -337,6 +338,13 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
// Runs every 6 h while the new procedural_events / sequencing_rules /
// legal_sources tables shadow the legacy paliad.deadline_rules
// table. A clean run logs at INFO; drift logs at WARN with the
// full report so a broken dual-write surfaces before the next
// deploy.
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -312,6 +312,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.durations.show": "Dauern anzeigen",
"deadlines.col.ours": "Unsere Seite",
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
@@ -3406,6 +3407,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.durations.show": "Show durations",
"deadlines.col.ours": "Client Side",
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",

View File

@@ -276,6 +276,21 @@ function writeNotesPref(on: boolean): void {
}
let showNotes = readNotesPref();
// Durations toggle (m/paliad#133, t-paliad-302) — when off (default),
// the per-rule duration label ("2 Mo. nach") only shows on hover via
// the date span's `title` attribute. When on, the label renders inline
// in the timeline meta row of every event card. Persisted in
// localStorage under its own key so the preference is independent of
// "Hinweise anzeigen".
const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show";
function readDurationsPref(): boolean {
try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; }
}
function writeDurationsPref(on: boolean): void {
try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showDurations = readDurationsPref();
// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
@@ -482,6 +497,7 @@ function renderResults(data: DeadlineResponse) {
? renderColumnsBody(data, {
editable: true,
showNotes,
showDurations,
side: currentSide,
// t-paliad-301: the appellant axis collapses into the single
// side picker. For role-swap proceedings, currentSide IS the
@@ -490,7 +506,7 @@ function renderResults(data: DeadlineResponse) {
// the appellant axis is irrelevant — pass null.
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -868,6 +884,19 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
// notes toggle. Hover-only labels (default) become inline labels when
// the user opts in.
const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
if (durationsShowCb) {
durationsShowCb.checked = showDurations;
durationsShowCb.addEventListener("change", () => {
showDurations = durationsShowCb.checked;
writeDurationsPref(showDurations);
if (lastResponse) renderResults(lastResponse);
});
}
// 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

View File

@@ -327,6 +327,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].ours).toHaveLength(0);
});
test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
// When the user has committed to a perspective via `?side=`, the
// mirror is visual noise: the same card renders twice on one row,
// once in 'Unsere Seite' and once in 'Gegnerseite'. The card's
// '↔ beide Seiten' indicator already conveys the both-parties
// semantic, so collapsing into ours is sufficient.
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "defendant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Antrag auf Simultanübersetzung", "2026-04-27")],
{ side: "claimant" },
);
expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]);
expect(rows[0].opponent).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([

View File

@@ -95,6 +95,36 @@ export interface CalculatedDeadline {
parentRuleCode?: string;
parentRuleName?: string;
parentRuleNameEN?: string;
// durationValue / durationUnit / timing surface the rule's arithmetic
// so the timeline card can show "2 Mo. nach" on hover (and inline when
// the "Dauern anzeigen" toggle is on). Zero-duration rules (root
// event, court-set) carry durationValue=0 and the renderer suppresses
// the affordance — those don't have an explainable interval.
// (m/paliad#133, t-paliad-302)
durationValue?: number;
durationUnit?: string;
timing?: string;
}
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely.
//
// Pluralisation key naming mirrors the Fristenrechner event-mode
// renderer (deadlines.event.unit.<unit>.{one,many}) — the unit and
// timing translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single
// source of truth.
export function formatDurationLabel(dl: CalculatedDeadline): string {
const value = dl.durationValue ?? 0;
const unit = dl.durationUnit || "";
if (value <= 0 || !unit) return "";
const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
const unitStr = tDyn(unitKey);
const timing = dl.timing || "";
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -321,15 +351,38 @@ export interface CardOpts {
// Page shells expose a toggle ("Hinweise anzeigen") that flips this and
// re-renders. Default false — notes are noisy on long timelines.
showNotes?: boolean;
// showDurations controls per-rule duration rendering on event cards
// (m/paliad#133, t-paliad-302):
// true → inline `<span class="timeline-duration">2 Mo. nach</span>`
// next to the date.
// false → hover-only tooltip on the date span (browser-native
// `title` attribute). Cards without a usable
// `durationValue > 0` get neither — court-set and trigger-
// event cards have no explainable interval.
// /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
// flips this and re-renders; persisted via the localStorage key
// `paliad.verfahrensablauf.durations-show`. Default false.
showDurations?: boolean;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
// both the date-span tooltip and the inline meta-row span pull from
// the same string. Empty for rules without a usable duration.
const durationLabel = formatDurationLabel(dl);
// Hover affordance on the date span: prefer the duration tooltip when
// we have one, else fall back to the edit-hint when the cell is
// click-to-edit. The edit affordance still works either way — the
// title is purely advisory.
const dateTitle = durationLabel
? durationLabel
: (editable ? t("deadlines.date.edit.hint") : "");
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
: (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
// Conditional rows (t-paliad-289) replace the date column with an
// "abhängig von <parent>" chip. The chip remains click-to-edit so
// the user can pin a real date once known (e.g. once the oral
@@ -434,9 +487,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint)
// Inline duration affordance (m/paliad#133, t-paliad-302). Only
// emitted when the "Dauern anzeigen" toggle is on AND the rule has a
// usable duration; the default-off hover-tooltip path is wired
// separately on the date span itself.
const showDurations = opts.showDurations === true;
const durationInline = showDurations && durationLabel
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${durationInline}
${ruleRef}
${noteHint}
</div>`
@@ -614,6 +677,9 @@ type ColumnPosition = "ours" | "opponent";
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// Forwarded to deadlineCardHtml — see CardOpts.showDurations.
// (m/paliad#133, t-paliad-302)
showDurations?: boolean;
// side: which side the user is on. Drives column placement;
// does NOT filter rows. Default null = claimant-on-the-left
// (i.e. "ours = claimant", legacy default).
@@ -698,7 +764,18 @@ export function bucketDeadlinesIntoColumns(
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else if (userSide !== null) {
// Side picked but no appellant axis (first-instance Inf, Rev,
// …): the user has committed to a perspective, so the mirror
// is visual noise — the same card appears twice on the same
// row, once in "Unsere Seite" and once in "Gegnerseite".
// Collapse into ours; the "↔ beide Seiten" indicator on the
// card already conveys that the rule applies to both parties.
// (m/paliad#135 / t-paliad-304)
row.ours.push(dl);
} else {
// No perspective picked → keep the legacy mirror so neither
// axis is privileged. Pinned by the "default (no opts)" test.
row.ours.push(dl);
row.opponent.push(dl);
}
@@ -724,12 +801,23 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
const cardOpts: CardOpts = {
showParty: false,
editable: opts.editable,
showNotes: opts.showNotes,
showDurations: opts.showDurations,
};
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = !appellantPinned;
// be misleading. Both collapse paths suppress it:
// - appellantPinned: role-swap collapse into appellant's column
// - userSide !== null without appellantPinned: perspective-locked
// collapse into ours (m/paliad#135 / t-paliad-304).
// Legacy mirror path (no side, no appellant) keeps the tag — both
// sibling rows still render so the tag has a visual referent.
const sideCollapse = userSide !== null;
const showMirrorTag = !appellantPinned && !sideCollapse;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {

View File

@@ -1264,6 +1264,7 @@ export type I18nKey =
| "deadlines.dpma.appeal.bgh"
| "deadlines.dpma.appeal.bpatg"
| "deadlines.dpma.opp.dpma"
| "deadlines.durations.show"
| "deadlines.empty.filtered"
| "deadlines.empty.hint"
| "deadlines.empty.title"

View File

@@ -3750,6 +3750,16 @@ input[type="range"]::-moz-range-thumb {
color: var(--color-text-muted);
}
/* Per-rule duration label rendered inline in the meta row when
"Dauern anzeigen" is on (m/paliad#133, t-paliad-302). Matches the
sibling .timeline-rule weight so the meta line reads as one band of
secondary metadata; non-mono so the value reads as prose ("2 Mo. nach")
rather than a code reference. */
.timeline-duration {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.timeline-adjusted {
font-size: 0.78rem;
color: var(--status-amber-fg-2);

View File

@@ -341,6 +341,13 @@ export function renderVerfahrensablauf(): string {
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
{/* Durations toggle (m/paliad#133, t-paliad-302).
Default off — hover-tooltips on date spans are
the always-on path. */}
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -585,6 +585,16 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): if rule_id was in the
// patch (auto/custom swap from t-paliad-258), the parallel
// procedural_event_id + sequencing_rule_id columns must follow.
// Call unconditionally — it's a single UPDATE keyed on
// deadlineID and a no-op when rule_id is unchanged.
if input.RuleSet {
if err := syncDeadlineDualLinks(ctx, tx, deadlineID); err != nil {
return nil, err
}
}
}
if input.EventTypeIDs != nil && s.eventTypes != nil {

View File

@@ -0,0 +1,392 @@
// 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.
//
// Contract:
//
// - 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.
//
// 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"
)
// 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.
//
// 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 sequencing_rule_id = d.rule_id,
procedural_event_id = (
SELECT sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
)
WHERE d.id = $1`, deadlineID); err != nil {
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

@@ -0,0 +1,300 @@
// Slice B.2 dual-write tests (t-paliad-305 / m/paliad#93).
//
// Asserts the parallel projection — paliad.procedural_events +
// paliad.sequencing_rules + paliad.legal_sources — stays in lock-step
// with paliad.deadline_rules through the full RuleEditorService
// lifecycle. Skipped when TEST_DATABASE_URL is unset.
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestDualWrite_RuleEditorLifecycle walks Create → UpdateDraft →
// CloneAsDraft → Publish → Archive → Restore on RuleEditorService and
// after each operation asserts that paliad.sequencing_rules has the
// 1:1 mirror, paliad.procedural_events carries the projected identity,
// and paliad.legal_sources carries the citation.
func TestDualWrite_RuleEditorLifecycle(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', 'slice b.2 test cleanup', true)`)
// Order matters: sequencing_rules → procedural_events → legal_sources
// (FK direction). deadline_rules cleanup last because mig 079 audit
// trigger captures the DELETE.
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code LIKE 'sliceb2.%' OR code LIKE 'null.sliceb2%'`)
pool.ExecContext(ctx, `DELETE FROM paliad.legal_sources
WHERE citation LIKE 'SLICEB2.%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICEB2_TEST_%'`)
pool.ExecContext(ctx,
`DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_TEST_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_TEST_PT', 'Slice B.2 Test PT', 'Slice B.2 Test PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
subCode := "sliceb2.create"
legalSrc := "SLICEB2.PatG.1"
// 1. Create — assert the parallel rows land.
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_create",
NameEN: "SLICEB2_TEST_create_EN",
ProceedingTypeID: &ptID,
SubmissionCode: &subCode,
LegalSource: &legalSrc,
DurationValue: 30,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write create test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// legal_sources should now carry SLICEB2.PatG.1
var lsCount int
if err := pool.GetContext(ctx, &lsCount,
`SELECT COUNT(*) FROM paliad.legal_sources WHERE citation = $1`, legalSrc); err != nil {
t.Fatalf("query legal_sources: %v", err)
}
if lsCount != 1 {
t.Errorf("legal_sources after Create: got %d, want 1 for citation %q", lsCount, legalSrc)
}
// procedural_events should carry the submission_code
var peName, peLifecycle string
if err := pool.GetContext(ctx, &peName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events name: %v", err)
}
if peName != "SLICEB2_TEST_create" {
t.Errorf("procedural_events.name after Create: got %q, want %q", peName, "SLICEB2_TEST_create")
}
if err := pool.GetContext(ctx, &peLifecycle,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query procedural_events lifecycle: %v", err)
}
if peLifecycle != "draft" {
t.Errorf("procedural_events.lifecycle_state after Create: got %q, want %q", peLifecycle, "draft")
}
// sequencing_rules should have id = created.id and link to PE
var srCount, srMatchPE int
if err := pool.GetContext(ctx, &srCount,
`SELECT COUNT(*) FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sequencing_rules count: %v", err)
}
if srCount != 1 {
t.Errorf("sequencing_rules row after Create: got %d, want 1 for id %s", srCount, created.ID)
}
if err := pool.GetContext(ctx, &srMatchPE, `
SELECT COUNT(*) FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.id = $1 AND pe.code = $2`, created.ID, subCode); err != nil {
t.Fatalf("query sr→pe join: %v", err)
}
if srMatchPE != 1 {
t.Errorf("sequencing_rules.procedural_event_id after Create: got %d join hits, want 1", srMatchPE)
}
// 2. UpdateDraft — change name + legal_source. Assert propagation.
newName := "SLICEB2_TEST_updated"
newLegal := "SLICEB2.ZPO.2"
_, err = svc.UpdateDraft(ctx, created.ID, RulePatch{
Name: &newName,
LegalSource: &newLegal,
}, "B.2 dual-write update test")
if err != nil {
t.Fatalf("UpdateDraft: %v", err)
}
var afterName string
if err := pool.GetContext(ctx, &afterName,
`SELECT name FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.name post-update: %v", err)
}
if afterName != newName {
t.Errorf("procedural_events.name after UpdateDraft: got %q, want %q", afterName, newName)
}
// New citation must appear in legal_sources, and procedural_events.legal_source_id
// must point at it (idempotent UPSERT — the old SLICEB2.PatG.1 row stays).
var pePointsAtNewLegal int
if err := pool.GetContext(ctx, &pePointsAtNewLegal, `
SELECT COUNT(*) FROM paliad.procedural_events pe
JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
WHERE pe.code = $1 AND ls.citation = $2`, subCode, newLegal); err != nil {
t.Fatalf("query pe→ls join: %v", err)
}
if pePointsAtNewLegal != 1 {
t.Errorf("procedural_events.legal_source_id after UpdateDraft: got %d hits, want 1", pePointsAtNewLegal)
}
// 3. Publish — flip to published. Assert lifecycle mirror.
_, err = svc.Publish(ctx, created.ID, "B.2 dual-write publish test")
if err != nil {
t.Fatalf("Publish: %v", err)
}
var srLifecycle, peLifecycleAfterPub string
if err := pool.GetContext(ctx, &srLifecycle,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle: %v", err)
}
if srLifecycle != "published" {
t.Errorf("sequencing_rules.lifecycle_state after Publish: got %q, want %q", srLifecycle, "published")
}
if err := pool.GetContext(ctx, &peLifecycleAfterPub,
`SELECT lifecycle_state FROM paliad.procedural_events WHERE code = $1`, subCode); err != nil {
t.Fatalf("query pe.lifecycle post-publish: %v", err)
}
if peLifecycleAfterPub != "published" {
t.Errorf("procedural_events.lifecycle_state after Publish: got %q, want %q", peLifecycleAfterPub, "published")
}
// 4. Archive — flip to archived. Assert mirror.
_, err = svc.Archive(ctx, created.ID, "B.2 dual-write archive test")
if err != nil {
t.Fatalf("Archive: %v", err)
}
var srLifecycleArchived string
if err := pool.GetContext(ctx, &srLifecycleArchived,
`SELECT lifecycle_state FROM paliad.sequencing_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("query sr.lifecycle post-archive: %v", err)
}
if srLifecycleArchived != "archived" {
t.Errorf("sequencing_rules.lifecycle_state after Archive: got %q, want %q", srLifecycleArchived, "archived")
}
// 5. Drift check should return zero drift right after the dance.
report, err := CheckDualWriteDrift(ctx, pool)
if err != nil {
t.Fatalf("CheckDualWriteDrift: %v", err)
}
if report.HasDrift() {
t.Errorf("CheckDualWriteDrift unexpectedly flagged drift: %+v", report)
}
}
// TestDualWrite_SyntheticCodeForNullSubmission asserts that a rule
// created with submission_code=NULL gets a synthetic 'null.<8hex>'
// procedural_events row matching mig 136's mint expression — so a new
// draft without a code participates in the dual-write contract without
// colliding with any code-bearing rule.
func TestDualWrite_SyntheticCodeForNullSubmission(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
svc := NewRuleEditorService(pool, rules)
cleanup := func() {
pool.ExecContext(ctx, `SELECT set_config('paliad.audit_reason', 'slice b.2 null-code cleanup', true)`)
pool.ExecContext(ctx, `DELETE FROM paliad.sequencing_rules WHERE id IN (
SELECT id FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
// Synthetic PE rows are keyed off the rule's uuid; delete by name reference.
pool.ExecContext(ctx, `DELETE FROM paliad.procedural_events
WHERE code IN (
SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'
)`)
pool.ExecContext(ctx, `DELETE FROM paliad.deadline_rules WHERE name = 'SLICEB2_TEST_nullcode'`)
pool.ExecContext(ctx, `DELETE FROM paliad.proceeding_types WHERE code = 'SLICEB2_NC_PT'`)
}
cleanup()
defer cleanup()
var ptID int
if err := pool.GetContext(ctx, &ptID, `
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
VALUES ('SLICEB2_NC_PT', 'NC PT', 'NC PT', 'fristenrechner', 'UPC', true)
RETURNING id`); err != nil {
t.Fatalf("seed proceeding_type: %v", err)
}
created, err := svc.Create(ctx, CreateRuleInput{
Name: "SLICEB2_TEST_nullcode",
NameEN: "SLICEB2_TEST_nullcode_EN",
ProceedingTypeID: &ptID,
// SubmissionCode intentionally NIL → tests the synthetic-code branch.
DurationValue: 5,
DurationUnit: "days",
Priority: "mandatory",
}, "B.2 dual-write null-code test")
if err != nil {
t.Fatalf("Create: %v", err)
}
// Compute the expected synthetic code in the same way mig 136 / the
// dual-write helper do — keep the expression in lock-step with the
// SQL via this Go-side mirror.
var expectedCode string
if err := pool.GetContext(ctx, &expectedCode,
`SELECT 'null.' || substring(replace(id::text, '-', ''), 1, 8)
FROM paliad.deadline_rules WHERE id = $1`, created.ID); err != nil {
t.Fatalf("compute expected synthetic code: %v", err)
}
var actualCode string
if err := pool.GetContext(ctx, &actualCode, `
SELECT pe.code
FROM paliad.procedural_events pe
JOIN paliad.sequencing_rules sr ON sr.procedural_event_id = pe.id
WHERE sr.id = $1`, created.ID); err != nil {
t.Fatalf("query procedural_events via sequencing_rules: %v", err)
}
if actualCode != expectedCode {
t.Errorf("synthetic code mismatch: got %q, want %q", actualCode, expectedCode)
}
if len(actualCode) != len("null.")+8 {
t.Errorf("synthetic code length: got %d, want 13 (null.+8hex)", len(actualCode))
}
}

View File

@@ -221,6 +221,12 @@ func (s *RuleEditorService) ResolveOrphan(ctx context.Context, orphanID uuid.UUI
); err != nil {
return fmt.Errorf("set deadline rule_id: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): mirror the new linkage onto
// the parallel deadlines.procedural_event_id + sequencing_rule_id
// columns so they don't drift from rule_id.
if err := syncDeadlineDualLinks(ctx, tx, oc.DeadlineID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadline_rule_backfill_orphans
SET resolved_at = $1,

View File

@@ -209,6 +209,14 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
return nil, fmt.Errorf("insert rule: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): project the new row into
// legal_sources / procedural_events / sequencing_rules in the same
// transaction so the parallel tables stay in lock-step with
// deadline_rules through the B.3 read-cutover window.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create: %w", err)
}
@@ -276,6 +284,10 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return nil, fmt.Errorf("update rule draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update: %w", err)
}
@@ -336,6 +348,14 @@ func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reas
); err != nil {
return nil, fmt.Errorf("clone rule as draft: %w", err)
}
// Slice B.2 dual-write (t-paliad-305): new draft gets its own
// procedural_events + sequencing_rules row. The synthetic-code
// branch fires here when the source rule had NULL submission_code
// (the clone inherits the NULL and mints a fresh 'null.<8hex>'
// derived from newID).
if err := syncDualWriteFromDeadlineRule(ctx, tx, newID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit clone: %w", err)
}
@@ -392,6 +412,18 @@ func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason st
}
}
// Slice B.2 dual-write (t-paliad-305): sync both sides — the newly
// published draft AND the cloned-from peer that just flipped to
// archived (if any).
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if current.DraftOf != nil {
if err := syncDualWriteFromDeadlineRule(ctx, tx, *current.DraftOf); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit publish: %w", err)
}
@@ -459,6 +491,12 @@ func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, tar
}
}
// Slice B.2 dual-write (t-paliad-305): mirror the lifecycle flip
// onto sequencing_rules + procedural_events.
if err := syncDualWriteFromDeadlineRule(ctx, tx, id); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit flip: %w", err)
}

View File

@@ -0,0 +1,322 @@
package litigationplanner
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// Regression test for t-paliad-304 / m/paliad#135.
//
// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi:
// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0
// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45
// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46
// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50
//
// The "before" children are listed BEFORE the oral hearing in sequence
// order (because chronologically they happen before it). The engine walks
// rules in sequence_order, so when it processes the translation/
// interpreter rows, the oral hearing has not yet been processed →
// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the
// engine falls back to the trigger date as the base. Result: the timing=
// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of
// the conditional-no-date treatment that a court-set parent should
// trigger.
//
// Expected post-fix: translation_request + interpreter_cost render as
// IsConditional (no concrete date) because their parent's date is
// court-set and the proceeding does not yet have an explicit override.
// stubCatalog implements lp.Catalog backed by an in-memory rule slice.
// Only LoadProceeding is needed for the engine path under test; the
// other interface methods return errors so an unintended call surfaces
// immediately.
type stubCatalog struct {
pt ProceedingType
rules []Rule
}
func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) {
if code != s.pt.Code {
return nil, nil, ErrUnknownProceedingType
}
rules := make([]Rule, len(s.rules))
copy(rules, s.rules)
pt := s.pt
return &pt, rules, nil
}
func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) {
return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented")
}
func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) {
return nil, errors.New("stubCatalog.LoadRuleByID: not implemented")
}
func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) {
return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented")
}
func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) {
return nil, nil
}
func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) {
return map[int64]TriggerEvent{}, nil
}
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
return nil, nil
}
// noOpHolidays never adjusts dates — the test fixture doesn't care about
// weekends or holidays, only about which base date the engine resolves.
type noOpHolidays struct{}
func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false }
func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) {
return d, d, false
}
func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) {
return d, d, false, nil
}
type fixedCourts struct{}
func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) {
return CountryDE, RegimeUPC, nil
}
func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) {
ctx := context.Background()
// proceeding metadata
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
NameEN: "Infringement",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
interpID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
interpCode := "upc.inf.cfi.interpreter_cost"
rules := []Rule{
{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "Statement of Claim",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Translation request: sequence_order BEFORE the oral hearing.
// Reproduces the real corpus ordering (DB rows 45 < 50).
{
ID: transID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &transCode,
Name: "Antrag auf Simultanübersetzung",
NameEN: "Translation request",
PrimaryParty: str("both"),
DurationValue: 1,
DurationUnit: "months",
Timing: str("before"),
SequenceOrder: 45,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
},
// Interpreter cost notice: sequence_order BEFORE the oral hearing.
{
ID: interpID,
ProceedingTypeID: procIDPtr,
ParentID: &oralID,
SubmissionCode: &interpCode,
Name: "Mitteilung Dolmetscherkosten",
NameEN: "Interpreter cost notice",
PrimaryParty: str("court"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 46,
IsCourtSet: false,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
// Oral hearing: court-set, no calculable date. Listed AFTER its
// "before"-timed children in sequence_order.
{
ID: oralID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &oralCode,
Name: "Mündliche Verhandlung",
NameEN: "Oral hearing",
PrimaryParty: str("court"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 50,
IsCourtSet: true,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
// The trigger event itself is unambiguous.
if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent {
t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent)
}
// Oral hearing must surface as IsCourtSet (no date).
oral := byCode[oralCode]
if oral.DueDate != "" || !oral.IsCourtSet {
t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet)
}
// The two "before" children of the court-set oral hearing MUST surface
// as conditional rows (no date, no fabricated arithmetic off the
// trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12.
trans := byCode[transCode]
if trans.DueDate != "" {
t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate)
}
if !trans.IsConditional && !trans.IsCourtSet {
t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet)
}
interp := byCode[interpCode]
if interp.DueDate != "" {
t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate)
}
if !interp.IsConditional && !interp.IsCourtSet {
t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet)
}
}
// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the
// override semantics: when the user supplies an anchor override for
// the court-set parent, the "before" children should compute against
// that override date instead of remaining conditional.
func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
socID := mkID()
oralID := mkID()
transID := mkID()
socCode := "upc.inf.cfi.soc"
oralCode := "upc.inf.cfi.oral"
transCode := "upc.inf.cfi.translation_request"
rules := []Rule{
{
ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil,
SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC",
PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory",
},
{
ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID,
SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request",
PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"),
SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional",
},
{
ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil,
SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing",
PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"),
SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory",
},
}
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
opts := CalcOptions{
AnchorOverrides: map[string]string{
oralCode: "2026-10-15",
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
if got := byCode[oralCode].DueDate; got != "2026-10-15" {
t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got)
}
// 1 month before 2026-10-15 = 2026-09-15
if got := byCode[transCode].DueDate; got != "2026-09-15" {
t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got)
}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/google/uuid"
@@ -183,10 +184,27 @@ func Calculate(
return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
}
// Walk the rule list in sequence_order (already sorted by the
// catalog query) and compute each entry, keeping a code→date map so
// RelativeTo / parent_id references resolve to the adjusted
// predecessor date.
// Walk the rule list in TOPOLOGICAL order (parents before children),
// not the raw sequence_order order from the catalog. The catalog
// returns rules sorted by sequence_order, which is the chronological/
// display order. That order is parent-first for the common
// timing='after' case but parent-LAST for timing='before' children
// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
// ordering the parent-state checks below (courtSet[parent] /
// computed[parent_code]) read stale empty maps when a child appears
// before its parent, and the engine falls back to the trigger date
// → fabricates dates before the SoC.
//
// Original sequence_order is restored at the end of the walk so the
// wire shape and the timeline view's render order stay identical to
// the legacy behaviour modulo the bug fix.
sequenceIndex := make(map[uuid.UUID]int, len(rules))
for i, r := range rules {
sequenceIndex[r.ID] = i
}
walkRules := topoSortByParentDepth(rules)
computed := make(map[string]time.Time, len(rules))
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]TimelineEntry, 0, len(rules))
@@ -197,7 +215,7 @@ func Calculate(
hiddenCount := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
for _, r := range walkRules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false
// AND no alt_* values exist, the rule is dropped from the
@@ -249,6 +267,10 @@ func Calculate(
appellantContext[r.ID] = ctxVal
}
ruleTiming := ""
if r.Timing != nil {
ruleTiming = *r.Timing
}
d := TimelineEntry{
RuleID: r.ID.String(),
Name: r.Name,
@@ -258,6 +280,9 @@ func Calculate(
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
IsHidden: isHidden,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
Timing: ruleTiming,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -547,6 +572,20 @@ func Calculate(
deadlines = append(deadlines, d)
}
// Restore sequence_order on the output slice. The compute walk
// re-ordered rules topologically (parent-first) so the parent-state
// checks resolved correctly; the wire shape and the linear timeline
// view both rely on sequence_order being the surface render order.
// (m/paliad#135)
sort.SliceStable(deadlines, func(i, j int) bool {
a, errA := uuid.Parse(deadlines[i].RuleID)
b, errB := uuid.Parse(deadlines[j].RuleID)
if errA != nil || errB != nil {
return false
}
return sequenceIndex[a] < sequenceIndex[b]
})
// t-paliad-296: within consecutive runs of rules sharing the same
// trigger group (parent_id + trigger_event_id), reorder by duration
// ascending so optional events following the same anchor render in
@@ -671,6 +710,9 @@ func calculateByTriggerEvent(
OriginalDate: original.Format("2006-01-02"),
WasAdjusted: wasAdj,
AdjustmentReason: reason,
DurationValue: r.DurationValue,
DurationUnit: r.DurationUnit,
Timing: timing,
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode
@@ -940,3 +982,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool {
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
return wireFlagsFromPriority(priority)
}
// topoSortByParentDepth returns a copy of `rules` ordered so every rule
// appears after its parent_id ancestor. Ties (rules at the same depth)
// preserve their input order — which the catalog returns in
// sequence_order. Used by Calculate to ensure the parent-state checks
// (courtSet[parent], computed[parent_code]) see populated entries even
// when sequence_order lists a "before"-timed child BEFORE its parent
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
//
// Rules whose parent_id is missing from the rule slice (cross-tree
// references that the per-proceeding filter dropped) are treated as
// depth 0 — they walk in their original sequence position.
//
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
// rule chain that revisits a node is broken at depth 0; production
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
// the calculator.
func topoSortByParentDepth(rules []Rule) []Rule {
byID := make(map[uuid.UUID]Rule, len(rules))
inSlice := make(map[uuid.UUID]bool, len(rules))
for _, r := range rules {
byID[r.ID] = r
inSlice[r.ID] = true
}
depth := make(map[uuid.UUID]int, len(rules))
var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
if d, ok := depth[id]; ok {
return d
}
if seen[id] {
depth[id] = 0
return 0
}
seen[id] = true
r, ok := byID[id]
if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
depth[id] = 0
return 0
}
d := resolve(*r.ParentID, seen) + 1
depth[id] = d
return d
}
for _, r := range rules {
resolve(r.ID, map[uuid.UUID]bool{})
}
out := make([]Rule, len(rules))
copy(out, rules)
sort.SliceStable(out, func(i, j int) bool {
return depth[out[i].ID] < depth[out[j].ID]
})
return out
}

View File

@@ -430,6 +430,17 @@ type TimelineEntry struct {
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
AppellantContext string `json:"appellantContext,omitempty"`
IsHidden bool `json:"isHidden,omitempty"`
// DurationValue / DurationUnit / Timing surface the rule's
// arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on
// each event card (m/paliad#133, t-paliad-302). Source values from
// the Rule row (not the post-alt-swap arithmetic) — the tooltip
// reads as a property of the rule, not a recap of which branch
// fired. Zero-duration rules (root event, court-set) emit
// DurationValue=0 and the frontend suppresses the affordance.
// Timing is "before" | "after" — empty when r.Timing is NULL.
DurationValue int `json:"durationValue,omitempty"`
DurationUnit string `json:"durationUnit,omitempty"`
Timing string `json:"timing,omitempty"`
}
// RuleCalculation is the single-rule calc response that backs the