Phase 3 Slice 10 staging table for the fuzzy-match orphans mig 090
produces (design §3.I + m's Q10 ruling). Each legacy deadline that
the matcher can't uniquely bind to a deadline_rule logs here with
the full candidate list so a legal-review pass can hand-link the
ambiguous tail without rerunning the match.
Schema:
- deadline_id FK to paliad.deadlines (ON DELETE CASCADE).
- title + project_id + proceeding_code denormalised so the admin
orphan-review UI groups + filters without re-joining.
- reason text CHECK in ('no_match', 'ambiguous', 'no_project',
'manual_unbound'). Mig 090 writes the first two; the editor
surface (Slice 11) may add the others.
- candidate_count + candidate_rule_ids carry the full list of
plausible rules so the legal-review UI can render "pick one"
chips from the matcher's actual output.
- resolved_at + resolved_rule_id flip when an editor binds the
row via the admin UI; the matching paliad.deadlines.rule_id
UPDATE happens at the same time. Both rows hold so the staging
table doubles as an audit trail of the legal-review pass.
Indexes:
- deadline_id for the per-deadline lookup the admin UI uses.
- unresolved_at DESC for the "open orphans" list (the only one
the legal-review UI typically lists).
RLS: admin-only read. The orphan list contains real deadline titles
+ project ids, so non-admins must not see it. Service-layer surfaces
(Slice 11) gate further.
Mig 089 ships the table; mig 090 does the fuzzy-match backfill +
populates this table. Numbering reflects the dependency order (the
backfill SELECTs INTO this table, so the table must exist first).
Phase 3 Slice 5 Step F-2. BEFORE INSERT/UPDATE trigger on
paliad.projects rejects any write that binds proceeding_type_id to a
non-fristenrechner-category proceeding_types row. NULL is allowed.
PostgreSQL CHECK constraints can't reference other tables, so this
is the only way to evaluate the (proceeding_types.category =
'fristenrechner') predicate per row without restructuring the
existing FK relationship.
Trigger trades narrower FK + partial-unique-index approach for
keeping the existing schema reference (mig 027) untouched. Slice 9
or later may drop this trigger when the litigation category is
fully retired.
Error message is bilingual (German + English) so the Go handler can
either surface it verbatim OR — preferably — intercept the typed
service error first and emit a clean i18n string. mig 088 is
defence-in-depth; the Go service-layer validation is the primary
path.
Idempotent: CREATE OR REPLACE FUNCTION + DROP TRIGGER IF EXISTS
before CREATE TRIGGER.
Phase 3 Slice 5 Step F-1 (design §3.F, m's Q2 ruling). UPDATE any
paliad.projects row still pointing at a litigation-category code
to the fristenrechner-category equivalent:
INF → UPC_INF (UPC infringement, canonical reading)
REV → UPC_REV
APP → UPC_APP
CCR → NULL (no UPC_CCR — flag for legal review)
APM → NULL (no UPC_APM)
AMD → NULL (no UPC_AMD)
ZPO_CIVIL → NULL (no fristenrechner analogue)
Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
today, so this migration touches zero production rows. Ships
defensively for any future test / staging / imported data.
NULL-remaps write a paliad.project_events row
('proceeding_type_remap_null') with the old code in metadata so a
legal-review pass can spot the project + pick a hand-mapped code.
Idempotency: WHERE pt_old.category = 'litigation' AND pt_old.code IN
(...). Re-running on a clean target is a no-op.
Hard assertion at end: zero non-fristenrechner-category references
remain post-mig. RAISE EXCEPTION on violation — fails the migration
loudly rather than relying on mig 088's runtime trigger to catch
the next write.
Audit-reason wrapper cites design §3.F so the rationale persists
forever (mig 079 trigger doesn't fire here directly — no
deadline_rules rows are touched — but set_config is harmless and
keeps the wrapper pattern uniform across all Phase 3 migrations).
Phase 3 Slice 3 cutover-window guard. BEFORE INSERT/UPDATE/DELETE
trigger on paliad.event_deadlines raises EXCEPTION with a message
pointing the writer at paliad.deadline_rules. SELECT remains
unaffected.
Why: Slice 3 just moved 77 rows into the unified backend (mig 085).
Until Slice 4 cuts every reader over and Slice 9 drops the legacy
table, the two sides must not diverge. Letting any write through
event_deadlines would silently regress "Was kommt nach…" parity.
Supabase service_role bypasses RLS but NOT triggers — direct DB
maintenance (psql, migration scripts, MCP) is also blocked. That's
intentional: every further edit to event_deadlines pre-Slice-9 is a
mistake. Slice 9's mig ~090 will drop the table + this trigger
together as part of the legacy cleanup.
Function is plain (not SECURITY DEFINER): the trigger function only
RAISE EXCEPTIONs, no INSERTs anywhere, so it doesn't need elevated
privileges. Caller's RLS / role context doesn't matter — the raise
fires unconditionally before any tuple lock is taken.
Phase 3 Slice 3 Step C (design §3.C). INSERT 77 active rows from
paliad.event_deadlines into paliad.deadline_rules so the unified
backend can serve both pipelines. Source rows preserved (mig 086
wraps the source table in a read-only trigger; Slice 9 drops it).
Mapping:
trigger_event_id ← event_deadlines.trigger_event_id (bigint, mig 028)
name (DE, NOT NULL) ← event_deadlines.title_de (NOT NULL DEFAULT '')
name_en (NOT NULL) ← event_deadlines.title (EN, NOT NULL)
duration_value / unit ← event_deadlines.duration_value / unit
timing ← event_deadlines.timing (before / after)
alt_duration_value / unit ← event_deadlines.alt_duration_*
combine_op ← event_deadlines.combine_op (mig 078 column)
deadline_notes (DE) ← event_deadlines.notes (DE; NULLIF '' so empty
stays NULL on dr side)
deadline_notes_en ← event_deadlines.notes_en (mig 036)
legal_source ← event_deadlines.legal_source
published_at ← event_deadlines.created_at (chronological audit)
sequence_order = 1000 + ed.id (large offset so Pipeline-C rules
sort after any hand-authored
Pipeline-A sequence_orders; preserves
source ordering within Pipeline C)
lifecycle_state = 'published' / priority = 'mandatory' / is_active = ed.is_active
Pipeline-A-only fields stay NULL on the new rows: proceeding_type_id,
parent_id, spawn_proceeding_type_id, code, primary_party, event_type,
condition_expr, condition_flag. is_court_set = false (no court-set
rules in the Pipeline-C corpus today; legal-review pass can flip
Zustellung-* later via a separate slice).
Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name).
Re-running the migration is a no-op.
Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
IS NOT NULL) must equal COUNT(event_deadlines WHERE is_active=true)
post-mig. RAISE EXCEPTION on mismatch — better to fail the migration
loudly than to ship a partial Pipeline-C corpus and poison Slice 4.
Audit-reason set via set_config so the mig 079 trigger writes 77
paliad.deadline_rule_audit rows with the design §3.C citation
preserved as the rationale. That's the persistent compliance trail
for the data-move.
No mandatory bool on event_deadlines (the head instruction sketch
suggested mapping it; the schema doesn't have one) — Pipeline-C
rules default priority='mandatory', consistent with the statutory
nature of the corpus.
Phase 3 Slice 2 Step B-3. Convert condition_flag text[] →
condition_expr jsonb per DESIGN §2.4 long form (NOT msg 1746's
short {"and":[...]} form — head clarified in msg 1750 that
design §2.4 wins because long form parses uniformly across
and/or/not, matching what the Slice-4 calculator + Slice-11 rule
editor will emit).
Mapping:
['with_ccr'] → {"flag":"with_ccr"} (5 rows)
['with_amend'] → {"flag":"with_amend"} (4 rows)
['with_cci'] → {"flag":"with_cci"} (4 rows)
['with_ccr', 'with_amend'] → {"op":"and","args":[
{"flag":"with_ccr"},
{"flag":"with_amend"}
]} (4 rows)
NULL or {} → NULL (155 rows)
Total translated: 17 rows.
Single-flag is unwrapped (no AND wrapper) per design §2.4 — a
shortcut equivalent to a 1-arg AND that saves a layer of nesting
without losing semantics. The calculator's parser treats
{"flag":"<name>"} as the leaf and {"op":"<and|or|not>","args":[…]}
as the canonical boolean node.
jsonb construction uses jsonb_build_object + a LATERAL unnest…WITH
ORDINALITY over the flag array so args[] order matches the source
array exactly (load-bearing if a future migration adds order-
sensitive ops).
Idempotent via WHERE condition_expr IS NULL — re-running doesn't
double-write audit rows for already-translated rules. Migration
ends with a DO block that RAISE EXCEPTION if any non-empty
condition_flag row still has NULL condition_expr (catches a
broken translation path before it reaches Slice 4).
Phase 3 Slice 2 Step B-2. UPDATE paliad.deadline_rules.priority
from the legacy (is_mandatory, is_optional) pair per DESIGN §2.3
(NOT msg 1746's inverted mapping — head clarified in msg 1750
that design §2.3 is the load-bearing spec).
Mapping:
T/F (153 rows) → 'mandatory' (statutory must, ☑ pre-checked)
T/T ( 1 row) → 'optional' (RoP.151 — opt-in deadline,
☐ pre-unchecked per mig 068)
F/T ( 0 rows) → 'recommended' (defensive; no live data)
F/F ( 18 rows) → 'recommended' (situational filings —
Berufungserwiderung, Replik,
Duplik, R.19 Preliminary
Objection, R.116 EPÜ, etc.)
Why NOT msg 1746's mapping:
- T/T → 'recommended' would PRE-CHECK RoP.151 in the save modal
and auto-create a Kostenentscheidung deadline the user didn't
ask for. That's the regression we'd ship.
- F/F → 'informational' would render 18 real filing deadlines
NEVER-SAVEABLE per design §2.3 ("informational … NEVER saves
as a deadline"). They'd disappear from save flows entirely.
T/F branch is intentionally skipped — mig 078 already defaults
priority='mandatory', so all 153 T/F rows are already correct.
Writing 153 needless audit rows would dilute the backfill trail.
Audit-reason cites design §2.3 — that's the persistent rationale
captured in paliad.deadline_rule_audit. Migration enforces NOT NULL
post-run via a DO block that RAISE EXCEPTION on stragglers.
Phase 3 Slice 2 Step B-1 (design §3.B). UPDATE paliad.deadline_rules
to set is_court_set=true where the live isCourtDeterminedRule()
heuristic returns true:
primary_party = 'court'
OR event_type IN ('hearing', 'decision', 'order')
Expected delta on the production corpus: 47 rows flipped false→true
(every primary_party='court' rule overlaps with a court event_type
in the current data, so the two predicates fully overlap at 47).
Replicates the live fristenrechner.go body EXACTLY, not the
ILIKE-padded sketch in msg 1746. Per head's ruling msg 1750:
padding with '%entscheidung%' / '%urteil%' would mis-flag party
filings like RoP.151 (Antrag auf Kostenentscheidung) and § 83 PatG
(Stellungnahme zum Hinweisbeschluss) as court-set. They aren't —
only their anchors are.
Audit footnote: ~8 'Zustellung…' rules (LG-Urteil, OLG-Urteil,
BPatG-Entscheidung, Beschwerdeentscheidung, DPMA-Entscheidung)
carry primary_party='both' + event_type='filing'. Semantically the
Zustellung date IS court-set; flagging them is left to the legal-
review pass mentioned in design §2.3, not this slice.
Idempotent via WHERE is_court_set = false. Audit-reason is set via
set_config('paliad.audit_reason', …, true) so the mig 079 trigger
captures one paliad.deadline_rule_audit row per flipped rule —
the persistent backfill trail.
Mig 081 was reserved for proceeding_types display_order verification
in design §3.1; it was a no-op and was not authored. Tracker
skips 081, advances 80 → 82. golang-migrate handles non-contiguous
numbers fine as long as the order ascends.
Phase 3 Slice 1, design §2.7 + §7. Adds a nullable text column
gated by a CHECK to 'first' | 'appeal' | 'cassation'. Combined
with proceeding_code + jurisdiction, the FristenrechnerService
(Slice 8) will derive the effective proceeding code — e.g.
DE_INF + appeal → DE_INF_OLG.
No backfill in this slice. The project-detail picker UI (Slice 8)
writes the column; pre-Slice-1 rows stay NULL and behave as
implicit 'first' in the calculator's fallback.
Phase 3 Slice 1 audit-log foundation (design §2.8). The audit log
lands BEFORE the rule editor (Slice 11) so every future write to
paliad.deadline_rules is captured — including the Slice 2
backfill UPDATEs.
paliad.deadline_rule_audit columns mirror design §2.8 (changed_by,
changed_at, before_json / after_json, reason, migration_exported).
Two intentional deviations, documented inline:
1. changed_by is nullable, not NOT NULL. Trigger reads auth.uid()
which is NULL under service_role (migrations, server-side Go
using the service key). NOT NULL would block Slice 2 backfills
and every seed insert.
2. action values written by the trigger are 'create'|'update'|
'delete' (raw TG_OP). Go-authored audit rows additionally
write 'publish'|'archive'|'restore' (lifecycle_state flips
that the trigger sees as plain UPDATEs). The audit UI in
Slice 11 collapses the paired rows.
Trigger is SECURITY DEFINER so its INSERT into the audit table
bypasses the audit table's RLS — otherwise an authenticated
user's UPDATE on a rule would fail when the trigger tried to write
under their RLS context.
Audit-reason enforcement: trigger reads paliad.audit_reason via
current_setting(..., true) and raises EXCEPTION on UPDATE/DELETE
when unset. INSERT defaults to 'create' so seed migrations stay
ergonomic.
RLS: SELECT for global_admin only (mirrors mig 057 pattern). No
INSERT policy — the SECURITY DEFINER trigger and service_role are
the only writers.
Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:
trigger_event_id (bigint, FK trigger_events.id)
spawn_proceeding_type_id (int, FK proceeding_types.id)
combine_op (text, CHECK 'max'|'min')
condition_expr (jsonb)
priority (text, DEFAULT 'mandatory', 4-way CHECK)
is_court_set (bool, DEFAULT false)
lifecycle_state (text, DEFAULT 'published', 3-way CHECK)
draft_of (uuid, self-FK)
published_at (timestamptz)
FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.
FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.
Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE
SET NULL) plus a partial index. A trigger function rejects two-level CCR
chains: a project with counterclaim_of NOT NULL cannot be the target of
another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at
the schema level rather than defending in the application layer.
ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR
sub-projects against a parent) and CreateCounterclaim (atomic: project
row + creator-as-lead team membership + audit rows on parent AND child).
The CCR child is placed as a sibling under the same patent (§4.4), our
side flips claimant↔defendant by default with a "Stimmt nicht?" override
for the R.49.2.b CCI edge case, and the proceeding type defaults to
UPC_REV. Title auto-suggests from the patent ancestor's patent_number
when available.
Tracker advances 76 → 77.
Slice 2 of the SmartTimeline (docs/design-smart-timeline-2026-05-08.md
§6 + §9 + §10) bundled with m/paliad#31's layered requirements:
Migration 076:
- appointments.deadline_rule_id nullable FK to deadline_rules + partial idx
- deadlines.source CHECK widened to include 'anchor' (alongside existing
'manual','fristenrechner','rule','import').
ProjectionService (extended):
- Wires FristenrechnerService + DeadlineRuleService.
- For() now emits Kind="projected" rows for any rule lacking a matching
paliad.deadlines.rule_id / appointments.deadline_rule_id row, with
Status in {predicted | predicted_overdue | court_set}.
- Lookahead cap (default 7, override via ?lookahead=N, max 50): future
predicted rows beyond N are dropped; predicted_overdue + court_set
rows are exempt from the cap (#31 layer 1).
- Dependency annotations DependsOnRuleCode/Date/Name on every row that
carries a DeadlineRuleID, walked from the rule's parent_id chain
(#31 layer 2). Date prefers actuals over projections.
- AnchorOverrides built from completed deadlines (completed_at /
status='completed') + appointments tied via deadline_rule_id.
- triggerDate derives from the proceeding's root rule's anchor when
present, else today() as placeholder.
Anchor write path (POST /api/projects/{id}/timeline/anchor):
- Sequence guard: if rule.parent_id has no anchored actual, return
409 predecessor_missing with the missing rule's code/name DE+EN +
pre-formatted bilingual messages so the frontend can render an
inline error with a "Stattdessen <predecessor> erfassen" link
(#31 layer 3, no confirm-and-write override in v1).
- kind dispatch: rules with event_type IN ('hearing','decision','order')
write paliad.appointments with deadline_rule_id; everything else
writes paliad.deadlines with source='anchor', status='completed',
completed_at=actual_date.
- Idempotent: existing (project_id, rule_id) row PATCHes instead of
inserting (race-safe per design §13).
Skip write path (POST /api/projects/{id}/timeline/skip):
- Writes paliad.project_events with event_type='rule_skipped' +
metadata.rule_code; subsequent reads drop the matching projected
row from the cascade (§6.4).
Handlers expose projection meta via X-Projection-{Has,Total,Shown,Overdue,Lookahead}
headers so the wire shape stays []TimelineEvent (frozen since Slice 1).
Adds a nullable text column on paliad.project_events so a subset of
audit rows can opt into surfacing as SmartTimeline content. Existing
rows stay NULL (audit-only); the partial index keeps the lookup tiny
because the SmartTimeline read filter is the indexed predicate.
Value space (enforced in code in internal/services/projection_service.go):
'milestone' — structural event (counterclaim_filed, ...)
'custom_milestone' — free-text "Eigener Meilenstein"
NULL — audit only (default)
Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
m's 2026-05-08 22:08 dogfood: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung'
(DE) auto-filled to 'Klageerwiderung' label but the chosen event_type was
upc_statement_of_defence (UPC). Both render as 'Klageerwiderung' in the
UI, but they are different legal events in different jurisdictions.
Migration 074 adds a jurisdiction column to
paliad.deadline_concept_event_types and swaps the unique-default index
from per-concept to per-(concept, jurisdiction). Backfills jurisdiction
from each event_type's own column, then re-elects DE / DPMA / EPO
defaults where a non-UPC event_type genuinely exists. Idempotent: uses
ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE, partial unique index.
DeadlineRuleService.hydrateConceptDefaultEventTypes now JOINs
paliad.proceeding_types and matches on (rule.concept, rule.jurisdiction)
with EPA→EPO canonicalisation. Rules whose (concept, jurisdiction) has
no default stay NULL — silent no-op on the form, better than a wrong
jurisdictional default. UPC rules unchanged; DE rules now resolve to
de_klageerwiderung when concept = statement-of-defence, else no autofill.
Live audit confirms: every active rule now resolves to a same-
jurisdiction event_type or no event_type at all. No more cross-
jurisdiction matches in the seed.
Two slices on mai/noether/collapse-regel-typ-on:
0c12644 feat(deadline-rules): expose concept's canonical event_type per rule
1e97ecc feat(deadlines/new): auto-link Typ to Regel's concept
What ships:
- New junction paliad.deadline_concept_event_types maps every
paliad.deadline_concepts row to its canonical paliad.event_types
row(s). Many-to-many for concepts with multiple legitimate variants
(statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
EPO + DPMA). Exactly one row per concept marked is_default = true
by a partial unique index — that is the row the deadline form
auto-fills with.
- Backend: paliad.deadline_rules_with_concept_event_type view + the
deadline-rules read path now expose the rule's default concept
event_type so the form has the auto-fill target without an extra
round-trip.
- Frontend deadline create / edit form: when the user picks a Regel,
the Typ chip auto-fills with the rule's concept's default event_type.
A small "vorgegeben durch Regel — überschreiben?" hint sits next to
the chip so the auto-fill is visible. The user can override (free-
text or pick a different type); the override is explicit, no
blocking validation.
- Free-text Typ stays available — manual deadlines without a
matching rule (e.g. "Call me" reminders) keep working as today.
Migration housekeeping
======================
noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).
Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.
Refs m/paliad#18.
Add paliad.deadline_concept_event_types junction (mig 072) mapping each
deadline_concept to its canonical paliad.event_types row(s). Hydrate
DeadlineRule.ConceptDefaultEventTypeID via one IN query per List call so
/api/deadline-rules carries the autofill hint for the deadline create
form (t-paliad-165 / m/paliad#18).
Seed mapping covers the active concepts driving existing rules — 29
rows across 26 distinct concepts. Concepts without an obvious event_type
counterpart (decision, filing, grant, the DE-only Begründung family)
stay unmapped; auto-fill silently skips them.
m's 2026-05-08 21:42 dogfood feedback on the Determinator perspective
chip: when an Akte is selected, the chip should be locked to the firm's
known side instead of asking the user to re-pick. paliad didn't track
that anywhere — paliad.parties.role records each party's role but no
flag for "this is the side we represent".
Migration 072 adds paliad.projects.our_side text with a CHECK
constraint (claimant | defendant | court | both | NULL). NULL stays the
default so existing rows are neutral and the Determinator falls back to
free-pick. Idempotent (ADD COLUMN IF NOT EXISTS + DO-block guarded
constraint) so a re-run against a partially-applied state is safe —
paliad has been bitten by collision twice this week.
Project model + ProjectService:
- OurSide *string field on models.Project
- CreateProjectInput / UpdateProjectInput accept our_side
- INSERT and partial UPDATE thread the value through; validateOurSide
rejects unknown enum values with ErrInvalidInput before the DB
constraint would; nullableOurSide turns "" into NULL so the form's
"unset" sentinel can clear the column
- Update logs an our_side_changed audit event with "<from> → <to>"
description (matching status_changed / project_type_changed
shape); both ends use the literal "none" sentinel for NULL so the
frontend renderer can map it to projects.field.our_side.none
i18n: event.title.our_side_changed (DE/EN), dashboard.action.short
verb form, projects.field.our_side.{label,hint,unset,claimant,
defendant,court,both,none} for the upcoming Slice 2 select.
Frontend translateEventDescription gets an our_side_changed branch
that runs translateArrowSlugs over the projects.field.our_side.*
prefix so the Verlauf tab renders localized labels.
Slice 2 wires the form, Slice 3 wires the Determinator.
m's 2026-05-08 18:09 spec — Slice 3c. Adds a Klägerseite / Beklagtenseite
chip strip at the top of the B1 cascade panel; cascade leaves tagged
with a contradictory party get hidden. Klägerseite never files
Klageerwiderung; Beklagtenseite never files Klageschrift.
Migration 071 adds `paliad.event_categories.party text[]` (CHECK on
{claimant, defendant, both, court}) plus a partial GIN index. Backfill
is conservative — only the obvious leaves get tagged on this pass:
- claimant ich-moechte-einreichen.klage.* (9 leaves)
ich-moechte-einreichen.spaetere-schriftsaetze.replik-*
- defendant ich-moechte-einreichen.widerklage.*
ich-moechte-einreichen.spaetere-schriftsaetze.duplik-*
cms-eingang.* (incoming) and frist-verpasst.* (anyone misses a
deadline) stay NULL because the user can be on either side and still
receive the same court communication. Cross-appeal / Anschluss-
berufung / Reply-to-cross-appeal also stay NULL — the role flips
depending on who appealed first; the cascade doesn't have that
context yet. Tag in a follow-up once dogfood validates the chip.
Backend: EventCategoryNode JSON gains optional `party` array;
EventCategoryService.Tree SELECT picks it up via pq.StringArray.
Frontend: new Perspective type + URL state (?role=claimant|defendant)
+ perspective chip strip styled identically to the inbox-channel chip
strip. perspectiveAllowsParty(party) gates each cascade child;
"both"/"court" tagged nodes always pass; neutral nodes always pass.
Persistence is URL-only — dogfood will tell us whether to add a saved
default later.
Migration applied to live Supabase; tracker at v71.
Refs t-paliad-157 / m/paliad#15.
Two coordinated additions:
1. paliad.approval_requests gets requester_kind text NOT NULL DEFAULT
'user' CHECK ('user','agent') + agent_turn_id uuid REFERENCES
paliadin_turns(turn_id) ON DELETE SET NULL. The xor-check pins
(kind='agent' ↔ agent_turn_id IS NOT NULL) so agent rows can't lose
provenance and user rows can't accidentally pick up a turn id.
Existing rows backfill cleanly via the DEFAULT.
2. paliad.paliadin_turns.context jsonb — structured page-context
payload (route_name + primary_entity + selection + view hints) the
inline widget submits with every turn. Old page_origin column stays
as the cosmetic URL field.
Idempotent — every ALTER uses IF NOT EXISTS and the constraints/index
are guarded by DO blocks. Verified live via BEGIN..ROLLBACK on the
production DB: cols + 3 constraints + index land cleanly, second apply
is a no-op, xor-check rejects ('agent', NULL).
Skipped the optional PaliadinRelay interface extraction per the design
doc's own §6.4 caveat: paliadin.go + paliadin_remote.go already share
the paliadinDB substrate cleanly; introducing an interface now would
duplicate without removing duplication. Reserves the seam for the
future API cutover without paying its cost today.
Refs: docs/design-paliadin-inline-2026-05-08.md §7.1, §4.2, §6.4.
m's 2026-05-08 17:41 batch Item 4: today
`cms-eingang.gegenseite` exposes UPC INF/REV, DE INF/NULL, EPA OPP/APP,
DPMA OPP — but is missing the appellate / interim-measures arms m
named. m: "we need a lot more proceeding types for Opponent submission
— we currently only see Verletzungsverfahren."
Migration 069 adds 5 new parent nodes and 17 leaves under
`cms-eingang.gegenseite`:
- upc-app UPC Berufungsverfahren — 5 leaves: Berufungs-
schrift, -begründung, -erwiderung, Anschluss-
berufung (R.237), Erwiderung dazu (R.238).
Concepts: notice-of-appeal, statement-of-
grounds-of-appeal, response-to-appeal,
cross-appeal, reply-to-cross-appeal.
- upc-pi UPC einstweilige Maßnahmen — 2 leaves: PI-
Antrag, PI-Erwiderung. Concepts: application-
for-provisional-measures, statement-of-defence.
- de-bgh-inf DE Revision / NZB BGH (Verletzung) — 5 leaves:
NZB, NZB-Begründung, Revisionsschrift,
Revisionsbegründung, Revisionserwiderung.
Concepts: nichtzulassungsbeschwerde,
nichtzulassungsbeschwerde-begruendung,
revisionsfrist, revisionsbegruendung,
response-to-appeal.
- de-bgh-null DE Berufung BGH (Nichtigkeit) — 3 leaves:
Berufungsschrift, -begründung, -erwiderung.
Concepts: notice-of-appeal,
statement-of-grounds-of-appeal,
response-to-appeal.
- dpma-bgh DPMA Rechtsbeschwerde BGH — 2 leaves:
Rechtsbeschwerde, RB-Begründung. Concepts:
rechtsbeschwerde, rechtsbeschwerde-begruendung.
Each parent + leaf carries the matching forums tag so the
inbox-channel chip (#15) hides / shows the subtree correctly. The
event_category_concepts junction sets proceeding_type_code per leaf
(UPC_APP / UPC_PI / DE_INF_BGH / DE_NULL_BGH / DPMA_BGH_RB) so the
result card pills only the relevant proceeding's rules.
All INSERTs use ON CONFLICT (slug) DO UPDATE so re-running the
migration after a partial apply is safe. Mig applied to live Supabase;
tracker at v69.
Refs m/paliad#15.
Three commits from mai/feynman/fristenrechner:
- 614f9af fix(approval-pill): two-eyes glyph 👀 instead of single SVG eye
on /deadlines + /appointments + /agenda. m's preference: emoji denotes
"being looked at" closer to "wartet auf Genehmigung" semantics.
- 2d6ea3e feat(deadline-rules/is-optional): conditional rules opt-in via
save modal. Adds paliad.deadline_rules.is_optional. Distinct from
is_mandatory: a rule can be statutorily fixed when it applies AND
conditional on whether it applies (RoP.151 cost-decision request,
appeal-related deadlines). Save-modal pre-unchecks optional rows;
user toggles to opt in. Timeline shows "auf Antrag" pill.
- 097e21c feat(fristenrechner): proceeding-picker collapses to one-line
"Verfahren: X · [Reselect]" pill after pick (saves vertical space).
Column view becomes the default for the timeline (was previously
whichever-default; m wants Column on first render).
Migration housekeeping:
feynman's migration was authored as 066 on his branch but main has
already taken 066/067 via shannon's t-paliad-160 (approval policy
split + drop required_role). Renumbered to 068 during merge to
resolve the same-number collision. Added ADD COLUMN IF NOT EXISTS
to make the up-migration idempotent (defensive for environments
where the column was already applied out-of-band during dev). The
RoP.151 backfill UPDATE is naturally idempotent.
Live tracker bumped from 66 → 68 to reflect schema reality before
this merge: shannon's 066+067 effects and feynman's is_optional
column are all already present in the live youpc Supabase. The
next deploy will see tracker=68 and have nothing to apply.
Refs m/paliad#15, m/paliad#18 (rule-Typ contradiction filed against
Item A scope, not part of this batch).
m's 2026-05-08 batch Item 2: some rules don't always apply per-instance.
Antrag auf Kostenentscheidung (RoP.151) only fires when a party files
for it; some appeal-related deadlines depend on specific facts. Today
they render in the timeline as if always applicable; the save-to-
project modal pre-checks them so the user has to remember to uncheck.
New paliad.deadline_rules.is_optional bool flag (default false). Threads
through the Go model, ruleColumns SELECT, UIDeadline JSON, and the
frontend save modal:
- Migration 066 adds the column + comment + a starter UPDATE that
flips RoP.151 to is_optional=true. m can flip more via SQL as he
reviews the rule library — distinct from is_mandatory, which is
about statutory strictness once the rule applies (an "auf Antrag"
rule can be is_mandatory=true once requested).
- Save modal: optional rows pre-uncheck (the user opts in) and a
small "auf Antrag" / "on request" pill renders in the meta line.
Court-determined rows still pre-uncheck via the existing disabled
path; isOptional doesn't override that.
Migration applied to live Supabase; tracker at v66.
Refs m/paliad#15 (m's 2026-05-08 18:21 follow-up batch Item 2).
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.
`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.
Go side:
- models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
The MinRole pointer is now the only seniority-threshold surface.
- LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
drop the required_role SELECT projection.
- UpsertProjectPolicySplit / UpsertUnitPolicySplit /
DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
drop the required_role write. The audit-log row still uses the
legacy string format ('partner|...|none'); composed via
legacyFromSplit() from the new columns so the audit table layout
keeps working without a parallel migration.
- submit() reads policy.MinRole directly (LookupPolicy guarantees
non-nil when a non-nil policy is returned).
- nullToPtr helper retired (no remaining callers).
Frontend side:
- admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
legacy required_role optional. The 2-control UI was already on the
split-grammar path.
- deadlines-new.ts + appointments-new.ts form-time hint readers prefer
requires_approval+min_role. They keep a soft-fall back to the
legacy required_role for one cycle in case any cached pre-M2 server
is still serving the old shape — that path is dead-code post-deploy
and can be dropped later.
Test:
- TestApprovalService_PolicyCRUD asserts MinRole instead of
RequiredRole after re-upsert.
Build: bun build OK, go build ./... OK, go test ./... OK.
Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
m's locked redesign (2026-05-08 16:40): replace `required_role` (with
'none' sentinel) with two columns — `requires_approval boolean` (the
gate) + `min_role text` (the seniority threshold). Cleanly separates
"approval applies at all" from "who's allowed to approve".
M1 phase: additive migration 064 adds the columns, backfills from the
legacy required_role ('none' → false/NULL; else → true/role), and
rewrites paliad.approval_policy_effective() to most-strict-wins:
- requires_approval := bool_or across project + ancestor + unit_default
- min_role := MAX(approval_role_level) among requires_approval=true
The legacy required_role column survives this slice as a dual-read
mirror (resolver returns it too) so any caller that hasn't cut over
keeps working. M2 will drop required_role.
Service layer (approval_service.go): LookupPolicy + GetEffectivePolicyOne
read the new columns; UpsertProjectPolicySplit / UpsertUnitPolicySplit
accept the new shape directly; legacy UpsertProjectPolicy /
UpsertUnitPolicy stay as thin shims that map required_role through
splitFromLegacy(). ApplyMatrixToDescendants writes both columns.
Handler 409 mapping (§B): writeServiceError now consults a shared
mapApprovalError() helper before falling through to the generic 500.
ErrConcurrentPending → HTTP 409 with body
{code: "awaiting_approval", message, request_id?, required_role?}.
PendingApprovalError wraps ErrConcurrentPending with the in-flight
request id + role so the UI knows which request to point a withdraw
button at. ErrNoQualifiedApprover, ErrSelfApproval, ErrNotApprover,
ErrRequestNotPending all mapped consistently. writeApprovalError
now defers to the same helper for shape consistency.
Models: ApprovalPolicy + EffectivePolicy gain RequiresApproval/MinRole
fields. RequiredRole stays as a dual-read mirror until M2.
Tests: TestMapApprovalError_* covers the four 409/403 branches and the
"no match — fall through" case. Existing approval service tests pass
unchanged.
Defers per task spec to follow-up slices:
- A3 (admin UI 2-control flip)
- C+E (badge + withdraw button on detail pages)
- D (/inbox Meine Anfragen visibility fix)
- M2 (drop required_role column)
Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.
Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:
1. Regex on slug for nodes that carry the forum token explicitly.
Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).
NULL stays neutral. Migration applied to live Supabase; tracker at v65.
Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.
Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.
Refs m/paliad#15 (B1 follow-up).
Adds paliad.users.forum_pref so /tools/fristenrechner can pre-narrow
the proceeding picker to the user's typical inbox channel without
re-asking on every visit. The new column threads through the User
model, the userColumns SELECT, and UpdateProfileInput so the existing
PATCH /api/me handler accepts it without a new endpoint.
Allowed values mirror the channel chips m named in t-paliad-157:
- cms → UPC
- bea → national-DE
- posteingang → national-DE (slower channel, same forums)
NULL means "no preference, picker shows everything"; URL ?inbox=
overrides per-visit (frontend lands in the next commit). The CHECK
constraint enforces the 3-value enum at the DB layer; isValidForumPref
mirrors it in the service so callers see a typed error instead of a
raw pq violation. Empty string in the PATCH body clears the
preference, consistent with the EscalationContactID convention.
Migration 064 applied to the live Supabase pool; tracker bumped to
v64 so the boot-time runner skips re-applying.
Refs m/paliad#15.
The "Frist verpasst" cascade covered DE PatG/ZPO, EPA Art.122 and DPMA
Wiedereinsetzung paths but had no UPC option, even though UPC R.320 RoP
grants re-establishment of rights with the same shape (2 months from
removal of the obstacle, 12-month outer limit).
Migration 063 adds:
- trigger_event id 207 "Wegfall des Hindernisses (UPC R.320)" tied to
the existing wiedereinsetzung concept, so the concept card picks
up a UPC pill alongside DE / EPA / DPMA.
- event_categories leaf frist-verpasst.upc at sort_order 50 so UPC
reads first under "Frist verpasst" (national + EPA siblings stay
at 100/200/300/400).
- event_category_concepts junction linking the new leaf to the
wiedereinsetzung concept; NULL proceeding_type_code mirrors the
sibling pattern (cross-cutting trigger pills bypass the forum
filter by design — per-leaf narrowing is part of the IA-reframe
issue #16).
Migration applied to live Supabase; matview refreshed; tracker bumped
to v63 so the boot-time runner skips re-applying.
Refs m/paliad#14 section C.
Q8 of locked design: policy CRUD audits to /admin/audit-log only, NOT
to per-project /verlauf. The 4 existing audit sources (project_events,
caldav_sync_log, reminder_log, partner_unit_events) don't fit cleanly:
project_events would surface on /verlauf (rejected by Q8); partner_unit_events
constrains event_type and requires unit_name + a non-null partner_unit_id
which doesn't fit project-scoped policy changes.
Added paliad.policy_audit_log as a fifth audit source — admin-only, scoped
either to a project or a partner unit, snapshots scope_name so post-cascade
rows still render. RLS: select for any authenticated user (route gate is
the actual control); write for global_admin only.
AuditService.ListEntries will union this source in commit 2 of this PR.
Validated insert/select live in BEGIN ... ROLLBACK.
Schema:
- ALTER paliad.approval_policies: project_id nullable, ADD partner_unit_id
uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE.
- XOR check: exactly one of (project_id, partner_unit_id) is set.
- Replace UNIQUE composite with two partial unique indexes (one per scope).
- Extend required_role CHECK with 'none' sentinel.
- approval_role_level('none') already returns 0 via existing ELSE branch
in 059_profession_vs_responsibility.up.sql:218 — no function update.
Resolver paliad.approval_policy_effective(project, entity_type, lifecycle):
- Step 1: project-specific row wins outright (any value, including 'none').
- Step 2: MAX(approval_role_level) across ancestor rows on project's path
+ unit-default rows for partner units attached to project. Tied levels
break alphabetically ('ancestor' beats 'unit_default') for stable
attribution.
- Step 3: zero rows (no candidates) — caller treats as 'no policy applies'.
Returns (required_role, source, source_id) — source ∈ {project, ancestor,
unit_default}; source_id is project_id or partner_unit_id depending.
Seed:
- 8 rows × every existing partner_unit (currently 11): deadline+appointment
× create/update/delete = associate; complete = none.
- ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL DO NOTHING — idempotent on re-run
(verified live: 11 units → 88 seed rows, second run is no-op).
- Safe on a DB with 0 partner_units (SELECT returns no rows).
Down migration: reverse-order. Coerces 'none' rows to 'associate' before
restoring CHECK so rollback works without data loss. Drops seeded unit
rows; preserves project rows that pre-date 062.
Validated end-to-end against the live DB inside BEGIN ... ROLLBACK; the
existing project policy (deadline:create=partner) is preserved by the
DO NOTHING clause and the partial-index scope.
Design: docs/design-approval-policy-ui-2026-05-07.md §3.1.
No RAISE EXCEPTION. No bare CSS tokens (no CSS in this commit).
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
labels.
- RLS owner-only (mirrors paliad.user_views from t-144).
LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}
DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).
CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.
ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.
Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).
Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.
Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
title-row-first invariant, hidden leading allowed, dup-key rejection,
unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
+ idempotent, first Create auto-becomes default, SetDefault clears
prior, Delete refuses active default, Delete non-default works,
duplicate name rejected, Update round-trips layout JSON.
go build / vet / test (short) clean.
Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.
PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.
ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:
Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
with InheritedVisibility flag on greyed ancestors)
StatusIn / TypeIn: chip-narrowing whitelists
HasOpenDeadlines: per-node or subtree-aggregated, depending on
IncludeSubtreeCounts
SearchTerm: case-fold contains on title/reference/clientmatter, then
prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
IncludeSubtreeCounts: post-order DFS sums, O(N)
GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.
POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.
build + vet clean.
Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
Adds paliad.users.profession (firm-wide career tier) and paliad.project_teams.responsibility
(per-project responsibility, default 'member'). Backfills both from the legacy
project_teams.role column — highest-tier-per-user for profession, single-row map
for responsibility (lead→lead, observer→observer, local_counsel/expert→external,
others→member).
Updates paliad.approval_role_level to recognise 'partner' as the new ceiling
(replaces 'lead' as the firm-tier ceiling), keeping 'lead' at level 5 as a
deprecated-shadow row until follow-up migration 058 retires project_teams.role.
Updates paliad.approval_role_from_unit_role: lead → partner.
Creates paliad.user_project_authority_level(user_id, project_id) — the
tuple-with-gate ladder. Returns profession_level if responsibility ∈ {lead,member}
else 0; max with derived authority via partner-unit attachments where
derive_grants_authority=true.
Updates approval_policies.required_role + approval_requests.required_role CHECK
constraints (drop 'lead', add 'partner'); backfills any existing rows.
Rewrites project_partner_units write RLS policy to read pt.responsibility='lead'
instead of pt.role='lead'.
Live-DB BEGIN/ROLLBACK dry-run verified: 2 users get profession='partner'
(matthias.siebels, tester@hlc.de — the only users currently on project_teams),
45 users get profession=NULL (admin fills via /admin/team).
project_teams.role kept as deprecated shadow column. Drop in follow-up migration 058.
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.
Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
template_key, recipient_filter jsonb, recipient_user_ids uuid[],
send_report jsonb, sent_at). RLS: senders read own rows, global_admin
reads all; inserts must self-attribute. No CHECK-constraint extension to
partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
persists audit row, dispatches via 5-deep goroutine pool with 15s
per-send timeout. Send report (sent/failed counts + per-recipient errors)
is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
*italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
first; only whitelisted tags re-emitted. Script tags and javascript:
URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
{{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
index. Powers the /team project multi-select filter without N round
trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
/admin/broadcasts is gateOnboarded (not adminGate) so leads can see
their own sends; the service applies the per-row visibility filter.
Frontend
- /team gains a project multi-select chip dropdown (visible projects
loaded from /api/projects, intersected against the memberships index)
alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
true (global_admin always; non-admin needs lead-ship on selected
projects, or at least one project when no filter is set). Server still
re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
template dropdown (loads existing email templates and strips Go-template
directives) + recipient preview (first 5 + expand) + send. Hard-blocks
empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
detail (subject, body, recipient list, send_report counts).
Tests
- broadcast_service_test.go: placeholder substitution table-driven,
Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
validation cases (empty subject/body, recipient cap, invalid email),
signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
rules (lead can send on own project, member cannot, admin sees all,
member can't read lead's row). Skips when TEST_DATABASE_URL is unset.
i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
Schema-only commit (1 of 8) for the 4-Augen-Prüfung workflow per
docs/design-approvals-2026-05-06.md. No Go code reads these yet —
paliad behaves identically until commit 2 wires ApprovalService into
the mutation paths.
Migration 054 adds:
1. `senior_pa` to paliad.project_teams.role CHECK. Drops both the
English `project_teams_role_check` and the German-legacy
`projekt_teams_role_check` (live-DB constraint name carried over
from migration 018's pre-rename era).
2. `paliad.approval_role_level(text) RETURNS int IMMUTABLE` — strict
ladder helper: lead(5) > of_counsel(4) > associate(3) > senior_pa(2)
> pa(1) > [local_counsel/expert/observer = 0 = ineligible]. Mirrors
the upcoming Go `levelOf()`.
3. `paliad.approval_policies` (project_id, entity_type, lifecycle_event,
required_role) — UNIQUE composite key gives at most 8 rows per
project. RLS: SELECT via can_see_project; INSERT/UPDATE/DELETE only
for global_admin (defence-in-depth; service-role pool bypasses RLS,
so the actual gate is service-layer).
4. `paliad.approval_requests` — operational pending workflow.
pre_image jsonb captures revert state; payload echoes the diff;
required_role snapshots the policy at request time. CHECK
`decided_by != requested_by` is the second layer of self-approval
block. RLS = same can_see_project predicate as deadlines /
appointments — anyone with project visibility sees pending requests.
5. `approval_status` (default 'approved'), `pending_request_id`,
`approved_by`, `approved_at` columns on both deadlines and
appointments. `appointments.completed_at` (new) lands the
appointment:complete lifecycle event.
6. Backfill: every existing deadline + appointment row marked
approval_status='legacy'. Per Q11, no retroactive approval; the
next mutation on a legacy row that hits an active policy follows
the normal flow.
Live-DB dry-run verified end-to-end: 20 deadlines + 5 appointments
backfill, both new tables instantiate cleanly, helper function returns
correct levels, self-approval CHECK fires on invalid INSERT, valid
pending insert succeeds.
Adds paliad.countries (13 ISO-3166 codes), paliad.courts (41 entries
seeded from internal/handlers/courts.go), and the country/regime split
on paliad.holidays. The 33 t-paliad-121 UPC vacation rows previously
stored as country='UPC' migrate cleanly to country=NULL + regime='UPC'
— 'UPC' is a supranational regime, not an ISO country, and the new
shape lets a UPC LD München (country='DE', regime='UPC') pull both DE
federal holidays and UPC vacation entries while a UPC LD Paris
(country='FR', regime='UPC') pulls FR + UPC. Holidays now FK-protected
against typo'd country codes.
Migration 052 fixes six concept↔leaf mismaps in the v3 seed and adds
three proactive entry leaves under spaetere-schriftsaetze.
1. cms-eingang.gericht.hinweisbeschluss — drop the response-to-
preliminary-opinion | DE_INF row. DE_INF (LG) has no
Hinweisbeschluss; the concept lives only in DE_NULL via PatG §83.
2. cms-eingang.gegenseite.upc-inf.klageschrift — drop the notice-of-
defence-intention | UPC_INF row. UPC has no such rule in the corpus;
R.23 reaction is captured by statement-of-defence directly.
3. UPC R.221 cost-appeal sequence (m's Q5): three leaves now surface
BOTH application-for-leave-to-appeal | UPC_COST_APPEAL (sort 100,
R.221.1, 15 days) AND notice-of-appeal | UPC_APP (sort 200,
conditional on leave granted, R.220.1). Replaces the wrong notice-of-
appeal | UPC_COST_APPEAL row that was silently dropping pills.
4. ich-moechte-einreichen.berufung.upc-coa-orders — replace the buggy
application-for-leave-to-appeal | UPC_APP_ORDERS (no rule for that
combo) with request-for-discretionary-review | UPC_APP_ORDERS
(R.220.3).
5. cms-eingang.gericht.anordnung — narrow request-for-discretionary-
review NULL → UPC_APP_ORDERS. R.220.3 review applies specifically
to the Anordnungen / 15-day track.
6a. reply-to-cross-appeal coverage: add UPC_APP rows under upc-{inf,
rev}.berufungsschrift so the reply leaf is reachable when the
opponent files an Anschlussberufung.
6b. New leaves under ich-moechte-einreichen.spaetere-schriftsaetze for
proactive entry: r116-eingaben (EPA R.116 final submissions),
anschlussberufung-upc (R.237), reply-to-cross-appeal-upc (R.238).
NO `RAISE EXCEPTION` coverage gate (m's Q7) — last night's outage was
caused by exactly that pattern in migration 049. Replaced with a Go-
side test in event_category_coverage_test.go that asserts every
category='submission' concept is reachable from at least one leaf
(except the prosecution-only exempt list: filing, request-for-
examination, approval-and-translation). Skipped without
TEST_DATABASE_URL; CI gates on it.
bescheid-mit-frist mapping deferred per m's Q4. Will land separately.
Migration verified via supabase MCP dry-run + ROLLBACK on the live
youpc DB; end-state matches design §3.2-§3.4.
Five m's-bookmark fixes on top of the B1 surface change:
1. Sort proceeding pills inside concept cards by real-world frequency.
New paliad.proceeding_types.display_order column (m's spec values:
UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
999 for unmapped legacy codes. Search service surfaces it through
the deadline_search matview (rebuilt to add the column) and uses
it as primary key in pillSortKey, replacing the jurisdiction-rank.
2. Name standardisation: -klage → -verfahren on the proceeding-types
that describe a multi-step process. Specifically:
UPC_REV Nichtigkeitsklage → Nichtigkeitsverfahren
UPC_APP Berufung → Berufungsverfahren
DE_INF Verletzungsklage (LG) → Verletzungsverfahren (LG)
DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
same -verfahren standardisation.
3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
(Defence to Application for Revocation, R.49.1).
4. Frontend renderPill no longer falls back to rule_local_code when
legal_source is missing — the source span just collapses, so no
internal slug ever shows up as a "citation".
5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
fristenrechner.tsx, single source of truth for both fork-shortcut
and B2-search-bar rows. Each chip carries data-chip-name-de /
data-chip-name-en; relabelChips() rewrites visible text per active
language. Dropped the duplicate "Statement of Defence" chip (same
concept as "Klageerwiderung"). Each chip now maps to one concept
slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
appeal, Einspruch→opposition, Replik→reply-to-defence,
Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
application-for-determination-of-damages, Wiedereinsetzung→
wiedereinsetzung.
Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
Migration 049 went dirty in prod because the coverage gate at the end
(DO $coverage$) raised on 'reply-to-cross-appeal' — it's defined as a
submission concept but no leaf in the decision-tree seed maps to it.
reply-to-cross-appeal is a downstream-of-cross-appeal concept, only
reachable after the user has already entered the cross-appeal Pathway B
branch via 'response-to-appeal'. Adding a dedicated leaf would be
useful UX (file a follow-up), but for now exempting it from the
coverage gate matches the established 'pure-administrative' exemption
pattern used for filing / request-for-examination / approval-and-translation.
Manual recovery: set tracker version=48 dirty=false on prod (schema
from 048 was already applied via supabase MCP). Dokploy redeploy will
now run 049 + 050 cleanly and reach version=50.
Refs: t-paliad-133 prod outage 11:15-11:30 Tue 05.05.2026
Three migrations land the data layer for the Fristenrechner v3 decision
tree (Pathway B / B1) plus the bilateral-rule flag for the new party-
perspective selector. All purely additive — no breaking changes to the
v2 (t-paliad-131) corpus.
Migration 048 — schema:
- paliad.event_categories: recursive taxonomy tree (parent_id self-FK,
unique slug as materialised dot-path, step_question_de/en on internal
nodes, is_leaf bool, optional emoji icon).
- paliad.event_category_concepts: many-to-many junction (leaf →
deadline_concepts) with optional proceeding_type_code narrowing.
UNIQUE NULLS NOT DISTINCT prevents duplicate (leaf, concept, NULL)
rows (PG 15+).
- paliad.deadline_rules.is_bilateral bool: when true AND
primary_party='both', the rule mirrors into both party columns of
the v3 columns view; otherwise 'both' resolves single-side via the
perspective selector.
Migration 049 — seed taxonomy:
6 root buckets (cms-eingang, muendl-verhandlung, beschluss-entscheidung,
frist-verpasst, ich-moechte-einreichen, sonstiges) with 70+ leaves and
115+ junction rows. Tree depth reaches 4 today (cms-eingang › gericht
› endentscheidung › <leaf>) but the schema supports unlimited depth
per design lock §10 Q2. Coverage gate at the end raises if any
category='submission' concept is unreachable from a leaf, except the
3 pure-administrative slugs (filing, request-for-examination,
approval-and-translation) that live on Pathway A only.
Migration 050 — bilateral backfill:
Tags exactly 4 genuinely-bilateral rules:
- de_null.stellungnahme (Stellungnahme zum Hinweisbeschluss, PatG §83.2)
- epa_opp.r79_further (Stellungnahme weiterer Beteiligter)
- epa_opp.r116, epa_app.r116 (Eingaben vor mündl. Verhandlung)
All other primary_party='both' rules (Berufungsfristen, Anschlussberufung,
…) are role-swap appeals that resolve via the perspective selector at
render time.
Schema dry-run validated end-to-end against Supabase PG 15.8.
Design ref: docs/plans/unified-fristenrechner-v3.md §4.1 + §10 Q12.