Server-side additions so /inbox can render the suggest-changes back-link
without an extra client round-trip:
- ApprovalRequestView gains NextRequestID. Hydrated via correlated
subquery on previous_request_id; mig 103's partial index makes the
lookup O(1) per row.
- view_service.go approvalRowSubtitle picks up the changes_requested
case ("Abgelehnt mit Vorschlag von <decider>").
- filter_spec.go validRequestStatuses includes "changes_requested" so
user-views can filter on it.
- handlers/approvals.go isValidInboxStatus accepts "changes_requested"
on the /api/inbox/{mine,pending-mine}?status= query. Test case added
to TestParseInboxFilter_DropsUnknownStatus.
Suggest-changes branches off into handleSuggestChanges() instead of the
generic POST-with-prompt-note path. It:
1. Fetches the full request payload + pre_image via GET
/api/approval-requests/{id} — list payload may be stale.
2. Opens approval-edit-modal with the entity_type-appropriate fields
pre-populated.
3. On submit, POSTs to /api/approval-requests/{id}/suggest-changes
with {counter_payload, note}.
4. On success, refreshes the filter bar (the OLD row flips to
changes_requested, the NEW pending row appears) and the inbox badge.
5. Stamps data-spawned-request-id on the OLD row's <li> so downstream
code / tests can locate the counter without a re-query.
The error mapping (mapApprovalError) gains the two new codes
suggestion_requires_change + suggestion_lifecycle_invalid plus the
generic codes already handled. Body reads now go through `body.code`
preferentially with `body.error` fallback — the server uses {code,
message} envelopes.
New module: frontend/src/client/components/approval-edit-modal.ts.
The approver clicks "Änderungen vorschlagen" on a pending update-lifecycle
row; this modal opens with the requester's original payload pre-populated
in editable date inputs (per entity_type allowlist):
- deadline: due_date, original_due_date, warning_date
- appointment: start_at, end_at
The pre_image value for each field renders as a "Vorher" hint so the
approver sees what's being changed before they commit a counter.
A free-text "Vorschlagskommentar" textarea sits below the inputs. The
submit button stays disabled until the form is dirty OR the note has
non-whitespace content — mirrors the server's ErrSuggestionRequiresChange
no-op guard so the user doesn't bounce off a server-side 400.
API: openApprovalEditModal({entityType, lifecycleEvent, payload,
preImage}) returns Promise<{counterPayload, note} | null>. null = user
cancelled (ESC, overlay click, Cancel button). counterPayload contains
only fields that the user changed; unchanged keys are omitted (the
server's buildRevertSetClauses ignores absent keys cleanly).
Lifecycles other than "update" are guarded with an alert + resolve null —
shape-list.ts hides the button for them, but the modal is defence-in-
depth.
shape-list.ts:
- Pending-row action group extends to four buttons. suggest_changes is
only rendered for lifecycle='update' rows (the backend rejects other
lifecycles with ErrSuggestionLifecycleInvalid).
- ApprovalAction union widened to "approve" | "reject" | "revoke" |
"suggest_changes". Disabled-reason logic shared with approve/reject
(viewer_can_approve gate).
- Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
via the existing approval-pill--historic style — no new colour token.
- ApprovalDetail picks up counter_payload + next_request_id. When a
row is changes_requested AND a next_request_id is present, render a
back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
row (server-side hydrated via correlated subquery on
previous_request_id, indexed by mig 103's partial index).
filter-bar/axes.ts:
- APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
the /inbox filter cluster alongside pending/approved/rejected/revoked.
m's ask 2026-05-20 09:42. Eighth HLC office alongside Munich,
Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan.
- `internal/offices/offices.go` — append Madrid to All[] (display
order: end of list, after Milan). Doc comment refreshed to point at
the actual current CHECK constraints (users mig 002 + partner_units
mig 018/024/027), not the obsolete akten reference from before
projects-v2.
- `internal/offices/offices_test.go` — add `madrid` to the valid-keys
table.
- mig 106 — extend the two CHECK constraints on users.office and
partner_units.office. Idempotent (DROP IF EXISTS), audit_reason
set_config at top, dry-run validated against the live youpc paliad
schema (BEGIN; ALTER...; ROLLBACK).
Frontend picks up Madrid automatically via GET /api/offices.
Admin UI for managing firm office list is a separate longer-term
issue — m's "for now, just add Madrid already" path.
Service-level (real DB, gated on TEST_DATABASE_URL like the rest of the
approval suite):
- HappyPath: OLD row → changes_requested; NEW row pending with
previous_request_id back-pointer; entity reflects counter payload;
two project_events emitted (changes_suggested + requested).
- NoOpRejected: identical counter + empty note → ErrSuggestionRequiresChange.
- NoteOnlyAccepted: identical counter + non-empty note succeeds; entity
keeps the original counter values.
- SelfApprovalBlocked: original requester cannot suggest on their own row.
- RequestNotPending: already-decided row rejects suggest-changes.
- LifecycleInvalid: create-lifecycle pending → ErrSuggestionLifecycleInvalid.
- OriginalRequesterCanApproveCounter: m's Q6 model — after the approver
suggests changes, the ORIGINAL REQUESTER (now no longer the new row's
requested_by) can approve the counter themselves provided their
profession qualifies.
- CounterApproverCannotSelfApprove: 4-Augen still holds — the suggesting
approver cannot approve their own counter (ErrSelfApproval on the new row).
Handler-level (pure-Go, no DB):
- SuggestionRequiresChange400: error code mapping.
- SuggestionLifecycleInvalid400: error code mapping.
ApprovalService.SuggestChanges is the fourth approval action — in one
transaction:
1. Validates the OLD pending row (caller satisfies canApprove,
lifecycle in update/complete only, counter differs from old.payload
OR note is non-empty).
2. Closes the OLD row as 'changes_requested' with decision_note +
counter_payload + decided_by + decided_at + decision_kind.
3. Reverts the entity from old.pre_image (reuses applyRevert — same
code path Reject runs).
4. Runs the deadlock check for the NEW row (excluding the suggesting
caller; original requester is no longer excluded).
5. Re-applies the counter_payload to the entity row (via
applyEntityUpdate, mirroring the write-then-approve write).
6. INSERTs a NEW pending approval_requests row authored by the caller
with previous_request_id pointing back at the OLD row.
7. Marks the entity pending + pending_request_id → new row.
8. Emits two project_events: *_approval_changes_suggested + a fresh
*_approval_requested for the new row.
4-Augen still holds: the suggesting caller is the new row's
requested_by, so self-approval on the new row is blocked by the standard
3-layer guard. The ORIGINAL requester is no longer the requested_by of
the new row — if their profession satisfies the required_role they can
now approve the counter themselves.
Adds:
- const RequestStatusChangesRequested = "changes_requested"
- var ErrSuggestionRequiresChange = "suggestion requires counter diff or note"
- var ErrSuggestionLifecycleInvalid = "suggest is only valid for update/complete"
- models.ApprovalRequest.CounterPayload + PreviousRequestID
- Per-row read paths (getRequestForUpdate, approvalRequestViewColumns)
populate the new columns.
Adds the schema scaffolding for the fourth approval action (alongside
Approve / Reject / Revoke):
1. Extends approval_requests.status CHECK to include 'changes_requested'.
2. Adds counter_payload jsonb — the approver's edited values on a
changes_requested row (the basis of the new row's payload).
3. Adds previous_request_id uuid FK — back-pointer from a SuggestChanges-
spawned row to its source. Partial index on the FK supports chain
traversal.
Non-blocking: extending a CHECK constraint is metadata-only on Postgres;
adding NULLable columns + a NULLable FK is metadata-only. Safe for live
deploy.
Dry-run validated against the live youpc paliad schema via BEGIN/ROLLBACK
(migration tracker at 102 pre-apply; schema unchanged post-rollback).
§0a captures m's locked picks across all 8 questions. Two divergences from
inventor recommendations reshape the model:
- Q4: hybrid — approver edits the proposed values (counter-payload) AND/OR
leaves free-text in decision_note. Adds counter_payload jsonb column.
- Q6/Q7: the counter is treated as a NEW pending approval_request authored
by the approver, not an "edit and resubmit" CTA on the requester side.
Original requester sees the old row as changes_requested ("Abgelehnt mit
Vorschlag") and the new row as pending — they can approve it themselves
if eligible (they're no longer the requested_by). 4-Augen still holds.
§3 implementation sketch rewritten: SuggestChanges atomically closes the
old row, applyRevert's the entity, spawns a new pending row with
counter_payload as payload + previous_request_id linking back, re-applies
the counter via write-then-approve, emits both *_approval_changes_suggested
and *_approval_requested events. Migration 103 adds the CHECK value plus
counter_payload jsonb + previous_request_id FK + index. Slice plan trimmed
to backend / frontend-modal / Verlauf-integration.
Inventor draft of the fourth approval action alongside approve / reject /
revoke. Open questions in §2 will be resolved via AskUserQuestion before
any coder work. Recommendations folded in inline.
Verified live state before designing: status enum already carries an
unused 'superseded' value; entity approval_status is approved/pending/legacy
only; decision_note exists as free text; the existing decide() kernel
handles approve / reject / revoke with a single switch.
Three parked commits from fermi's 2026-05-18 interactive session, never
engaged at the time; m greenlit 2026-05-20 09:43:
- mig 104 (was 101): strip rule-cite brackets from Einspruch names + flip
CCR priority informational → optional. m's 18:01 + 18:08 corrections.
- mig 105 (was 102): track-aware sequence reshuffle for upc.inf.cfi
(infringement → revocation → amendment within tied-date groups). m's
18:08 ask about Replik-before-Erwiderung-NichtigkeitsWK ordering.
- Notes toggle UI: per-rule notes default to compact ⓘ hover hint;
"Hinweise anzeigen" switch in the toggle bar expands them inline.
Shared localStorage between verfahrensablauf and fristenrechner.
m's 18:21 ask.
Renumbered from 101/102 because leibniz CalDAV claimed mig 101 and
archimedes system_audit_log claimed mig 102 between fermi's parked
session and now. Mig 103 reserved for hertz suggest-changes Slice A
(in flight). Both go and frontend builds green.
m's ask 2026-05-18 18:21: per-rule descriptive notes ("Innerhalb von 1
Monat ab Zustellung der Klage. Drei mögliche Gründe…") are noisy in the
default timeline view. Make them optional — small ⓘ icon next to the
meta line by default with full text on hover; switch in the toggle bar
expands them inline when the user wants the wall of text.
**Renderer (verfahrensablauf-core.ts)** — `CardOpts.showNotes?: boolean`
gates two render paths:
- on → `<div class="timeline-notes">…</div>` (today's behaviour)
- off → `<span class="timeline-note-hint" tabindex=0 role=note
aria-label=… title=…>ⓘ</span>` inside the meta line (browser
title for hover, aria-label for screen readers, tabindex for
keyboard accessibility)
Pass-through wired in renderColumnsBody too so the columns view picks
up the toggle equally.
**Toggle UI** — added a checkbox row to the existing `fristen-view-toggle`
bar on both /tools/verfahrensablauf and /tools/fristenrechner:
"Hinweise anzeigen" / "Show details". CSS modifier
`.fristen-notes-option` separates it from the radio view-picker with
a leading border-left.
**State** — `paliad.fristen.notes-show` localStorage key (shared
between both pages so the preference carries across), default off,
re-render on flip.
i18n: 1 new key DE + EN (deadlines.notes.show). Build clean.
m's ask 2026-05-18 18:08: 'the infringement parts (like Replik) should
show above the part for the revocation (Erwiderung Nichtigkeitswider-
klage)'. Three tracks (infringement / revocation / amendment) coexist
on upc.inf.cfi once with_ccr / with_amend are set. They share tied
calendar dates because R.29/R.30/R.32 all key off the SoD or its
descendants. Current sequence_orders (post-mig 100) interleave them
arbitrarily; user sees Erwiderung-zur-CCR before Replik even though
Replik is the infringement-side response to the same triggering event.
**Re-sequencing** keeps the existing soc=0, prelim=5, sod=10 head and
the interim=40 / oral=50 / decision=60 / cost_app=70 / appeal_spawn=80
tail untouched. The 10 reshuffled rules move into a track-aware
arrangement:
10-19 infringement: sod=10, reply=12, rejoin=14
20-29 revocation: ccr=20, def_to_ccr=22, reply_def_ccr=24, rejoin_reply_ccr=26
30-39 amendment: app_to_amend=30, def_to_amend=32, reply_def_amd=34, rejoin_amd=36
Tied-date ordering after the reshuffle:
D+3mo: sod(10), ccr(20) — SoD then its CCR
D+5mo: reply(12), def_to_ccr(22), app_to_amend(30) — inf → rev → amd
D+7mo: reply_def_ccr(24), def_to_amend(32) — rev → amd
D+8mo: rejoin_reply_ccr(26), reply_def_amd(34) — rev → amd
**Two-phase swap** — every reshuffled rule first parks at sequence
1000+number, then jumps to its final value. Prevents transient
sequence-collisions if Postgres evaluates UPDATEs in parallel within
the same statement. Each UPDATE is keyed by submission_code AND the
SOURCE sequence_order, so re-apply is a no-op.
audit_reason set_config at top per mig 099 hotfix pattern.
Renumbered from mig 102 → mig 105 to avoid collision with archimedes
system_audit_log mig 102 (merged between fermi's parked session and
now); follows mig 104 (Einspruch name + CCR priority).
Two corrections to mig 100's merged-state:
1. **CCR priority informational → optional**. m's correction
2026-05-18 18:01. The fermi amend (e8d658a) flipping this didn't
land — paliadin merged the pre-amend c10f8cf. The Nichtigkeits-
widerklage is a substantive defensive choice, rendered unchecked
in the save modal so user opts in if they want to track it.
2. **Strip rule-cite brackets from Einspruch names**. m's
correction 2026-05-18 18:08. Every other rule name in the corpus
carries the act-name without a parenthetical rule cite — the two
Einspruch rules were outliers:
upc.inf.cfi.prelim 'Einspruch (R. 19 VerfO)' → 'Einspruch'
upc.rev.cfi.prelim 'Einspruch (R. 19 i.V.m. R. 46 VerfO)' → 'Einspruch'
plus EN equivalents. The legal_source / rule_code columns already
carry the citation in the meta line, so the name stays clean.
Idempotent: priority UPDATE guarded on 'informational'; name UPDATEs
guarded on the current parenthetical-bearing values. audit_reason
set_config at top per mig 099 hotfix pattern.
Renumbered from mig 101 → mig 104 to avoid collision with leibniz
CalDAV mig 101 + archimedes system_audit_log mig 102 (both merged
between fermi's parked session and now); mig 103 reserved for hertz.
New "Schriftsätze" tab on /projects/{id}, lazy-loaded by the
existing tab switcher (same pattern as the Checklisten tab — only
hits the API when the user actually opens it). Lists the project's
filing rules in a 4-column table: name (with submission_code under
it), party, legal basis, action button.
Action column shows [Generieren] for rules with a resolvable
template and "Keine Vorlage" / "No template" for rules without one.
The generate button fetches the .docx via XHR, parses the
Content-Disposition filename, creates an object URL, and triggers
the browser download via a hidden <a download>. Disabled
mid-flight to prevent double-submits.
The table opts into the `.entity-table--readonly` modifier — rows
themselves don't navigate; only the inline button does (avoids the
"clickable row that isn't" UX lie called out in the project
CLAUDE.md frontend conventions).
11 new i18n keys per language. New CSS block for the submission-row
typography (name + dim-grey code stacked vertically, right-aligned
action cell, italic no-template hint).
Two endpoints under /api/projects/{id}/:
GET /submissions
Lists the project's filing-type rules (event_type='filing',
lifecycle_state='published') for the project's proceeding,
each annotated with has_template via the registry's cheap
SHA-only probe. Powers the SubmissionsPanel.
GET /submissions/{code}/generate
Renders the .docx and streams it back as an attachment with
Content-Disposition: attachment; filename="…". Writes three
audit records: paliad.system_audit_log (event_type=
'submission.generated'), paliad.project_events (event_type=
'submission_generated', surfaces in Verlauf / SmartTimeline),
and paliad.documents (doc_type='generated_submission',
file_path NULL — bytes are regenerable from inputs per m's
Q3 pick, no server-side binary). All three writes use a 10s
background context so the user still gets the download if
audit insertion races a slow DB.
File naming follows §7 of the design:
{rule.name}-{project.case_number}-{YYYY-MM-DD}.docx with locale-
aware rule.name and slash→underscore sanitisation on
case_number. Empty case_number falls back to an 8-hex-char id from
the project UUID.
Visibility: ProjectService.GetByID gates every request; 404 (not
403) on no-access to avoid project enumeration. No profession floor
— matches every other write surface in paliad.
Wired into handlers.Services + dbServices + cmd/server/main.go.
Singletons constructed once at boot; no per-request allocation. No
migration needed — paliad.documents has no CHECK on doc_type, so
'generated_submission' is purely additive.
TemplateRegistry (services/submission_templates.go) walks the
m-locked Q4 fallback chain — templates/{FIRM_NAME}/{code}.docx →
templates/_base/{code}.docx → templates/_base/{family}.docx →
templates/_base/_skeleton.docx — against the Gitea repo
HL/mWorkRepo. SHA-cache + 5-min refresh check, identical pattern to
internal/handlers/files.go's HL Patents Style proxy. Distinguishes
"no template" (chain fallthrough) from "Gitea down" so the handler
can render different UI for each.
SubmissionVarsService (services/submission_vars.go) assembles the
~30-placeholder bag from project + parties + rule + next-deadline +
user + firm + today. Locale-aware long-date forms (DE + EN) and a
legal_source pretty-printer that rewrites DE.ZPO.276.1 → "§ 276 Abs.
1 ZPO" / "Section 276(1) ZPO" for the prefixes the 254-rule corpus
uses today. Unknown prefixes pass through unchanged.
Visibility inherits from ProjectService.GetByID
(paliad.can_see_project) — unauthorised callers get the same
ErrNotVisible that every project surface returns.
Pure-Go {{path.dot.notation}} placeholder engine + unit tests
(t-paliad-215, design docs/design-submission-generator-2026-05-19.md
§6). Chosen over github.com/lukasjarosch/go-docx because that library
treats sibling placeholders inside one <w:t> run as nested and
refuses to replace them — patent submissions routinely carry multiple
placeholders per paragraph (party blocks especially), so the library
is a non-starter.
Two-pass strategy preserves run-level formatting on the common path:
1. Pass 1: regex replace inside each <w:t>…</w:t> independently —
no format loss for the 99% case where placeholders are intact.
2. Pass 2: paragraph-level merge for paragraphs that still contain
orphan "{{" or "}}" markers (Word fragmented the placeholder
across runs).
Missing placeholders render [KEIN WERT: <key>] / [NO VALUE: <key>]
markers so the lawyer sees the gap in Word rather than getting a 400.
Tests cover: single-run, multi-per-run (the go-docx failure mode),
cross-run merge, missing-marker (DE+EN), XML escaping of special
chars, non-document zip entries preserved, placeholder regex
grammar.
DESIGN READY FOR REVIEW — copernicus inventor pass on the submission
generator (t-paliad-215). 5 questions answered with m's picks captured
in §2; awaiting head's go/no-go on coder shift.
Locked decisions:
- Scope: template-render to .docx (no LLM in v1)
- Template registry: Gitea (mWorkRepo proxy, same pattern as
HL Patents Style)
- Output: direct download, no server-side binary persistence
- Mapping: fallback chain (firm → base/code → base/family → skeleton)
- Slice 1: one template end-to-end on one project
(de.inf.lg.erwidg / Klageerwiderung)
No code, no migrations, no schema additions. Read-only design phase
per inventor SKILL.md.
Replaces the one-sentence "endpoint" stub with a proper landing: features list, update flow explainer, fresh-install download link, contact line. Renders the served version live from version.json. Paliad palette (midnight/lime). This is what the HL Patents Style ribbon's Info dialog now links to on OK.
m hit two bugs opening the Slice 1 export in Excel / Windows:
1. **Excel showed a "Repairs required" prompt** on open. Root cause:
the SetPanes call passed only `{Freeze: true, YSplit: 1}` — the
obvious-but-wrong shape. The resulting <pane> XML missed the
`topLeftCell` and `activePane` attributes that Excel requires for
a frozen-row pane (excelize's parser is permissive on re-read but
Excel is strict). Fix: complete the Panes struct (TopLeftCell="A2",
ActivePane="bottomLeft", Selection on bottomLeft) and surface
SetPanes errors instead of `_ =`-ignoring them.
2. **Windows Explorer / Excel's File→Info showed Modified=2006-09-16
("xuri")** — excelize's hardcoded first-commit defaults. Root cause:
buildXLSX never called SetDocProps so the canned defaults leaked.
Fix: SetDocProps({Created, Modified} = meta.GeneratedAt;
Creator = "Paliad (<firm>)"; Title/Description scoped per export).
3. **Bonus**: the outer-zip entry mtimes were stamped 2000-01-01 (the
deterministic constant) so extracted files showed a Y2K Modified
date in Explorer. Now stamped meta.GeneratedAt, which preserves
determinism within an export (same row state + same GeneratedAt →
same bytes, the actual m's-Q6 contract).
Also: set the active sheet to __meta (index 0) after sheet creation so
a future code path that adds/removes sheets can't leave an out-of-range
active-sheet index that would trip a separate "repairs required" path.
Regression tests in dump_export_test.go pin all three fixes by re-opening
the generated xlsx via excelize.OpenReader and asserting:
- docProps Created/Modified == meta.GeneratedAt (RFC 3339 UTC)
- docProps Creator contains "Paliad"
- xlsx bytes never contain "2006-09-16T00:00:00Z" or "<dc:creator>xuri</dc:creator>"
- sheet2/sheet3 raw XML carries topLeftCell + activePane + state=frozen
- outer-zip entries' Modified is within ±2s of GeneratedAt
- developer hatch: DUMP_EXPORT=1 writes /tmp/paliad-export-debug.{zip,xlsx}
for opening in real Excel.
Adds a 4th tab "Datenexport" to /settings (after Profil /
Benachrichtigungen / CalDAV) with a single-button card that triggers
GET /api/me/export. Browser handles the download via
Content-Disposition: attachment.
i18n: 12 new keys under einstellungen.export.* (DE primary, EN
secondary) — subtitle, bullets per format, scope notice, audit
notice, button label, post-click hint.
The tab is loaded lazily (idempotent loadExportTab) like every other
settings tab, and the runExport handler swaps in a transient <a download>
to use the browser's normal download pipeline.
Schema-only landing for Slice 1 of the CalDAV multi-calendar design
(docs/design-caldav-multi-calendar-2026-05-19.md). Sync engine NOT
touched — Slice 2 wires the per-binding fan-out. After this migration:
- paliad.user_calendar_bindings — N bindings per user with scope_kind
∈ {all_visible, personal_only, project, client, litigation, patent,
case}. Hierarchy scopes anchor scope_id at paliad.projects(id).
Partial unique indexes enforce one binding per (user, scope_kind,
scope_id) for hierarchical scopes and one per (user, scope_kind)
for the scope-less roots. RLS mirrors user_caldav_config.
- paliad.appointment_caldav_targets — per-(appointment, binding) join
carrying caldav_uid + caldav_etag. UID stays canonical per
appointment so the same event in N cals shares one UID.
- Backfill — one all_visible binding per existing user_caldav_config
row, one target row per appointment already pushed. Maps target to
the creator's binding, matching today's Phase F semantics where the
creator's goroutine owns the etag.
Legacy paliad.appointments.caldav_uid / caldav_etag columns are
untouched (kept as denormalised pointers through Slice 1+2; dropped
in Slice 4 after telemetry).
Dry-run verified against live Supabase (PG 15.8): synthetic config +
appointment backfill creates exactly 1 binding + 1 target; re-run is a
no-op; all CHECK + unique-index constraints enforce as designed; final
assertions pass with 0 missing rows.
Prod impact at landing: 0 rows in user_caldav_config and 0 appointments
with caldav_uid — backfill is a true no-op. Slice 1 ships invisible.
Slice 1 of docs/design-paliad-test-strategy-2026-05-19.md — the test
infrastructure that would have caught mig 098 (digit-regex) and mig 099
(missing audit_reason) before the deploy hit prod.
Three new files + one route addition:
- Makefile: `make verify-migrations` (alias `verify-mig`) runs the
per-migration dry-run + boot smoke against TEST_DATABASE_URL. Fails
fast with a clear error if TEST_DATABASE_URL is unset so CI can't
silently pass a missing env var. `make test` and `make test-go`
cover the rest of the short / full Go suites.
- internal/db/migrate_test.go (TestMigrations_DryRun): walks every
pending *.up.sql in numeric order, applies each inside its own
BEGIN..ROLLBACK transaction, fails on the first SQL error with the
file name + Postgres error. "Pending" = greater than the scratch
DB's current tracker version, so fresh-DB CI runs verify everything
while developer scratch DBs only re-verify the new pending migration.
Always non-destructive — the rollback runs even on success.
- cmd/server/main_smoke_test.go (TestBootSmoke): boots the apply path
end-to-end, asserts (a) db.ApplyMigrations returns nil, (b) the
tracker advanced to the highest *.up.sql version on disk with
dirty=false, (c) GET /healthz on the registered mux returns 200.
The dry-run catches per-migration syntax errors; this catches the
apply+bind path the container actually runs.
- internal/handlers/handlers.go: adds a GET /healthz public route — a
no-auth, no-DB liveness probe. Used by the boot smoke; also safe
for any future orchestrator or uptime check.
Both live-DB tests gate on TEST_DATABASE_URL and skip cleanly without
it, matching the rest of paliad's live-DB test pattern.
Verification: go build ./... clean, go vet ./... clean,
go test -short ./internal/... ./cmd/... clean (all packages pass,
live-DB tests skip), bun run build clean (2436 i18n keys unchanged).
Per CLAUDE.md inventor → coder gate, NOT self-merged.
t-paliad-214. m walked all 9 questions live; deviated on Q2 (project-scope
floor = any team member, not associate), Q3 (retention 90d, not 7d), Q5
(paliadin_turns hard-excluded from org scope, not opt-in). Other 6
matched inventor picks. Net slice-plan deltas captured in §12.
Addendum after §10 captures m's picks (2026-05-19, via AskUserQuestion):
§8.1 bidirectional default: YES; §8.2 personal_only: KEEP first-class;
§8.3 MKCALENDAR: Slice 2 with Google-degrade; §8.4 soft caps: NONE in
v1 (add later if telemetry warrants); §8.5 admin view: don't ship;
§8.6 approval-flow remote-edit gap: separate task under t-138.
Net effect: drops the 20-warn/80-block UI guards from §6 and the
`read_only` flag from §3; Slice 2 gains MKCALENDAR + binding-count
telemetry; §8.6 fix filed separately so multi-cal slices stay clean.
m's 2026-05-19 picks via AskUserQuestion interview:
- Q1 budget: 60–90s gate, 3–4min full (inventor's call — m deferred)
- Q2 CI: Gitea Actions, gate tier only
- Q3 test DB: YouPC for devs + ephemeral docker for CI
- Q4 coverage: critical-path only, no % gate
- Q5 floor: Slices 1+4+5 before new feature work
- Q6 ownership: head decides + rotate per profile
All six matched inventor's recommendation. Slice 1 (migration
dry-run + boot smoke) starts first; Slices 4+5 in parallel after.
t-paliad-214. Covers scope definitions, format choices (xlsx + JSON + CSV
in one zip, deterministic, schema_version 1), authorization model
(global_admin / project-team-with-associate-floor / authenticated-self),
trigger model (sync personal+project, async org), storage on
PALIAD_EXPORT_DIR with 7-day retention, PII/GDPR posture, 3-slice plan,
and 9 open questions for m. No code touches — design only.
t-paliad-213 — six-layer pyramid (migration dry-run, Go/frontend unit,
frontend DOM, service live-DB, handler integration, Playwright E2E),
audit of current coverage (323 test funcs, 24 untested services, 53
untested handlers, 4/90 frontend modules), eight-slice tracer-bullet
roll-out, six open questions for m.
Read-only design phase per CLAUDE.md inventor gate — no test files,
make targets or CI configs touched. Awaiting m go/no-go on §5 slice
plan + §6 open questions before any coder shift.
Inventor design for letting users connect Paliad's CalDAV sync to N
external calendars per user, with scope filters (master / personal /
per-project / per-client / per-litigation / per-patent / per-case)
rather than today's single-target push. Splits credentials (per user,
unchanged) from bindings (new join table). Adds a per-target join for
push state so the same Appointment can live in multiple calendars at
once. Includes per-provider limit research (iCloud 100, Google ~100,
Fastmail no cap, Nextcloud 30 default), a 4-slice rollout plan, and 6
open questions for m. READ-ONLY design — no schema or code changes.