The "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.
Backend additions:
- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
walker. Per the head's Slice B brief, scope is paragraphs +
bold/italic + blank-line spacing. Placeholders pass through
unchanged for the v1 substitution pass. CRLF normalisation; nested
formatting (***bold-italic***); two delimiter forms (* and _);
XML-escaping for &/</>; explicit empty-paragraph emit so blank
lines round-trip. 12 unit tests.
- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
service. Pipeline: ConvertDotmToDocx pre-pass → extract
word/document.xml → render each included section's content_md_<lang>
→ splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
the body → strip anchors for excluded sections → append unanchored
sections before <w:sectPr> → repack zip → run v1 placeholder pass.
RE2-friendly anchor scanner walks markers in body-order and matches
open/close pairs with a stack (handles unbalanced anchors
defensively). 6 unit tests covering anchor-mode splice,
append-mode-no-anchors, excluded-section drop, placeholder
resolution, lang column pick, order_index ASC.
- internal/services/submission_section_service.go: SectionPatch +
Update method. Six optional fields (content_md_de/en, included,
label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
RLS-filtered miss.
- internal/handlers/submission_sections.go (NEW, ~150 LoC):
PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
cross-check. 404 on both missing-draft and section-belongs-elsewhere
paths.
- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
→ existing firmSkeletonSubmissionSlug, neutral → existing
skeletonSubmissionSlug.
- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
branches on draft.BaseID. When set AND base + bytes + sections all
resolve → Composer pipeline. Else v1 fallback render path stays.
Audit metadata jsonb gains "composer": true + "base_id" flag when
composer was used.
Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
inside section content).
Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
contentEditable per included section with a per-section B/I
toolbar. Per-section autosave debounced 500ms; mousedown handlers on
toolbar buttons preserve editor focus mid-command. domToMarkdown
walks the contentEditable's DOM tree back to Markdown source-of-
truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
→ newline). Updated state.view.sections in-place on PATCH success
without re-painting (avoids focus-stealing on every keystroke);
re-paints only on structural changes (included toggle, label edits,
order changes).
- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
section via PATCH. flushSectionAutosave on blur force-flushes
pending edits so leaving an editor doesn't strand unsynced changes.
- styles/global.css: editor surface (contentEditable area with focus
ring + placeholder), toolbar buttons (B/I 1.8rem squares),
per-section "Hide"/"Include" toggle in the head row.
- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
500ms. Letztes Layout in Word."
Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
sectPr → firm header/footer rIds): blob SHA
f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
(default true) so future regens emit composer-ready bodies. The
_firm-skeleton.docx regen was done via a one-off /tmp helper since
the gen-hl-skeleton-template script requires the proprietary .dotm
source which lives in HL/mWorkRepo; extending that script to accept
an existing .docx as input is a follow-up cleanup.
Build hygiene: go build/vet/test -short ./internal/... ./cmd/... all
clean; bun run build clean (2900 i18n keys, data-i18n scan clean).
NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.
Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).
NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
this slice per head's brief msg #2393).
t-paliad-313 Slice B
Symptom: paliadin chat returns "Verbindung verloren" because aichat's
paliadin persona is not configured with streaming support — every
RunTurnStream() call gets back HTTP 400 unsupported_streaming and the
SSE stream closes empty.
Fix: when RunTurnStream() detects "unsupported_streaming" in the
upstream error, transparently retry against /chat/turn (non-streaming)
with the same body. The full response gets emitted as a single
StreamChunk + StreamMeta so the SSE relay sees identical event
ordering. Persistence (completeTurn + markPrimed) mirrors the one-shot
RunTurn() path.
No real-time chunking until the persona is reconfigured upstream, but
the chat works end-to-end. Once the paliadin persona supports streaming
on aichat, this code path goes dormant — the unsupported_streaming
branch is only entered when the upstream actually returns that error.
Diagnostic logs from commit 937ff13 made this visible:
paliadin: backend returned error err=aichat: HTTP 400 (bad_request):
unsupported_streaming: persona paliadin does not support streaming
Refs m/paliad demo path.
The first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.
Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
(slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
users, mutations admin-only (handler-enforced, no RLS write paths).
Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
_skeleton.docx. Each section_spec carries the 10-section default
(letterhead, caption, introduction, requests, facts, legal_argument,
evidence, exhibits, closing, signature) with bilingual labels +
bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
composer_meta jsonb (default '{}'). Purely additive; pre-Composer
drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
section_key, order_index, kind ∈ {prose,requests,evidence},
label_de/en, included, content_md_de/en). RLS mirrors
submission_drafts (owner-scoped + can_see_project, four policies).
Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
the firm default base and seeds base_id + section rows in one tx.
Composer wiring is additive — when bases==nil the service stays
v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
on both project-scoped and global drafts accept "base_id".
Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
(hidden until catalog loads); section-list pane above the preview
(hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
paintBasePicker rebuilds <option> list on every paint; onBaseChange
PATCHes base_id and repaints; paintSectionList renders each section
read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
Pre-Composer drafts get the picker (catalog still loads) but the
section pane stays hidden until they pick a base on a new draft.
Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
(pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
seeded 10 default sections on both bases; GetDefaultForCode picks
hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
base_id + 10 section rows in tx with ascending order_index and
bilingual labels populated.
NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.
Build hygiene: go build/vet/test -short clean; bun run build clean
(2900 i18n keys, data-i18n scan clean).
t-paliad-313
All 12 §11 design questions ratified by m on 2026-05-26 via
AskUserQuestion (paliadin-authorised override per instruction msg #2391).
Picks matching inventor recommendations (9 of 12):
Q1 separate submission_sections table
Q3 Gitea-backed body + thin DB row
Q4 contentEditable + Markdown + in-house serializer
Q5 section anchors + in-house MD->OOXML walker
Q7 split content_md_de + content_md_en from day 1
Q8 Go map for per-submission_code section defaults
Q9 4 visibility tiers (private/team/firm/global)
Q11 collapsed preview pane by default
Q12 moot (superseded by Q2 simplification)
Deviations from recommendation (3 of 12):
Q2 SIMPLIFY further — m: "sounds overengineered". Building blocks
become plain text paste sources. No building_block_id column on
sections, no _versions table referenced from sections, no
refresh-from-library affordance. Slice G dropped.
Q6 Auto-upgrade all 11 existing drafts at mig-148 apply time (not
opt-in per draft). v1 fallback render path stays compiled in.
Q10 *_auto kind removed. Caption/letterhead/signature sections are
regular prose rows seeded with bag-driven Markdown; lawyer can
edit/hide. Untouched drafts export identically to v1.
Body sections updated inline (§4.3 schema, §4.4 BB tables, §6.3
seeding, §8.3+8.4 BB insert, slice plan A/C/G, §11 ratification notes,
§14 risks #8+11, §17+18 acceptance + gate). §11 retains the historical
recommendation matrix.
Status: ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for
Slice A coder shift. Inventor parks per hard gate. Head decides hire.
t-paliad-312
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 ./...`.
Sectioned composition, swappable base templates, in-app prose editing,
building-blocks library. Deepens t-paliad-215 + t-paliad-238 without
replacing them — v1 contracts (submission_drafts shape, {{rule.X}}
aliases, audit shape) preserved.
7 slices A→G; Slice B is the smallest "Composer works" milestone.
Existing 11 v1 drafts continue via v1 path; opt-in upgrade per draft.
12 open design questions with recommended defaults + alternatives for m
to ratify via head escalation (no AskUserQuestion per task brief).
Flags two issue-body inaccuracies: no submission_drafts.audit_log column
(audit lives in system_audit_log + project_events); live row count is
11, not 7.
t-paliad-312
Two complementary live tests (both skipped without TEST_DATABASE_URL):
- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
composer the way the backup runner does at the start of every run,
then executes each resolved SELECT against the live DB (wrapped in
LIMIT 1 to keep table reads cheap). A future column rename in a
table our spec still names triggers this test and surfaces in CI
before /admin/backups breaks.
- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
and the zip magic bytes; doesn't materialise the full bundle to
disk.
Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.
m/paliad#140
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.
At backup time the resolver:
1. probes information_schema.columns once for every spec table,
2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
3. logs WARN per ORDER BY column dropped because it's gone.
A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.
Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.
Tests:
- 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
DB needed (missing column, all-missing, override bypass, declared
order preserved, unknown table)
- Existing registry-shape tests (no duplicates, no paliadin leakage,
ref__ prefix, ORDER BY-for-determinism) updated to the spec API
- Full internal/services suite green
m/paliad#140
The Schriftsätze list rendered procedurally meaningless: Berufungsbegründung
ahead of Klageerhebung etc. because the ORDER BY was alphabetic by
submission_code within each proceeding. Add dr.sequence_order ASC as the
primary intra-proceeding sort; submission_code stays as the deterministic
tiebreaker for rules sharing a sequence_order.
deadline_rules.sequence_order is already populated for every published
filing rule (verified via paliad.deadline_rules_unified). Pure read-side
fix; no schema or data change.
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:
- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id
Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.
m/paliad#140
The --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.
Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white
Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.
Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.
Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).
Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.
Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
The new tables (mig 136) and the dual-write that keeps them in sync
(B.2) have been steady-state in prod since mig 136 deployed at
13:24 UTC today. Drift verified clean before this commit:
deadline_rules=231, sequencing_rules=231, procedural_events=231 (153
codes + 78 synthetic), legal_sources=87, zero mismatches across
counts, FK integrity, lifecycle, is_active.
This commit flips READ paths to source data from the new tables via
a backwards-compatible view, leaving the dual-write WRITE paths
untouched for B.4 to retire alongside the destructive drop.
* internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) —
CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls
back into the legacy paliad.deadline_rules column shape. Same
column names + types so the Go-side change is a 1-token
substitution per query with no struct or scanner edits.
Post-apply DO block asserts view row count = sequencing_rules row
count (FK NOT NULL on procedural_event_id guarantees they match).
* 10 service / handler files — every SELECT FROM paliad.deadline_rules
(or JOIN paliad.deadline_rules) flipped to use the view:
- internal/handlers/submissions.go (Schriftsätze list)
- internal/services/deadline_rule_service.go (8 read sites)
- internal/services/rule_editor_service.go (3 read sites — ListRules, getByID, validateSpawnNoCycle)
- internal/services/rule_editor_orphans.go (candidate-rule lookup)
- internal/services/submission_vars.go (loadPublishedRule)
- internal/services/deadline_service.go (deadlines list join)
- internal/services/fristenrechner.go (calculator reads)
- internal/services/projection_service.go (projection reads)
- internal/services/event_deadline_service.go (event→rule join)
- internal/services/export_service.go (3 export sites — ref__deadline_rules)
Verified semantically safe on live (read-only smoke):
- 231 rows in view match 231 in legacy.
- name + event_type pair: 231/231 match.
- legal_source: 231/231 match (NULL on both sides treated as match).
- submission_code: 153 non-NULL codes match exactly; the 78
synthetic 'null.<8hex>' codes diverge from legacy NULL but no
reader filters on NULL submission_code (verified
handlers/submissions.go: synthetic-code rules all have NULL
event_type so the WHERE event_type = 'filing' filter excludes
them; the Schriftsätze surface returns the same 105 rows).
Scope decisions documented (deviation from design §5.3):
- B.3 ships the READ flip only. WRITE paths (RuleEditorService
Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle)
retain the dual-write from B.2 — they write to both legacy and
new tables. B.4 (destructive drop) will retire the legacy writes
in the same slice that drops the table, avoiding a transient
state where the legacy writes have no purpose.
- The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays
active for the same reason: dual-write continues, so the
invariants the loop checks remain meaningful.
This shape is paliadin-approvable on a "good solution > strict
phase boundary" reading of m's greenlight. If paliadin pushes back
and wants the legacy writes removed in B.3, the refactor is ~300
LOC across the 5 RuleEditorService write methods + buildPatchSets
split into PE/SR sets — schedulable as B.3.5 before B.4.
Build + vet clean. TestMigrations_NoDuplicateSlot passes.
Frontend half of the four Verfahrensablauf appeal bugs.
Bug 1 (frontend half) — Side selector dead on appeal. The column
bucketer now reads dl.appealRole (engine-stamped under
appeal_target) and routes each "both" appeal rule via the user
side: side=claimant maps the user to the appellant, so appellant
filings land in 'ours' and appellee filings in 'opponent';
side=defendant mirrors. side=null keeps the legacy mirror so every
appeal rule renders in both columns (every-rule-visible behaviour
the brief calls out). The new appealAware opt gates the path so
non-appeal proceedings keep their existing bucketing untouched.
Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal
routing is now per-rule via appealRole, not a page-level appellant
collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals)
keep the appellant axis since they have no appeal_target metadata.
Bug 3 — Duration label appends parent name. formatDurationLabel now
takes an optional parent fallback and renders "<n> <unit> <timing>
<parent>". deadlineCardHtml resolves the parent per-rule
(dl.parentRuleName / EN variant), falling back to opts.trigger
EventLabel for root rules with a non-zero duration (e.g.
Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns
Body + renderTimelineBody auto-derive the trigger event label from
the response via the new pickTriggerEventLabel helper unless the
caller passes one explicitly.
Bug 4 — Duration prefix stripped from deadline_notes. New
stripLeadingDurationFromNotes regex peels off leading
"Frist N <unit> <vor|nach|ab|seit> …. " (DE) and
"<N>-<unit> period from …" / "N <unit> BEFORE …" / "Period is N
<unit> from …" (EN) up to the first sentence boundary. Wired into
deadlineCardHtml so noteHint + notesBlock both render the deduped
text. Per the brief's option (a): conservative regex, composite
durations with "ODER" / "whichever is the longer" stay untouched
as a follow-up editorial cleanup. deadline_rules DB untouched.
Tests: 22 new test cases across appeal-aware bucketing,
formatDurationLabel parent append, deadlineCardHtml duration
tooltip resolution, and stripLeadingDurationFromNotes regex
(positive + negative + composite + EN/DE variants). All 209
frontend tests pass.
Engine wire fields added in the preceding commit (AppealRole,
IsTriggerEvent). Reads them from CalculatedDeadline without
breaking the wire contract for non-appeal callers.
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136.
Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set,
Calculate now prepends a synthetic TimelineEntry to the deadlines slice
dated to the trigger date, carrying the per-appeal-target label from
TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten-
entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked
IsRootEvent + IsTriggerEvent + party=court + priority=informational
so the frontend renders it as a dimmed anchor card without a save
button / choices caret / click-to-edit affordance. Empty Code so it
doesn't collide with real rule UUIDs downstream.
Bug 1 (engine half) — Side selector dead on appeal. Every appeal
filing rule carries primary_party='both' in the catalog, so the
column bucketer couldn't distinguish Berufungskläger vs Berufungs-
beklagter filings from primary_party alone. Engine now stamps the
new TimelineEntry.AppealRole field with appellant/appellee from the
rule-semantic AppealFilerRole mapping (appeal_role.go) when an
appeal_target is in scope. The frontend half of the fix (next commit)
consumes this to route each "both" rule into the user-perspective
column once the user picks a side.
Mapping covers all 12 appeal filing rules across the three
applies_to_target tracks (endentscheidung/schadensbemessung,
kostenentscheidung, anordnung/bucheinsicht). Court-issued events
(merits.decision, merits.oral, cost.decision, order.order) stay
empty — they continue to route on Party='court'. Unmapped
submission_codes return empty so a new appeal rule we forgot to map
falls through to the bucketer's legacy path rather than silently
picking a side.
Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal
SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole
stamped when target is set, (b) no synthetic row + no AppealRole
when target is unset (regression guard), (c) unknown target
short-circuits to no-op. Existing tests untouched — both behaviours
gate on opts.AppealTarget != "".
No DB migration — the bugs are calc-side. deadline_rules untouched.
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.
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
Q1 composition: primary + spawned (v1); multi-proceeding peer
compose is the v2 goal (spec.proceedings[] array)
Q2 scope: per-project + abstract (project_id NULL = abstract)
Q3 trigger: per-anchor overrides over one base date
Q4 storage: NEW paliad.scenarios table with jsonb spec
(NOT a project_event_choices column extension)
Migration 145 — additive only. Pre-flight coordination check:
- On-disk max: 138 (Berufung backfill, just merged).
- Live DB tracker: 106 (significantly behind — many migs pending
deploy).
- curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
as buffer; claimed 145 as the safe minimum that won't collide.
- paliad.scenarios has audit_reason NOT applicable (no audit
trigger on the table); updated_at trigger added on the table
itself.
- paliad.projects gains active_scenario_id uuid NULL FK with ON
DELETE SET NULL (mig 134 lesson — no updated_at clauses on
proceeding_types-style assumptions).
Schema:
paliad.scenarios (
id uuid pk,
project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
name text NOT NULL CHECK char_length > 0,
description text NULL,
spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
created_by uuid NULL FK → users(id) ON DELETE SET NULL,
created_at + updated_at timestamptz,
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
);
paliad.projects.active_scenario_id uuid NULL FK;
RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
Trigger: scenarios_touch_updated_at_trg.
pkg/litigationplanner additions:
- Scenario struct (db + json tags)
- ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
view of the jsonb (version-1 today, v2 multi-peer-ready)
- ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
- ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
- CalculateFromScenario(scenario, catalog, holidays, courts) — high-
level engine entry: parses spec → builds CalcOptions → delegates
to Calculate
- Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
ErrScenarioNoPrimary
paliadCatalog impl:
- LoadScenarios with progressively-built WHERE clauses (project-id
filter, abstract-for-user filter, or all)
- MatchScenario by id — returns ErrUnknownScenario on not-found
- Services connection bypasses RLS; ScenarioService enforces
visibility at the application layer (mirrors EventChoiceService
pattern from t-paliad-265)
SnapshotCatalog impl (embedded/upc):
- LoadScenarios returns empty slice (no scenarios in the snapshot)
- MatchScenario returns ErrUnknownScenario
internal/services/scenario_service.go:
- Create / Get / ListForProject / ListAbstractForUser / Patch /
SetActive / Delete with visibility checks
- validateSpec checks version, base_trigger_date format, every
proceedings[*].code resolves to an active paliad.proceeding_types
row, every appeal_target is valid, every anchor_overrides date
parses, every role ∈ {primary, peer}
- SetActive validates the scenario belongs to the requested project
(a scenario from a different project can't be active here)
- Returns ErrScenarioNotVisible for failed visibility checks
REST endpoints (registered in handlers.go):
GET /api/scenarios?project=<id> — list project's
GET /api/scenarios?abstract=true — list user's abstract
GET /api/scenarios/{id} — one
POST /api/scenarios — create
PATCH /api/scenarios/{id} — partial update
DELETE /api/scenarios/{id} — remove
PUT /api/projects/{id}/active-scenario — set / clear active
Handler error mapping:
- ErrUnknownScenario / ErrScenarioNotVisible → 404
- ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
- everything else → 500
Tests:
- pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
(well-formed + unknown version + malformed json),
PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
unpack, trigger_date_override path, no-base-trigger safety check.
8 cases total, all DB-free.
Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).
Acceptance:
- go build ./... clean
- go test ./... all green (incl. new scenarios tests)
- Pre-flight audit confirmed mig 145 number is safe vs curie's
pending B.2-B.6 range
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'.
After Slice B1's Berufung unification (mig 134), the picker exposed
five appeal targets but only three carried rules. Schadensbemessung and
Bucheinsicht returned empty timelines.
m's 2026-05-26 decision (#134): R.224 is uniform across substantive
R.118 decisions, and R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
uniform across the orders they appeal — so the existing merits-track
and order-track rules can carry the missing targets via a non-destructive
applies_to_target extension.
Audit of live `paliad.deadline_rules` for upc.apl.unified (proceeding_type_id=160):
- 7 endentscheidung rules → extend with 'schadensbemessung'
- 7 anordnung rules → extend with 'bucheinsicht'
- 2 kostenentscheidung rules — untouched (distinct leave-to-appeal track)
Migration:
- set_config('paliad.audit_reason', …) at top of UP and DOWN — required
by the mig 079 deadline_rule_audit_trigger on every UPDATE.
- Audit-first DO block lists every row to be touched (pre/post state)
and RAISE EXCEPTIONs on pre-condition drift (missing proceeding_type,
wrong rule counts, partial-run carry-over of the new targets).
- Two narrow UPDATEs keyed off upc.apl.unified + existing target +
absence of new target.
- Post-sanity asserts schad=7, buch=7, end=7, anord=7, cost=2 — hard
RAISE EXCEPTION on any drift.
- DOWN strips both new targets via array_remove with the same WHERE.
- No deadline_rules.updated_at writes; column exists but the migration
is single-purpose and leaves it as-is.
Dry-run via Supabase MCP confirmed:
- UP yields {schad:7, buch:7, end:7, anord:7, cost:2} on prod.
- DOWN restores {schad:0, buch:0, end:7, anord:7, cost:2}.
- DB returned to pre-state; the real golang-migrate boot path will
apply 138 cleanly at next deploy.
Version bump 137→138: cronus's mig 137 (proceeding_role_labels, #132)
merged to main while this branch was in flight. Rebased onto current
main, renamed files, rewrote all "mig 137" references inside the SQL +
test code.
Test:
- lookup_events_test.go: the schadensbemessung empty-result assertion
becomes the inverse (rules expected). Adds a parallel bucheinsicht
assertion. Same anchor-row shape check as the existing endentscheidung
case (DepthFromAnchor=1, target ∈ AppliesToTarget, proceeding_type
= upc.apl.unified).
- `go test ./...` green post-rebase, including pkg/litigationplanner/
appeal_target_label_test.go added by cronus's mig 137.
Refs: m/paliad#134, t-paliad-303.
Lessons applied from mig 134 hotfixes: audit_reason set_config, no
updated_at writes, audit live DB before drafting, RAISE EXCEPTION on
integrity violations.
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).
Two bugs from the Slice B1 Berufung rollout, one fix surface:
Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).
Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.
Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
- ADD COLUMN role_proactive_label_de (text NULL)
- ADD COLUMN role_proactive_label_en (text NULL)
- ADD COLUMN role_reactive_label_de (text NULL)
- ADD COLUMN role_reactive_label_en (text NULL)
- Audit-first DO block lists the rows the UPDATE will touch.
- Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
and the renderer falls back to default labels.
- Down drops the 4 columns.
Package additions (pkg/litigationplanner):
- ProceedingType gains 4 *string fields (RoleProactive/Reactive
LabelDE/EN) — db tags match the new columns; existing scans pick
them up via the proceedingTypeColumns extension.
- TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
the 5 appeal-target slugs to their DE/EN trigger-event labels.
Empty result on unknown target signals "fall back to proceeding's
own trigger_event_label".
- Engine override: when CalcOptions.AppealTarget is set, the
resulting Timeline.TriggerEventLabel/EN are replaced from the
per-target map.
Frontend:
- Removed #appellant-row div (was a separate 3-radio selector
duplicating side).
- Dropped ?appellant= URL state + the change handler + the init
readback. The engine still consumes "appellant" — sourced from
currentSide for role-swap proceedings; null otherwise.
- applyRoleLabels(proceedingType) swaps the side-row radio labels
from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
Falls back to deadlines.side.claimant/defendant i18n keys for
proceedings without overrides.
- syncTriggerEventLabel reads data.triggerEventLabel from the calc
response — which the engine override now sets per appeal_target,
so no client-side mapping needed.
- i18n cleanup: removed orphan deadlines.appellant.* keys (label /
claimant / defendant / none) in both DE + EN.
Tests:
- pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
label matrix + a coverage test that fails if a new entry in
AppealTargets is added without populating the label switch.
Acceptance:
- go build + go test all green (incl. new lp test).
- bun run build clean (i18n codegen drops 4 keys, regenerates).
- Live-DB audit before drafting confirmed: 4 target columns don't
exist on proceeding_types, zero triggers on the table, exact
column inventory matches the design.
paliad.proceeding_types has no updated_at column. Removing the
UPDATE ... SET ..., updated_at = now() clause from both up and down
migrations. Third bug in cronus's Slice B1 mig 134 — production
still down.
Verified columns on paliad.proceeding_types via prod-snapshot.sql:
id, code, name, description, jurisdiction, category, default_color,
sort_order, is_active, name_en, display_order, trigger_event_label_de,
trigger_event_label_en, appeal_target (added by this mig).
Refs t-paliad-292, m/paliad#124. No new issue filed — single-line
emergency fix during head's incident response.
Mig 134's step 4 UPDATEs paliad.deadline_rules to reassign 16 rules
to the unified upc.apl.unified proceeding_type. The mig-079 audit
trigger requires set_config('paliad.audit_reason', …, true) before
any mutation — mig 134 missed it, causing the migration runner to
abort with P0001 "audit reason required for UPDATE" on every boot
after #130 landed.
Adds the canonical set_config call at the top of both up + down,
matching the pattern from mig 082, 099, 100, 103, 106, 110, 127, 129.
Creates the three new tables that split today's paliad.deadline_rules
into its three latent concepts, plus two nullable link columns on
paliad.deadlines for B.2 dual-write.
ADDITIVE ONLY. paliad.deadline_rules is untouched. deadlines.rule_id
stays in place — it remains the authoritative deadline → rule link
until B.3 cutover flips reads and B.4 drops the legacy table.
* paliad.legal_sources — distinct citations (87 rows backfilled).
pretty_de/pretty_en deferred (Go
legalSourcePretty still computes them
on read; future slice backfills).
* paliad.procedural_events — 153 rows from distinct submission_codes
+ 78 synthetic-code rows for the
NULL-submission_code branch (m's pick
via paliadin 2026-05-26: mint
'null.<8hex>' codes so every rule row
has a procedural event, preserving the
NOT NULL FK on sequencing_rules).
* paliad.sequencing_rules — 1:1 with deadline_rules (231 rows). id
inherited from deadline_rules.id so any
existing deadlines.rule_id FK resolves
transitively to the new sequencing_rule
during the dual-write window.
* paliad.deadlines.procedural_event_id, sequencing_rule_id (nullable,
backfilled by JOIN on the inherited id).
Audit-first pattern (mirrors mig 135): PRE pass counts what we're about
to backfill + refuses to run if multi-row submission_codes have crept
back in (B.0 found zero; the assertion guards against a future
re-archival or rule-editor bug). POST pass asserts the four
invariants — procedural_events count, sequencing_rules 1:1,
legal_sources distinct-citation match, FK integrity — and RAISE
EXCEPTIONs on any mismatch so the transaction rolls back cleanly.
Design deviations from §4.1 (documented in the migration header):
- procedural_events.event_kind is NULLABLE. 89 live rules have NULL
event_type today (structural / parent-only rows in the proceeding
tree). Tightening to NOT NULL with 'other' fallback would lose
semantics; a later slice can do it after reclassification.
- legal_sources.pretty_de / pretty_en are NULLABLE. Materialising them
requires the Go-side legalSourcePretty(); deferred to a Go-driven
slice. Read path keeps computing them from the citation in the
meantime.
- submission_drafts is NOT modified (instruction scope is explicit:
tables + deadlines columns only).
Down migration: drops the two deadlines columns first, then
sequencing_rules → procedural_events → legal_sources in FK-safe
order. No data loss possible (deadline_rules is the source of truth
through B.3).
Test: internal/db/migration_136_test.go restates the four
invariants in Go so they survive PL/pgSQL refactors. Skipped without
TEST_DATABASE_URL.
Verified on live (read-only): 153 distinct codes + 78 distinct
synthetic-code candidates = 231 = deadline_rules row count. 87
distinct legal_sources. Zero 8-hex synthetic-code collisions in the
live UUIDs.
Hard-stop: B.2 dual-write requires explicit m greenlight before
RuleEditorService starts writing to the new tables. B.4 destructive
drop additionally requires m's downtime window + a
paliad.deadline_rules_pre_<N> snapshot in the same migration.
Lays the foundation for youpc.org's cross-repo integration: an
in-package UPC subset of paliad's deadline corpus, embedded as JSON,
that any consumer can use to run the litigationplanner engine without
DB access.
Generator (cmd/gen-upc-snapshot):
- Reads paliad's live DB (DATABASE_URL), applies pending migrations
to match schema HEAD, SELECTs the UPC subset
(proceeding_types WHERE jurisdiction='UPC' AND is_active=true,
deadline_rules WHERE lifecycle_state='published' AND is_active=true
on those proceedings, referenced trigger_events, DE+UPC holidays,
UPC courts).
- Writes pretty-printed JSON to
pkg/litigationplanner/embedded/upc/{proceeding_types, rules,
trigger_events, holidays, courts, meta}.json.
- Idempotent — same DB state → same output (modulo
meta.generated_at + auto-versioned suffix).
- Date-stamped versioning (YYYY-MM-DD-N) with same-day suffix bump.
- Operator runbook in cmd/gen-upc-snapshot/README.md.
Embedded subpackage (pkg/litigationplanner/embedded/upc/):
- embed.go — //go:embed *.json + LoadMeta()
- snapshot.go — SnapshotCatalog (full lp.Catalog impl: LoadProceeding
/ LoadProceedingByID / LoadRuleByID / LoadRuleByCode /
LoadRulesByTriggerEvent / LoadTriggerEventsByIDs / LookupEvents);
O(1) map lookups; LookupEvents linear over the < 100-row UPC corpus.
- holidays.go — SnapshotHolidayCalendar implementing lp.HolidayCalendar
(IsNonWorkingDay / Adjust* with structured AdjustmentReason).
- courts.go — SnapshotCourtRegistry implementing lp.CourtRegistry.
- Compile-time assertions (_ lp.X = (*Snapshot*)(nil)) catch
interface drift.
Wire-up for consumers:
cat, _ := upc.NewCatalog()
hc, _ := upc.NewHolidayCalendar()
cr, _ := upc.NewCourtRegistry()
timeline, _ := lp.Calculate(ctx, "upc.inf.cfi", "2026-05-26",
lp.CalcOptions{}, cat, hc, cr)
Tests (snapshot_test.go, all DB-free):
- meta parses cleanly, non-zero counts
- LoadProceeding(upc.inf.cfi) returns expected proc + rules
- LoadProceeding(unknown) returns ErrUnknownProceedingType
- LookupEvents(Jurisdiction:UPC, all-following) covers corpus
- LookupEvents(party=defendant, next) scopes anchors correctly
- engine end-to-end via lp.Calculate against the embedded snapshot
- holiday calendar (weekends, DE closures, UPC vacation block)
- court registry (empty courtID fallback, known + unknown court)
Placeholder data shipped (2 proceedings, 2 rules, 5 holidays, 2
courts) so tests run without a live DB. Operator regenerates against
prod via `make snapshot-upc` once migrations 134 (B1) and 135 (B3)
have landed on prod — see cmd/gen-upc-snapshot/README.md for the
runbook. The placeholder's meta.version is suffixed `-placeholder`
to make the regeneration delta obvious.
Makefile target:
make snapshot-upc — wraps the generator + reruns the snapshot tests
Design (§19 of docs/design-litigation-planner-2026-05-26.md):
- Embedding format: go:embed JSON (diff-friendly, no compile coupling)
- Generator entry: cmd/gen-upc-snapshot/main.go (idiomatic Go cmd path)
- Versioning: meta.json carries semver + generated_at + paliad_commit
- Regeneration: manual via Make target or `go generate`; no CI cron in v1
- Out of scope: snapshot signing, DE/EPA/DPMA snapshots, snapshot
diff tooling
Acceptance:
- go build clean, go test all green (incl. 6 new tests in
pkg/litigationplanner/embedded/upc, all DB-free)
- SnapshotCatalog passes the compile-time lp.Catalog assertion
- Generator binary builds + runs (Idempotence verified by re-running
against the same source data)
mig 134 was inserting code='upc.apl' (2 segments) into paliad.proceeding_types,
which carries paliad_proceeding_code_shape CHECK requiring 3 dot-segments OR
'^_archived_'. Every container restart hit the constraint, rolled the migration
TXN back, and crash-looped paliad.de.
Rename the unified Berufung code to 'upc.apl.unified' (3 segments, satisfies the
constraint, preserves design intent). The pre-existing constraint is a useful
jurisdiction.category.specific invariant — keep it, fix the new row.
Touched only string literals:
- mig 134 up.sql + down.sql (insert, lookups, post-checks)
- frontend/src/verfahrensablauf.tsx (UPC_TYPES code + i18nKey)
- frontend/src/client/verfahrensablauf.ts (APPELLANT_AXIS + APPEAL_TARGET sets)
- frontend/src/client/i18n.ts (DE + EN translation rows)
- frontend/src/i18n-keys.ts (auto-regen via bun build)
- internal/services/lookup_events_test.go (anchor-row assertion)
Verified: `grep -rn "'upc\.apl'\|\"upc\.apl\""` returns zero hits.
go build, bun run build, go test ./... all green.