docs: PRD/design — submission generator v2 ("Composer") (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Sectioned composition, swappable base templates, in-app prose editing,
building-blocks library. Deepens t-paliad-215 + t-paliad-238 without
replacing them — v1 contracts (submission_drafts shape, {{rule.X}}
aliases, audit shape) preserved.

7 slices A→G; Slice B is the smallest "Composer works" milestone.
Existing 11 v1 drafts continue via v1 path; opt-in upgrade per draft.

12 open design questions with recommended defaults + alternatives for m
to ratify via head escalation (no AskUserQuestion per task brief).

Flags two issue-body inaccuracies: no submission_drafts.audit_log column
(audit lives in system_audit_log + project_events); live row count is
11, not 7.

t-paliad-312
This commit is contained in:
mAi
2026-05-26 18:37:52 +02:00
parent 235e68496b
commit 635457474a

View File

@@ -0,0 +1,963 @@
# Design — Submission generator v2 ("Composer")
**Author:** cronus (inventor)
**Date:** 2026-05-26
**Task:** t-paliad-312
**Issue:** m/paliad#141
**Branch:** `mai/cronus/inventor-prd-for`
**Status:** DESIGN READY FOR REVIEW
**Prior art:**
- `docs/design-submission-generator-2026-05-19.md` (t-paliad-215, Slice 1: template-merge engine, fallback chain, audit shape)
- `docs/design-submission-page-2026-05-22.md` (t-paliad-238, Slice A: dedicated draft editor at `/projects/{id}/submissions/{code}/draft`, sidebar + preview, autosave)
This design deepens — it does **not** replace — both prior docs. Every contract they locked in (placeholder grammar, `{{rule.X}}` aliases, audit-log shape, fallback chain, in-house renderer) is preserved. v2 adds sections, swappable bases, in-app prose editing, and building blocks **on top of** that substrate.
---
## §0 TL;DR
Today (v1): one Word `.docx` template per `submission_code`, picked from Gitea via a fallback chain, merged with a flat `{{placeholder}}` variable bag built from project + parties + deadline + user + firm state. The lawyer's only authoring surface is the sidebar — they fill in variables, see a read-only HTML preview, export to `.docx`, and finish in Word.
What m wants (v2 / "Composer"):
1. **Sections** — every submission is composed of named, independently-editable section blocks (`letterhead`, `caption`, `introduction`, `requests`, `facts`, `legal_argument`, `evidence`, `exhibits`, `closing`, `signature`). The lawyer can reorder, hide, and rename them.
2. **Selectable base templates** — the lawyer picks the *base* (firm letterhead / neutral skeleton / UPC-formal / LG-Düsseldorf-style); the base supplies the document chrome, fonts, paragraph styles, and seeds the default section set. Lawyer can swap mid-draft without losing prose.
3. **Editability of content** — inside paliad, the lawyer authors free prose per section (Markdown source-of-truth, contentEditable surface, minimal bold/italic/heading/list/quote toolbar). Variables `{{…}}` are still legal anywhere in section content.
4. **Building blocks** — a library of reusable section-shaped chunks (standard Anträge phrasing, *Verspätungseinrede* boilerplate, *Auslandszustellung* evidence offering, …). Authored once, inserted into any compatible section across drafts, with private / team / firm / global visibility tiers. Copy-on-insert so the lawyer's edits never get clobbered; insertion records lineage for opt-in "refresh from library".
**Hard constraints honoured:**
- The existing `submission_drafts` contract — `variables jsonb`, `selected_parties uuid[]`, `language`, `last_exported_*` columns, owner-scoped RLS — stays load-bearing. v1 drafts continue to render via the v1 path (template-only fallback) until the lawyer opts in to Composer for that draft.
- The `{{rule.X}}` legacy aliases (still resolved by `SubmissionVarsService` alongside the canonical `{{procedural_event.X}}`) are honoured inside section content too.
- The audit-log shape (`system_audit_log` event_type `submission.exported`, `project_events` event_type `submission_exported`) is preserved; v2 exports add `composer: true` to the metadata jsonb.
- No new third-party Go dependency. The in-house renderer (from commit `8ea3509`) extends to MD → OOXML and section-anchor splicing.
**Slice plan:** seven slices A → G, each independently shippable. Slice A lands the base picker and a read-only section list (no behavior change). Slice B is the smallest "Composer actually works" milestone: editable prose sections, anchor-spliced render, MD → OOXML walker for paragraphs.
**No code in this doc.** Implementation gate is m's go/no-go through the head.
---
## §1 Premises verified live (2026-05-26)
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
| Claim | Verification |
|---|---|
| `paliad.submission_drafts` exists with the v1 contract. | `information_schema.columns` query: `id, project_id (nullable), submission_code, user_id, name, variables jsonb DEFAULT '{}', last_exported_at, last_exported_sha, selected_parties uuid[] DEFAULT '{}', last_imported_at, language text DEFAULT 'de', created_at, updated_at`. RLS on, four policies (select/insert/update/delete) gated on `paliad.can_see_project` + owner-scoped UPDATE/DELETE. |
| There is **no** `submission_drafts.audit_log` column — the issue text is inaccurate on this point. | Same `information_schema` query: no column matching `%audit%`. Audit rows live in `paliad.system_audit_log` (event_type `submission.exported`) plus `paliad.project_events` (event_type `submission_exported`). This is the same shape t-paliad-238 §D4 locked in. **Issue #141 needs a one-line correction; flagged in §13.** |
| 11 live draft rows on prod (the issue text said "7"). | `SELECT COUNT(*) FROM paliad.submission_drafts` → 11 rows across 1 user, 4 projects, 10 submission_codes. Mostly small variable maps (25224 bytes JSONB). All language `de` except one `en`. Migration to v2 must keep these renderable without lawyer intervention. |
| `{{rule.X}}` legacy aliases are alive and resolved. | `internal/services/submission_vars.go` resolves `procedural_event.code/name/name_de/name_en/legal_source/legal_source_pretty/primary_party/event_kind` as canonical; the `rule.*` aliases (`rule.submission_code`, `rule.name`, `rule.name_en`, `rule.legal_source`, `rule.legal_source_pretty`, `rule.primary_party`, `rule.event_type`) resolve to identical values per `submission_vars_aliases_test.go` (~85 LoC of golden tests). Any v2 surface that touches placeholder text must preserve this. |
| In-house renderer handles single-run AND cross-run placeholder fragmentation. | `internal/services/submission_merge.go`, ~530 LoC. Two-pass: text-node replace first (preserves run-level formatting); paragraph-merge fallback when `{{` / `}}` survives pass 1 (handles Word's autocorrect-split placeholders). `lukasjarosch/go-docx` was explicitly rejected (refuses sibling placeholders inside one run). v2 reuses this renderer wholesale. |
| Templates resolve via a five-tier fallback chain. | `internal/handlers/submission_drafts.go` `resolveSubmissionTemplate`: `per_code_lang` (`templates/<FIRM>/<code>.<lang>.docx`) → `per_code``skeleton_lang` (`_skeleton.<lang>.docx`) → `skeleton` (`_firm-skeleton.docx`, `_skeleton.docx`) → `letterhead` (HL Patents Style `.dotm`). v2's base concept slots in at the `_firm-skeleton` / `_skeleton` tier but moves the section-anchor wiring into the base body. |
| Templates live in Gitea at `HL/mWorkRepo:6 - material/Templates/Word/Paliad/<FIRM>/...`. | `internal/handlers/files.go` Gitea proxy, in-process SHA cache, 5-min refresh. `_firm-skeleton.docx` carries 168 HL paragraph styles (memory `01cdd589`); `_skeleton.docx` carries a universal layout. Same proxy pattern reused for bases + building blocks if they need .docx-backed content. |
| Frontend draft editor exists at `/projects/{id}/submissions/{code}/draft`. | `frontend/src/submission-draft.tsx` (231 LoC) + `frontend/src/client/submission-draft.ts` (1913 LoC) — sidebar with variable groups (Mandant & Verfahren, Parteien, Frist, Sonstiges), language toggle (DE/EN), party multi-select, "Aus Projekt importieren", read-only preview pane, autosave. v2 grafts new sections + base picker into the same shell. |
| Migration tracker is at **106 in the live DB**; worktree filesystem has migration files up to **145**. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → newest is `145_scenarios.up.sql`. Multiple branches have queued migrations (e.g. 119145). Next free slot for v2 work on this branch is **146**; coder must re-check at implementation time in case other branches commit first. |
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; used by every project-scoped table. v2's new tables (`submission_bases`, `submission_sections`, `submission_building_blocks` + `_versions`) all gate on it. |
| `internal/branding.Name` defaults to "HLC", overridable via `FIRM_NAME`. | Plumbing reused for per-firm bases + per-firm building blocks (visibility tier "firm"). |
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler, not a `::before` overlay". Composer's section list (when rendered as cards/rows) follows this. |
**Doc-vs-live conflicts found:**
- Issue #141 mentions `submission_drafts.audit_log` and "7 live rows". The live table has no `audit_log` column (audit goes to `system_audit_log` + `project_events`); live row count is 11. Issue body should be corrected post-design-approval; the design does **not** depend on either claim being true.
---
## §2 m's headline requirements → design surface
| Issue text | Design surface |
|---|---|
| "different sections" | §4 data model (`submission_sections` table) + §6 default section set per `submission_code` + Slices B / G. |
| "selectable 'base templates'" | §5 base picker, `submission_bases` table with Gitea-backed body + section-default spec + Slice A / E. |
| "editability of content" | §7 editor UX — contentEditable per section with minimal toolbar, Markdown source-of-truth + Slice B. |
| "building blocks" | §8 `submission_building_blocks` table + library admin + section picker + Slice C. |
The four are interlocking but ship in independent slices.
---
## §3 Naming & vocabulary
For clarity throughout this doc and downstream code:
| Term | Meaning |
|---|---|
| **Composer** | The v2 submission-authoring experience (the editor page + render pipeline). NOT a separate product or feature flag — the existing `/projects/{id}/submissions/{code}/draft` URL stays; the editor evolves. |
| **Base** | A `.docx` chrome supplying letterhead, paragraph styles, font families, header/footer, and the default section spec for a draft. Replaces the loose "skeleton" terminology in v1's fallback chain. |
| **Section** | A named, ordered, lawyer-editable block of content within a draft (e.g. `requests`, `facts`). Stored as one row per (draft, section_key) in `submission_sections`. |
| **Building block** | A reusable, library-scoped chunk of section-shaped content. Inserted into a section copies its Markdown into the section; the section row retains `building_block_id` lineage. |
| **Variable bag** | The flat `{{path.dot.notation}} → string` map built by `SubmissionVarsService`. Unchanged from v1. |
| **Composer override** | The contents of `submission_drafts.variables jsonb` — lawyer's per-draft variable overrides. Unchanged from v1. |
| **Section anchor** | A pair of tokens `{{#section:KEY}} … {{/section:KEY}}` inside the base body that the render pipeline replaces with rendered section content. |
| **Section spec** | A JSON document on each base declaring (a) the default section set, (b) per-section default order, (c) per-section style anchors, (d) which Markdown constructs map to which paragraph styles. |
All identifiers in code follow the project's English convention (per project CLAUDE.md). User-facing labels are bilingual (DE primary, EN secondary).
---
## §4 Data model
### §4.1 Existing — preserved unchanged
`paliad.submission_drafts` keeps every column from v1. v2 adds two nullable columns:
| Column | Type | Nullable | Purpose |
|---|---|---|---|
| `base_id` | uuid | YES (FK → `submission_bases.id`) | Which base the draft renders against. NULL = legacy / v1 render path (fall back to `resolveSubmissionTemplate` chain). |
| `composer_meta` | jsonb | NO, DEFAULT `'{}'::jsonb` | Composer-specific metadata: `{"section_order": ["caption","requests","facts",…], "hidden_sections": ["evidence"], "active_locale": "de"}`. Order/hidden tracked here so a single fast read paints the editor without joining sections. |
Both additive; existing 11 rows get `base_id = NULL` (interpreted: not yet upgraded → render via v1 fallback chain) and an empty `composer_meta`. The migration is purely additive; no row rewrites.
The existing `variables jsonb` column still holds the lawyer's per-draft variable overrides. The `language` column still drives the active locale. `selected_parties uuid[]` still drives party scoping. Nothing about v1 is removed.
### §4.2 New — `submission_bases`
```sql
CREATE TABLE paliad.submission_bases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE, -- 'hlc-letterhead', 'neutral', 'upc-formal', 'lg-duesseldorf'
firm text, -- 'HLC' or NULL (global)
proceeding_family text, -- 'de.inf.lg', 'upc.inf.cfi', or NULL (any)
label_de text NOT NULL,
label_en text NOT NULL,
description_de text,
description_en text,
gitea_path text NOT NULL, -- 'Templates/Word/Paliad/HLC/_firm-skeleton.docx'
section_spec jsonb NOT NULL, -- §6.1 section-spec JSON
is_default_for text[] NOT NULL DEFAULT '{}', -- submission_codes this base seeds by default
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX submission_bases_firm_family_idx
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
-- All authenticated users SELECT; mutations admin-only (see §10).
```
Each base is a thin pointer at a `.docx` in Gitea plus the section-spec JSON. The actual body XML, paragraph styles, fonts, and section anchors live in the `.docx`; the table gives us listable, RLS-aware metadata, and a stable id for `submission_drafts.base_id` to reference.
Seed rows for v2.0:
| slug | firm | proceeding_family | label_de | label_en |
|---|---|---|---|---|
| `hlc-letterhead` | HLC | (any) | HLC-Briefkopf | HLC letterhead |
| `neutral` | (null) | (any) | Neutraler Schriftsatz | Neutral skeleton |
| `lg-duesseldorf` | (null) | `de.inf.lg` | LG-Düsseldorf-Stil | LG-Düsseldorf style |
| `upc-formal` | (null) | `upc.inf.cfi` | UPC-Verfahren | UPC formal |
The Gitea `_firm-skeleton.docx` becomes the body for `hlc-letterhead`; the universal `_skeleton.docx` becomes `neutral`. The two specialist bases are new authoring work for Slice E.
### §4.3 New — `submission_sections`
```sql
CREATE TABLE paliad.submission_sections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
section_key text NOT NULL, -- 'requests', 'facts', 'legal_argument', …
order_index int NOT NULL, -- lawyer-orderable
kind text NOT NULL, -- 'prose' | 'requests' | 'evidence' | 'caption_auto' | 'letterhead_auto' | 'signature_auto'
label_de text NOT NULL, -- lawyer-renameable
label_en text NOT NULL,
included bool NOT NULL DEFAULT true,
content_md_de text NOT NULL DEFAULT '', -- Markdown source; empty for *_auto kinds
content_md_en text NOT NULL DEFAULT '',
building_block_id uuid REFERENCES paliad.submission_building_blocks(id) ON DELETE SET NULL,
building_block_version int, -- snapshot version at insert time
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (draft_id, section_key)
);
CREATE INDEX submission_sections_draft_idx
ON paliad.submission_sections (draft_id, order_index);
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
-- Visibility flows through draft_id → submission_drafts → can_see_project + owner-scoped.
```
`section_key` is a stable identifier (English slug) the render pipeline keys off. `kind` distinguishes free-prose sections (`prose`, `requests`, `evidence`) from sections rendered automatically from the variable bag (`*_auto`) — those can be hidden but not edited as text. `label_de` / `label_en` start from the section spec defaults but can be renamed per draft.
`content_md_de` + `content_md_en` are both columns from the start (Slice F is not deferred — see §11 Q7) so a draft is bilingual-by-construction; the active rendering picks the column matching `submission_drafts.language`.
`building_block_id` retains lineage but the row's content is the snapshot at insertion time (§8.3 copy-on-insert).
### §4.4 New — `submission_building_blocks` + `_versions`
```sql
CREATE TABLE paliad.submission_building_blocks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL,
firm text, -- 'HLC' or NULL (global)
section_key text NOT NULL, -- which section kind this block fits
proceeding_family text, -- 'de.inf.lg', NULL = any
title_de text NOT NULL,
title_en text NOT NULL,
description_de text,
description_en text,
content_md_de text NOT NULL DEFAULT '',
content_md_en text NOT NULL DEFAULT '',
author_id uuid REFERENCES paliad.users(id),
visibility text NOT NULL, -- 'private' | 'team' | 'firm' | 'global'
is_published bool NOT NULL DEFAULT false,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
UNIQUE (slug, firm)
);
CREATE TABLE paliad.submission_building_block_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
building_block_id uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
version int NOT NULL,
content_md_de text NOT NULL,
content_md_en text NOT NULL,
edited_by uuid REFERENCES paliad.users(id),
note text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (building_block_id, version)
);
CREATE INDEX submission_building_blocks_section_visibility_idx
ON paliad.submission_building_blocks (section_key, visibility, firm, proceeding_family)
WHERE deleted_at IS NULL AND is_published;
```
Visibility tiers (mirror the team-visibility model elsewhere in paliad):
- `private` — visible only to `author_id`.
- `team` — visible to every member of any team the author belongs to that touches the project context (resolved via the standard team-visibility helpers).
- `firm` — visible to every user whose `email` ends in any of `ALLOWED_EMAIL_DOMAINS` and whose `branding.Name` matches the block's `firm`.
- `global` — visible to every authenticated user.
Versioning: same retention-20 policy as email templates (`internal/services/email_template_service.go`). Each Save appends a `_versions` row + GCs to 20 in-transaction.
### §4.5 Migration count and order
One migration per concern (atomic, drift-safe), suggested in this order — coder confirms slot numbers at impl time:
| Slot | Purpose |
|---|---|
| 146 | `ALTER TABLE submission_drafts ADD COLUMN base_id uuid REFERENCES submission_bases(id); ADD COLUMN composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;` (must come after `submission_bases` exists — alternative: bundle 146+147) |
| 147 | `CREATE TABLE submission_bases` + RLS + seed rows for `hlc-letterhead`, `neutral`. (Specialist bases land in Slice E migrations.) |
| 148 | `CREATE TABLE submission_sections` + RLS + indexes. |
| 149 | `CREATE TABLE submission_building_blocks` + `_versions` + RLS + indexes. |
The 146 column add must reference the bases table — practically, 147 (create bases) ships first and 146 becomes the next slot. Slot numbers are flexible; the coder picks contiguous free slots at impl time.
---
## §5 Bases — selection, swap, default
### §5.1 What a base supplies
A base provides:
1. **Document chrome** — the `.docx`'s headers, footers, page setup, font tables, paragraph styles. From the Gitea body.
2. **Section anchors**`{{#section:KEY}} … {{/section:KEY}}` pairs in the body, declaring where each section's rendered content goes. From the Gitea body.
3. **Section spec** — JSON on the base row declaring the default section set, ordering, kinds, labels, and the stylemap (which Markdown construct maps to which paragraph style in this base). From the DB.
4. **Variable bag context** — the base inherits the same `SubmissionVarsService` placeholder set as v1. `{{firm.name}}`, `{{user.display_name}}`, etc. work unchanged.
### §5.2 Picking a base on draft create
On `POST /api/projects/{id}/submissions/{code}/drafts`:
1. Compute the proceeding family (`de.inf.lg` from `de.inf.lg.erwidg`).
2. Look up the firm's default base for this family: `SELECT … FROM submission_bases WHERE firm=$FIRM AND is_active AND $code = ANY(is_default_for) ORDER BY priority` (priority resolved by spec — see §11 Q3 alternatives).
3. Fall back: firm + family → firm + any-family → global + family → global + any → first active row.
4. Seed `submission_drafts.base_id` with the picked base.
5. Seed `submission_sections` rows from the base's `section_spec.defaults` array.
The lawyer can change the base via the sidebar picker; on swap the section content rows are preserved (only the base.docx body and styles change at render time). See §5.3.
### §5.3 Swapping a base mid-draft
Hard rule: **section content is base-agnostic.** Markdown source carries no inline style references; lists, headings, bold/italic are semantic tokens. The MD → OOXML walker applies the *current base's* stylemap at render time. Swap is safe by construction.
What DOES change on swap:
- Default section set: if the new base spec defaults to a different ordering or includes sections the old base lacked, the lawyer is offered a non-destructive "Apply base's default section order? (keeps existing content)" prompt. Default answer: NO.
- `composer_meta.section_order` honoured if set; else `base.section_spec.defaults` honoured.
- Sections that exist in the draft but not the new base's spec stay visible but get an "ungrounded" badge so the lawyer notices.
- Sections in the new base's spec but not in the draft: they're created lazily on first focus, empty content.
### §5.4 Authoring a new base
Each base is one `.docx` in Gitea plus one row in `submission_bases`. Authoring:
1. Lawyer (or admin) creates the `.docx` in Word with HL Patents Style applied. Inserts section anchors as plain text `{{#section:KEY}}` / `{{/section:KEY}}` in the body where each section should go.
2. Generator script (`scripts/gen-submission-base/main.go`, modelled on `scripts/gen-hl-skeleton-template/main.go` from t-paliad-275) strips macros, validates anchors, embeds default section spec into the file as a custom XML part.
3. Admin uploads to Gitea via the contents API (as mAi via `--netrc-file ~/.netrc-mai`).
4. Admin inserts the `submission_bases` row via the new `/admin/submission-bases` editor (Slice E).
This mirrors the t-paliad-275 workflow that already shipped `_firm-skeleton.docx`. No new tooling beyond a slightly extended generator script.
---
## §6 Sections — the default set
### §6.1 Section spec on a base
JSON document stored on each `submission_bases.section_spec` row. Shape:
```json
{
"version": 1,
"stylemap": {
"paragraph": "HLpat-Body-B0",
"heading_1": "HLpat-Heading-H1",
"heading_2": "HLpat-Heading-H2",
"heading_3": "HLpat-Heading-H3",
"list_bullet": "HLpat-Body-B0",
"list_numbered": "HLpat-Body-B0",
"blockquote": "HLpat-Body-B1"
},
"defaults": [
{"section_key": "letterhead", "kind": "letterhead_auto", "order_index": 1, "label_de": "Briefkopf", "label_en": "Letterhead", "included": true},
{"section_key": "caption", "kind": "caption_auto", "order_index": 2, "label_de": "Rubrum", "label_en": "Caption", "included": true},
{"section_key": "introduction", "kind": "prose", "order_index": 3, "label_de": "Einleitung", "label_en": "Introduction", "included": true},
{"section_key": "requests", "kind": "requests", "order_index": 4, "label_de": "Anträge", "label_en": "Requests", "included": true},
{"section_key": "facts", "kind": "prose", "order_index": 5, "label_de": "Sachverhalt", "label_en": "Facts", "included": true},
{"section_key": "legal_argument", "kind": "prose", "order_index": 6, "label_de": "Rechtliche Würdigung", "label_en": "Legal argument", "included": true},
{"section_key": "evidence", "kind": "evidence", "order_index": 7, "label_de": "Beweisangebote", "label_en": "Evidence offering", "included": true},
{"section_key": "exhibits", "kind": "prose", "order_index": 8, "label_de": "Anlagen", "label_en": "Exhibits", "included": false},
{"section_key": "closing", "kind": "prose", "order_index": 9, "label_de": "Schlussformel", "label_en": "Closing", "included": true},
{"section_key": "signature", "kind": "signature_auto", "order_index": 10, "label_de": "Unterschrift", "label_en": "Signature", "included": true}
]
}
```
The `stylemap` is per-base because different bases use different paragraph style names (HLpat-* on the HLC base, generic Word styles on the neutral base). The default section list is a starting point; per-submission_code overrides land in §6.2.
### §6.2 Section default overrides per submission_code
Some submission codes naturally invert the section order (e.g. `upc.inf.cfi.sod` — Statement of Defence — puts requests last; `de.inf.lg.berufung` has a "Berufungsanträge" before facts, etc.). A small Go-side override table maps `submission_code → (section adjustments)`:
```go
// internal/services/submission_section_defaults.go (NEW, Slice B)
var sectionOverrides = map[string][]sectionAdjustment{
"upc.inf.cfi.sod": {
{SectionKey: "requests", OrderIndex: 9}, // last
},
"de.inf.lg.berufung": {
{SectionKey: "requests", OrderIndex: 4}, // before facts
{SectionKey: "berufungsantraege", Insert: true, OrderIndex: 4, Kind: "requests", LabelDE: "Berufungsanträge", LabelEN: "Appeal requests"},
},
// …
}
```
This is intentionally code-driven (not DB-driven) for v2.0 (see §11 Q8). The override applies once at draft creation; subsequent lawyer reorders win.
### §6.3 `*_auto` sections — content comes from the bag, not the editor
Three section kinds render entirely from the base + variable bag, with no user-editable content body:
- **`letterhead_auto`** — typically a `.docx` header part (HL Patents Style header1.xml) is the actual letterhead; the in-body section is either empty or a small "Schriftsatz von:" preamble using `{{firm.name}}`, `{{user.display_name}}`, etc. The lawyer toggles include/exclude but doesn't type text here.
- **`caption_auto`** — the "Rubrum" block. Renders from `{{parties.claimant.0.name}}`, `{{parties.defendant.0.name}}`, `{{project.case_number}}`, `{{project.court}}` etc. via a fixed micro-template embedded in the base (paragraphs using HLpat-Table-Recitals-Party styles). Lawyer toggles include/exclude.
- **`signature_auto`** — same shape. Uses `{{firm.signature_block}}` + `{{user.display_name}}`.
The base's section anchor for each `*_auto` section contains the OOXML for the canonical block. When the section is `included=false`, the entire `{{#section:KEY}} … {{/section:KEY}}` slot is dropped at render time.
### §6.4 Custom sections
Slice G adds "+ Abschnitt hinzufügen". UX:
- A picker shows existing slugs the base spec knows about that aren't yet in this draft, plus a "Neue Abschnitts-Slug" form (lawyer types `slug`, picks kind from `prose`/`requests`/`evidence`, default labels).
- Inserting writes a new `submission_sections` row.
- The render pipeline expects the base to declare an anchor for every section key. **Custom slugs without a matching anchor in the base** render after the last anchored section, before the signature, as plain paragraphs using the base's `paragraph` stylemap entry — the "trailing zone" semantic.
This keeps custom sections rendering predictably without requiring lawyer edits to the base `.docx`.
### §6.5 Reorder / hide
- Reorder: drag-and-drop in the editor; PATCH writes the new `order_index` array AND mirrors it into `composer_meta.section_order` for fast paint.
- Hide: per-section "Nicht aufnehmen" toggle flips `included` on the row.
- Both safe to round-trip; the base's anchor slots are stable, only the content (or absence of content) changes.
---
## §7 Editor UX
### §7.1 Page shell (unchanged)
`/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`. Sidebar + main body grid (the existing pattern in `frontend/src/submission-draft.tsx`). Sidebar carries: switcher, name, language toggle, save status, import-from-project, party picker, variable groups (Mandant/Verfahren/Sonstiges).
### §7.2 What's new in the body
The main body grows from a single read-only preview pane to a **section stack**:
```
┌──────────────────────────────────────────────────────────┐
│ Composer │
│ ────────────────────────────────────────────────── │
│ Basis: [HLC-Briefkopf ▾] [Basis wechseln…] │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 1. Briefkopf [Auto] [☐ verstecken] │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 2. Rubrum [Auto] [☐ verstecken] │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 3. Einleitung [+ Baustein] [⋮] │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ [B] [I] [H₁] [H₂] [• ] [1.] [❝] │ │ │
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ Namens und in Vollmacht der Klägerin │ │ │
│ │ │ {{parties.claimant.0.name}} legen wir │ │ │
│ │ │ folgende Klageschrift… │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 4. Anträge [+ Baustein] [⋮] │ │
│ │ … │ │
│ └──────────────────────────────────────────────────┘ │
│ … │
│ │
│ [Vorschau ▾] ← collapsible read-only preview │
└──────────────────────────────────────────────────────────┘
```
Per-section affordances:
- **`[B] [I] [H₁] [H₂] [• ] [1.] [❝]`** — a thin shared toolbar that activates on focus of any contentEditable region. One toolbar instance hops between sections.
- **`{{var}}` autocomplete** — typing `{{` opens a picker filtered by the resolved bag's keys. Inserts as plain text.
- **`+ Baustein einfügen`** — opens the building-block picker filtered to this section's `section_key` + project context. Insertion appends the block's Markdown to current cursor position.
- **`⋮` per-section menu** — Rename, Hide/Show, Move up/down, Reset to base default, Remove (custom sections only).
- **Read-only preview** is collapsed by default in v2 (the in-place editor is the primary surface); a `[Vorschau ▾]` toggle shows the merged HTML beneath the section stack as a fidelity guide. Same renderer as v1.
### §7.3 Editor surface — contentEditable + Markdown serialization
- Each section's contentEditable region is wired to a tiny TS shim (`frontend/src/client/composer-md.ts`, target ~250 LoC) that:
- On keystroke, debounces 500ms, serialises the DOM to Markdown via a deterministic walker (headings, paragraphs, lists, blockquote, bold/italic, links — no images, no tables in v2.0).
- PATCH-saves `content_md_<lang>` to `/api/.../sections/{section_id}`.
- On focus, re-renders Markdown → DOM (idempotent on round-trip).
- The Markdown serializer is the source of truth. The DOM is a view. This guarantees swap-base survives content.
- A `[Quelltext anzeigen]` toggle exposes the Markdown source in a `<textarea>` for lawyers who prefer typing raw.
**Why not Tiptap / ProseMirror:**
- ~80kB bundle add per editor instance; 8 sections per page means rendering cost.
- Paliad's frontend ethos is HTML-first, custom JSX renderer, no React. Adding ProseMirror pulls a heavy dep.
- Lawyers already accept "final formatting in Word"; the editor's job is structural + first-draft prose, not WYSIWYG fidelity.
Slice D extends the toolbar; Slice B ships with bold/italic/headings/bullet-list/quote only (the minimum that exercises every stylemap entry).
### §7.4 Variable insertion
The sidebar's existing `paintVariables` flow stays unchanged — it renders one input per placeholder for the lawyer's overrides on flat values (`{{project.case_number}}`, `{{parties.claimant.0.name}}`, etc.).
In section content, the lawyer types `{{` and an inline picker drops below the cursor showing the resolved-bag keys + their current values. Selecting one inserts the literal `{{path.dot.notation}}` text. No expansion — the text stays as the placeholder until render.
### §7.5 Per-section building-block insertion
`+ Baustein einfügen` button → modal listing visible blocks filtered to `section_key = <this section's key>` AND `proceeding_family ∈ {project family, NULL}` AND visibility within reach. Each row shows title, description, first 200 chars of `content_md_<lang>`. Click → block's full `content_md_<lang>` is appended to current cursor position; section row gets `building_block_id` + `building_block_version` stamped.
If the lawyer already has content in the section, the insert is appended with a `\n\n` separator. The lawyer can drag/edit afterwards.
A small badge in the section header — "Bausteinquelle: <block title> (Version <N>)" — surfaces lineage. Clicking the badge opens a one-step "Mit Bibliotheksversion abgleichen" diff view (Slice C+).
### §7.6 Autosave
Per-keystroke debounce 500ms (matches v1). PATCH `/api/.../drafts/{id}/sections/{section_id}` with `{content_md_de | content_md_en | order_index | included | label_de | label_en}`. No optimistic locking — drafts are owner-scoped per row, no multi-user co-editing.
The existing draft-level autosave (variables, selected_parties, name, language) is independent and unchanged.
---
## §8 Building blocks
### §8.1 Library structure
One row per block in `submission_building_blocks`. Each block is bound to one `section_key` (`requests` blocks can't be inserted into `facts` sections, etc.). The block carries both DE and EN content from the start.
### §8.2 Authoring surface
New admin page `/admin/submission-blocks` (mirrors `/admin/email-templates` shape):
- Left pane: list of blocks scoped to user's reach + a "Neuer Baustein" button.
- Centre pane: editor with `slug`, `firm`, `section_key`, `proceeding_family`, titles, descriptions, the `content_md_de` + `content_md_en` textareas. Visibility radio. "Speichern" / "Speichern und veröffentlichen" buttons. Append-only versions list with restore.
- Right pane: live render preview using `SubmissionRenderer.RenderHTML` against a sample bag.
Non-admin users get `/blocks` (personal) — same UI but filtered to `visibility = 'private'` and their authored rows. Private → published bumps visibility tier; tier upgrade routes the block past an admin moderation step if going `firm` or `global`.
### §8.3 Copy-on-insert + lineage
Insert mechanics:
1. Render Markdown copy of block's `content_md_<active_lang>` into `submission_sections.content_md_<lang>` at the cursor offset.
2. Stamp `submission_sections.building_block_id = <block.id>` and `building_block_version = <block.version>`.
3. If the section already had a `building_block_id` pinned: do NOT override the lineage — instead, append the new block's content with a `\n\n---\n\n` separator and clear the lineage stamp (mixed origin).
Rationale: the lawyer's edits never get clobbered by library updates. Lineage stays so we can show "this was inserted from BB-Antrag-Standardfassung v3 — Library is now at v5, [refresh?]".
### §8.4 Refresh-from-library
A per-section button "Aus Bibliothek aktualisieren" (only shown when `building_block_id` is set AND the library version exceeds the snapshot version). Click flow:
1. Render a diff view (text-level) of "current section content" vs "library version N+M content_md_<lang>".
2. Lawyer picks: (a) keep current, (b) overwrite with library version, (c) merge — opens a side-by-side editor with conflict markers.
3. On overwrite/merge: bump `building_block_version` to current.
Defaults to "off" — no automatic propagation. The lawyer's call always.
### §8.5 Visibility tiers — concrete examples
- **Private** "MS-Klageerhebung-Variante" — Matthias's draft prose for his style; nobody else sees it.
- **Team** "TeamPatentLitMUC-Klageerwiderung-Lieblingsphrase" — the Munich patent team's preferred opener; team members see it.
- **Firm** "HLC-Standardantrag-§43" — the firm's vetted Anträge boilerplate; every HLC user sees it.
- **Global** "Universal-Beweisangebot-Auslandszustellung" — open library curated cross-firm; everyone sees it.
The moderation step (tier upgrade to `firm` or `global`) is a content-review responsibility — admin review required, audit-logged.
---
## §9 Render pipeline
### §9.1 High-level
```
1. Load draft + sections + base.
2. Fetch base.docx bytes from Gitea (cached per SHA, 5-min refresh).
3. Build variable bag via SubmissionVarsService (unchanged from v1).
4. Apply lawyer's variables overrides on top (unchanged).
5. For each section in order_index order, with included=true:
a. If kind ∈ {prose, requests, evidence}: render content_md_<lang> → OOXML
using base.section_spec.stylemap.
b. If kind = *_auto: pass through the base's anchor slot's inline OOXML.
6. Splice rendered OOXML into base body at matching {{#section:KEY}} … {{/section:KEY}} anchors.
7. Sections with included=false: drop the entire {{#section:KEY}} … {{/section:KEY}} slot.
8. Custom sections without anchors: render to base.stylemap.paragraph and append after the last anchored section, before {{#section:signature}}.
9. Pass the assembled XML through the v1 placeholder-substitution (single-run + cross-run merge). {{rule.X}} aliases resolved identically.
10. Strip leftover unfilled section anchors.
11. ConvertDotmToDocx pre-pass (existing behaviour; idempotent on .docx).
12. Stream as application/vnd.openxmlformats-officedocument.wordprocessingml.document.
```
Step 9 reuses the existing `internal/services/submission_merge.go` walker untouched. Steps 18 are new (`internal/services/submission_compose.go`, target ~400 LoC). Step 10 is a small regex post-pass.
### §9.2 MD → OOXML walker (Slice B, extended in Slice D)
Subset supported in v2.0:
| Markdown construct | OOXML output | Stylemap key |
|---|---|---|
| `# Heading` | `<w:p><w:pPr><w:pStyle w:val="<stylemap.heading_1>"/></w:pPr><w:r><w:t>…</w:t></w:r></w:p>` | `heading_1` |
| `## Heading` | same with `heading_2` | `heading_2` |
| `### Heading` | same with `heading_3` | `heading_3` |
| `paragraph` | `<w:p><w:pPr><w:pStyle w:val="<stylemap.paragraph>"/></w:pPr><w:r>…</w:r></w:p>` | `paragraph` |
| `**bold**` | `<w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>` (inline) | — |
| `*italic*` | `<w:r><w:rPr><w:i/></w:rPr><w:t>…</w:t></w:r>` (inline) | — |
| `- item` | `<w:p><w:pPr><w:pStyle w:val="<stylemap.list_bullet>"/><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr>…</w:p>` | `list_bullet` |
| `1. item` | same with `list_numbered`, `numId=2` | `list_numbered` |
| `> quote` | `<w:p><w:pPr><w:pStyle w:val="<stylemap.blockquote>"/></w:pPr>…</w:p>` | `blockquote` |
| `[label](url)` | `<w:hyperlink r:id="…">` (with rel registered in document.xml.rels) | — |
Not supported in v2.0 (defer to later slice or document as "edit in Word post-export"):
- Tables (Slice F-or-later; lawyers expect Word for tabular data anyway)
- Images (the base supplies letterhead images; section content stays text-only)
- Footnotes
- Cross-references (Word's REF fields)
- Code blocks (no legal use case)
Lists need the base's `numbering.xml` to declare numId=1 (bullet) and numId=2 (numbered). The `_skeleton.docx` already carries numbering definitions; existing bases need an audit (Slice B prereq).
### §9.3 Section anchor format
In base.docx body XML, anchors live as plain text inside a `<w:p>` paragraph:
```xml
<w:p><w:r><w:t>{{#section:requests}}</w:t></w:r></w:p>
<w:p><w:r><w:t>{{/section:requests}}</w:t></w:r></w:p>
```
Renderer:
1. Find both paragraphs (regex on `<w:p[^>]*>…{{#section:KEY}}…</w:p>` for the opening; ditto for closing).
2. Replace the entire range between them (inclusive of both anchor paragraphs) with the rendered section OOXML.
3. If section is `included=false`, drop the range entirely (no replacement).
4. If section is `*_auto`, the anchor range carries the inline OOXML for the canonical block — keep it, don't replace.
Anchors that aren't matched (leftover from a missing section in the draft): regex-strip to `<w:p></w:p>` (empty paragraph) at step 10.
### §9.4 Preserving existing v1 contracts
- **Placeholder grammar:** unchanged. Same `{{path.dot.notation}}` regex.
- **`{{rule.X}}` aliases:** resolved identically inside section content. The composer's MD → OOXML walker preserves them as literal `{{rule.X}}` text; substitution happens in step 9 against the canonical resolver.
- **Missing-marker behaviour:** unchanged — `[KEIN WERT: <key>]` / `[NO VALUE: <key>]`.
- **Variable bag construction:** unchanged. `SubmissionVarsService.Build` returns the same shape.
- **Audit:** one `system_audit_log` row + one `project_events` row per export. Metadata jsonb gains `composer: true` flag when `base_id IS NOT NULL`. event_type stays `submission.exported` / `submission_exported`.
- **`last_exported_at` / `last_exported_sha`:** still bumped on each export. SHA is the base's `gitea_path` SHA.
### §9.5 Performance
A 10-section draft with 500 words each → ~50kB of Markdown total → ~150kB of OOXML to splice. The base is typically 100200kB. Total assembly under 200ms on a warm cache. The Gitea fetch (cached) is the only meaningful network cost.
---
## §10 Authorization
### §10.1 Visibility (read)
- `submission_bases`: every authenticated user reads. Filter by `firm` + `proceeding_family` at the picker layer (not RLS — the base list is the same for everyone).
- `submission_sections`: gated through draft_id → `submission_drafts``can_see_project` + owner-scoped. RLS mirrors `submission_drafts`'s policies.
- `submission_building_blocks`: visibility tier predicate per row. Private/team/firm/global filter logic enforced in Go-layer service + DB RLS using the same `auth.uid()` helpers other tables use.
### §10.2 Mutations
- `submission_bases`: admin-only (`/admin/submission-bases` page; gated by the existing `adminGate`).
- `submission_sections`: owner-scoped on the draft (matches v1 `submission_drafts` rules).
- `submission_building_blocks`:
- Create + edit `private`: any authenticated user.
- Promote to `team`: any team member of the author's relevant team.
- Promote to `firm` or `global`: admin moderation step (audit-logged).
- Delete (soft, `deleted_at`): author or admin.
### §10.3 No new profession floor
Same call as v1: any user who can see the project can author Composer drafts. Substantive approval happens downstream of paliad (in Word, before filing). Adding a profession gate would slow the workflow without preventing anything paliad's existing approval system doesn't already cover.
---
## §11 Open design questions — recommended defaults + alternatives
m to ratify these via head escalation (no AskUserQuestion per task brief). The defaults are the inventor's pick; alternatives are listed so m can flip with one word.
### Q1 — Section content storage shape
**Default: separate `submission_sections` table** with one row per (draft, section).
Alternatives:
- (a) jsonb column on `submission_drafts.sections` — simpler migration, no joins, but blocks the "which drafts use building block X" queries we'll want for refresh-from-library.
- (b) Hybrid: jsonb by default, promote to table only when queries demand it — extra concept, schema drift over time.
Why default: building-block lineage queries (refresh, "where is this block used?", "is this block stale across N drafts?") become trivial with a relational table; jsonb makes them awkward joins on jsonb_array_elements. The cost of an extra table is small (one migration, RLS mirror).
### Q2 — Building block insert semantics
**Default: copy-on-insert with lineage retained.**
Alternatives:
- (a) Live reference — every render re-fetches the library version. Surprising for the lawyer working a sealed pleading.
- (b) Mixed — opt-in per block (the library row carries a `live_reference bool` column).
Why default: the lawyer's edits never get clobbered. The "library was updated, refresh?" affordance is opt-in via the lineage. Predictable; matches Word's "Paste as plain text" mental model.
### Q3 — Base storage
**Default: Gitea-backed body + thin DB row** (`submission_bases` row points at `gitea_path`).
Alternatives:
- (a) Full-DB-stored — admin authors base body directly in paliad. Eliminates Gitea round-trip but requires a server-side .docx editor (complex; not core competency).
- (b) Gitea-only — no DB row, base picked by directory scan. Loses the `is_default_for[]`, `section_spec`, RLS-able metadata.
Why default: lawyers + admins already author `.docx` in Word; mWorkRepo is the existing distribution vehicle. The DB row gives us listability, default-per-proceeding policy, and section spec storage without duplicating .docx content.
### Q4 — Editor surface
**Default: contentEditable per section with minimal toolbar (Markdown source-of-truth, in-house serializer).**
Alternatives:
- (a) Tiptap (ProseMirror-based) — ~80kB bundle add per editor; matches WYSIWYG expectations but pulls a React-ecosystem dep into paliad's no-React frontend.
- (b) Plain `<textarea>` with Markdown + side-by-side preview — simplest, but no inline bold/italic affordance.
- (c) ProseMirror direct — same as Tiptap but more code; not a meaningful step up.
Why default: keeps paliad's HTML-first ethos, no React/ProseMirror dep, lawyers can still see bold/italic visually, source-of-truth is portable. Slice D extends the toolbar; the editor architecture survives.
### Q5 — Render pipeline
**Default: section anchors in base.docx + in-house MD → OOXML walker (pure Go).**
Alternatives:
- (a) LibreOffice server-side compose — heavy dep (200MB+ runtime), licensing considerations, slower per-render.
- (b) Pandoc shellout — same shape, smaller dep but still binary requirement on the Dokploy image.
- (c) Pre-rendered base + section-injection via Office.js (browser-side) — pushes work to the client, requires Word desktop API, not portable.
Why default: pure-Go, no new runtime dep, integrates cleanly with the existing in-house renderer, performance budget under 200ms per export.
### Q6 — Migration of existing 11 drafts
**Default: implicit upgrade.** Existing rows get `base_id = NULL` (interpreted as "not yet upgraded"); the v1 render path stays as fallback. On the lawyer's next edit to a v1 draft, the editor prompts "Auf Composer umstellen?" and on yes seeds sections from the firm default base.
Alternatives:
- (a) Auto-upgrade all rows on migration apply — sections lazily parsed from existing variables jsonb. Risky: lawyer's mental model of "my draft" changes without consent.
- (b) Manual-only — no prompt; lawyer must explicitly create a new draft on the Composer to migrate. Slower adoption.
Why default: zero blast radius on existing rows; explicit consent on first edit. Lawyer can defer indefinitely.
### Q7 — Multi-language storage
**Default: split `content_md_de` + `content_md_en` columns from day one.**
Alternatives:
- (a) Single `content_md` column, relies on draft.language to pick the right author surface (lawyer maintains one language at a time, switching language re-authors).
- (b) Two-row pattern: one section row per language (4× rows, double the RLS).
Why default: same draft can render cleanly in either language with no re-authoring. Bilingual building blocks fold in naturally. Two columns are cheap.
### Q8 — Section default order per submission_code
**Default: code-driven (Go map in `submission_section_defaults.go`).**
Alternatives:
- (a) DB-driven (`proceeding_section_templates` table) — flexible (admin can edit defaults without code change) but adds infra (table, RLS, admin editor).
- (b) Per-base only (no per-code override) — simpler; loses the ability to differentiate Klageerwiderung vs. Statement of Defence section order.
Why default: section-default tweaks are rare and almost always require coordinated changes to the base's section_spec anyway. Keeping defaults in Go means one place to read; promote to DB if the override list grows past ~20 submission_codes.
### Q9 — Building block visibility tiers
**Default: 4 tiers — `private` / `team` / `firm` / `global`.**
Alternatives:
- (a) 2 tiers — `private` / `firm`. Simpler RLS, loses the team-level curation that paliad already uses elsewhere.
- (b) 3 tiers — drop `global` until cross-firm exists in practice. Hedges complexity.
Why default: matches paliad's existing team-visibility model. The moderation step gates the `firm` and `global` promotions; tier upgrade is an admin-reviewable event.
### Q10 — `*_auto` section semantics
**Default: lawyer toggles include/exclude, content rendered from base + bag; no edit.**
Alternatives:
- (a) `*_auto` is editable (lawyer can override the auto-rendered Rubrum text). Risk: lawyer types content that conflicts with the variable bag.
- (b) `*_auto` is fixed (lawyer can't even hide). Less flexible; "draft without signature" use cases break.
Why default: keeps the auto-rendered blocks consistent across drafts in the firm (no per-draft Rubrum drift) while letting the lawyer drop them when they don't apply (e.g., a "Schutzschrift" might omit the Rubrum block).
### Q11 — Preview pane
**Default: collapsed by default in v2 (in-place editor IS the surface).** Toggle `[Vorschau ▾]` opens a read-only HTML render below the section stack.
Alternatives:
- (a) Side-by-side preview (always visible) — matches v1's layout, takes more screen real estate.
- (b) No preview — risky; lawyers want to see the final render before exporting.
Why default: with editable sections, the editor surface IS the preview for prose. The toggle covers the "let me check the final layout" need; rare enough to not warrant permanent screen estate.
### Q12 — Refresh-from-library trigger
**Default: explicit per-section button, only visible when library version > snapshot.**
Alternatives:
- (a) Auto-prompt on draft open ("4 building blocks have updates; refresh?") — annoying for active drafts.
- (b) No surfacing — lineage exists only in metadata; lawyer must manually check.
Why default: opt-in keeps lawyer in control; the surfaced affordance triggers only when there's a real update.
---
## §12 Slice plan
Each slice independently shippable. Coder picks up the next slice only after the previous one ships + lawyer validation.
### Slice A — Base picker (read-only) and section list shell
**Scope:**
- Migrations: `submission_bases` table, seed `hlc-letterhead` + `neutral` rows pointing at existing `.docx` files; `submission_sections` table; `submission_drafts.base_id` + `composer_meta` columns.
- Backend: `BaseService` (list, get-by-slug, get-default-for-code). `SubmissionDraftService.Create` seeds `base_id` + section rows.
- Frontend: sidebar picker "Vorlagenbasis" with the seeded bases. Section list rendered read-only above the existing preview (no edit yet).
- Render: unchanged — still uses v1 `resolveSubmissionTemplate` chain. `base_id` is informational.
- **Ships:** lawyer sees the base concept land, no behavior change.
**Acceptance:**
- Creating a new draft seeds `base_id` from firm + family default.
- Sidebar picker renders ≥2 bases; selecting one PATCHes `base_id`.
- Section list renders the base's default section set (read-only labels in active language).
- All 11 existing drafts continue to render via v1 path (their `base_id` is NULL).
- Unit tests cover: base default picker, section-list payload, migration round-trip.
### Slice B — Editable prose sections + composer render pipeline
**Scope:**
- Backend: `SubmissionSectionService` (CRUD on section rows). New endpoints: `GET/POST/PATCH/DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}/sections/{section_id}`. `SubmissionComposer` (render pipeline §9.1 + MD → OOXML walker §9.2, paragraphs + bold/italic + headings 13 + bulleted list only).
- Frontend: section stack on the body, contentEditable per section, shared toolbar (B/I/H1/H2/bullet), autosave per section. The existing variable sidebar stays.
- Render: when `base_id IS NOT NULL`, use the composer; else fall back to v1 path. Audit metadata gets `composer: true`.
- Base `.docx` audit: regenerate `_firm-skeleton.docx` and `_skeleton.docx` to add `{{#section:KEY}}` anchors for the default section set. Done via extended `scripts/gen-submission-base/main.go`.
- **Ships:** the smallest milestone where Composer actually works. Lawyer can author prose, see the rendered .docx.
**Acceptance:**
- Lawyer can edit any `prose`/`requests`/`evidence`-kind section.
- Toolbar applies bold, italic, headings 13, bullet list.
- Export renders all sections at their correct anchors with bag substitution.
- `{{rule.X}}` aliases still resolve inside section content.
- `*_auto` sections render as before (from base + bag).
- v1 drafts (base_id=NULL) render via v1 path unchanged.
- Golden tests on a sample draft → expected OOXML output.
### Slice C — Building blocks library + section picker
**Scope:**
- Migrations: `submission_building_blocks` + `submission_building_block_versions` tables.
- Backend: `BuildingBlockService` (CRUD + visibility filtering + version GC retention=20). Endpoints: `/api/blocks` (personal list), `/api/admin/submission-blocks` (admin CRUD), `/api/sections/{id}/insert-block` (the actual insert mechanic).
- Frontend: `/admin/submission-blocks` editor (full-page list + edit + version log + restore, mirrors `/admin/email-templates` shape). Per-section `+ Baustein einfügen` modal with filter chips.
- **Ships:** reusable building blocks.
**Acceptance:**
- Admin creates a block at firm visibility → visible to every HLC user in the picker.
- Lawyer inserts a block into a `requests` section → block content appended at cursor; `building_block_id` + version stamped.
- Block update → existing drafts unaffected; "Aus Bibliothek aktualisieren" button surfaces on the section.
- Visibility tiers enforced (private not visible to other users; team requires team membership; firm requires firm match; global open).
### Slice D — Rich-prose features (lists, numbered, blockquote, links, stylemap)
**Scope:**
- MD → OOXML walker: add numbered lists, blockquote, hyperlinks.
- Base `.docx` audit: ensure `numbering.xml` carries numId=1 (bullet) and numId=2 (numbered) definitions; backfill any base that lacks them.
- Each base's `section_spec.stylemap` gets all 8 entries populated.
- Frontend toolbar gains `[1.]` (numbered) and `[❝]` (quote).
- **Ships:** full prose feature set.
### Slice E — Specialist base templates (LG-Düsseldorf, UPC-formal) + base swap survives content
**Scope:**
- New `.docx` bases authored in Word: `lg-duesseldorf.docx`, `upc-formal.docx`. Each carries proceeding-family-specific letterhead, paragraph styles, default section spec.
- Verification: base swap mid-draft preserves section content; only chrome/styles change.
- Generator script (`scripts/gen-submission-base/main.go`) supports the new bases.
- **Ships:** lawyer can swap firm letterhead → court-style mid-draft.
### Slice F — Section reorder / hide / add custom
**Scope:**
- Frontend: drag-and-drop on the section list (small custom drag library or HTMX-style; no React DnD).
- "+ Abschnitt hinzufügen" picker (existing slugs + new slug form).
- `composer_meta.section_order` mirrors `order_index` for fast paint.
- Per-section "Nicht aufnehmen" toggle.
- **Ships:** full lawyer control over composition.
### Slice G — Diff & merge for building-block refresh
**Scope:**
- "Aus Bibliothek aktualisieren" button surfaces when section's snapshot_version < library_version.
- Diff view (text-level, library-side library version vs section content).
- Three-way: keep / overwrite / merge (side-by-side conflict markers).
- **Ships:** lawyer can actively curate library updates per draft.
---
## §13 Out of scope (explicit)
To set the boundary cleanly:
- **Real-time collaborative editing** no Operational Transform / CRDT. One draft, one author, last-write-wins on owner-scoped writes. Two lawyers wanting to co-edit start two drafts.
- **Comment threads on sections** lawyer-level review happens in Word post-export.
- **Deep version history** beyond `updated_at` on the draft + retention-20 on building-block library. Per-section version history is a Slice H+ idea.
- **AI-drafted prose** the existing Paliadin persona handles ad-hoc Q&A; AI-drafted section content (a `{{ai.draft_facts}}` placeholder, say) is a future task with its own design doc. v1's §11 sketch still applies.
- **Cross-draft section sharing** use building blocks. We won't add a "this section is a live reference to that section in another draft" concept.
- **Tables, images, footnotes, cross-references** author in Word after export. The Composer is for structural composition + first-draft prose.
- **PDF output** `.docx` only. Lawyers convert to PDF in Word.
- **Email or e-filing integration** explicit Word-then-NetDocuments-then-court flow is the current contract; not in scope.
- **Translation between DE and EN** both columns exist on every section + block, but content authoring is manual. No machine translation pass.
A handful of issue-#141 items that DON'T survive into v2 by inventor judgement:
- "Live FK to building block (no copy)" replaced by copy-on-insert + lineage (Q2).
- "Tiptap / ProseMirror" replaced by contentEditable + in-house serializer (Q4).
- The implied bigger ProseMirror push back into "rich editor" addressed with simpler approach.
Issue text also mentioned `submission_drafts.audit_log` this column doesn't exist; the audit contract uses `system_audit_log` + `project_events`. The design respects the **actual** audit contract; if m wants a new column added, that's a separate decision tracked here.
---
## §14 Risks + mitigations
1. **MD → OOXML walker fidelity.** Tiny rendering bugs (numbering, indentation, bullet styles) annoy lawyers who expect Word-perfect output.
- **Mitigation:** stylemap per base; golden-test set against HL Patents Style for every Markdown construct. Slice D's rich features only after Slice B's prose-only path proves stable. Lawyer can always fix layout in Word post-export.
2. **Base swap blast radius.** If section content depends inadvertently on base styles (e.g. lawyer pastes Word content with inline formatting), swap can look broken.
- **Mitigation:** Markdown source-of-truth is base-agnostic by construction. The contentEditable MD serializer strips inline styles. Pasted Word content goes through a paste-normalizer (drops `<style>`, `<font>`, color attributes). Bullet/numbered styles resolved per-base at render.
3. **Building block proliferation.** Without curation, every lawyer creates their own variant of the same boilerplate.
- **Mitigation:** visibility tiers (private team firm global); admin moderation gate on firm + global promotions; "duplicate-detector" check at create time (slug + content_md hash) suggests existing similar blocks.
4. **Section anchor robustness.** If a base.docx is malformed (missing closing anchor, anchor inside a table cell), the renderer breaks.
- **Mitigation:** base validation at upload (the `submission_bases` admin editor refuses bases with unmatched anchors); regex-based stripper at step 10 silently drops unclosed openings as empty paragraphs.
5. **{{rule.X}} alias forward-compat.** Section content authored today may use either canonical or legacy forms. If we ever drop the aliases, sections break silently.
- **Mitigation:** the alias contract is pinned (memory `01cdd589`, knuth's t-paliad-275 ship notes). Composer's editor surfaces the canonical name in the autocomplete picker so new content tends canonical; the renderer resolves both forms identically.
6. **Bundle size.** contentEditable + toolbar adds ~10kB; section stack of 810 instances ~40kB extra over v1. Acceptable.
- **Mitigation:** one shared toolbar instance hops between sections (single DOM tree, reactivated on focus). MD DOM serializer is ~3kB shared across all sections.
7. **Performance with 100+ building blocks per firm.** Picker render time grows.
- **Mitigation:** server-side filter + paginated list (limit 25 most-recent + free-text search input). Same shape as the party DB picker in t-paliad-287.
8. **Existing drafts orphaned.** If a lawyer leaves a v1 draft un-migrated and later edits it, the "upgrade" prompt might confuse them.
- **Mitigation:** prompt is non-destructive (default NO, "Composer is opt-in for this draft"). v1 path keeps working indefinitely.
9. **Section spec versioning.** If we tweak the default section set, in-flight drafts created before the change have a stale `composer_meta.section_order`.
- **Mitigation:** `section_spec.version` field. Compose service uses the lawyer's `composer_meta.section_order` if set (always); else seeds from current base spec. Spec bumps only affect new drafts.
10. **Coupling between base and stylemap.** A base swap could fail if the new base doesn't declare every stylemap key the old base did.
- **Mitigation:** required keys list enforced at base-upload-time validation. Specialist bases must declare paragraph + heading_1/2/3 + list_bullet + list_numbered + blockquote.
---
## §15 Open follow-ups (NOT blocking m's go/no-go)
These are coder/inventor follow-ups, not m-decisions:
- **Issue #141 body correction** flag the inaccurate `submission_drafts.audit_log` reference + "7 live rows" count. Update issue text post-design-approval.
- **`scripts/gen-submission-base/main.go`** extension of t-paliad-275's HL skeleton generator to embed `{{#section:KEY}}` anchors and a section_spec custom XML part. Builds on existing pattern.
- **Stylemap audit on `_firm-skeleton.docx`** verify every required style name exists (HLpat-Heading-H1/H2/H3, HLpat-Body-B0, HLpat-Body-B1). Pre-Slice-B work.
- **Numbering.xml audit on `_skeleton.docx` + `_firm-skeleton.docx`** verify numId=1 (bullet) and numId=2 (numbered) declared. Pre-Slice-D work.
- **i18n keys** ~40 new keys for Slice A (sidebar picker, base label, "Vorlagenbasis", "Basis wechseln", section toolbar). ~20 more for Slice C (building-block library labels). DE + EN.
- **CLAUDE.md updates** add a "Composer" section documenting the base concept, section spec, MD-source-of-truth contract, the `composer: true` audit metadata flag.
- **mBrian topic node** file the synthesis post-approval; topic hub probably `submission-composer` under `paliad`.
- **Storyboard / Loom walk-through** Slice B ships record a 90-second screen-cap of the lawyer experience for the HLC validation round.
---
## §16 Recommended implementer
Pattern-fluent Sonnet coder. The substrate is well-trodden:
- Existing draft service: `internal/services/submission_draft_service.go` extend, don't replace.
- Existing renderer: `internal/services/submission_merge.go` reuse for the placeholder-substitution pass; add `internal/services/submission_compose.go` for the assembly + MD walker.
- Existing template fetch: `internal/handlers/files.go` Gitea proxy reused for base.docx and (if Slice C+ adds Markdown blocks stored externally) building-block content.
- Existing audit shape: `paliad.system_audit_log` + `paliad.project_events` extend `metadata jsonb` with `composer: true`; no schema change there.
- Existing admin pattern: `/admin/email-templates` for the building-blocks admin editor; mirror exactly.
- Existing visibility helpers: `internal/services/visibility.go` + team-membership predicates for tier filtering.
Novel pieces (all isolated):
- MD OOXML walker (~350 LoC including tests).
- Section anchor parser + splicer (~150 LoC).
- contentEditable Markdown serializer (~250 LoC TS).
- Composer admin editor (`/admin/submission-bases`, `/admin/submission-blocks`).
NOT cronus (per project memory directive).
---
## §17 Acceptance for the design (this doc)
1. All four headline requirements (sections, bases, editability, building blocks) have concrete data-model + UX + render-pipeline proposals.
2. Every open design question (12 of them) has a recommended default + at least one alternative.
3. Slice plan present (A G), each independently shippable; Slice B is the smallest "Composer works" milestone.
4. Migration path documented: existing 11 drafts continue via v1 path; opt-in upgrade per draft.
5. Hard constraints honoured: `submission_drafts` shape preserved, `{{rule.X}}` aliases preserved, audit contract preserved, no new third-party Go dep.
6. Premises verified live against the running paliad + DB; doc-vs-live conflicts flagged in §1 and §13.
7. Risks identified with mitigations.
8. Inventor reports `DESIGN READY FOR REVIEW`. **Inventor MUST NOT shift to coder.** Head gates the inventorcoder transition.
---
## §18 What m needs to ratify before coder shift
Per task brief: no AskUserQuestion. Escalation via `mai instruct head` for the 12 §11 questions, batched. Default picks are listed inline; m can flip any to an alternative with one word.
Head's job after this doc lands:
1. Surface §11's Q1Q12 to m with the recommended defaults.
2. Capture m's ratifications (or alternative picks) as an addendum at the top of this doc m's decisions, 2026-MM-DD) same shape as t-paliad-215's §2.
3. Decide who codes Slice A: same worker as `/mai-coder` with this design + addendum as brief, fresh coder, or rescope.
4. Park Slice BG until Slice A ships + lawyer validation lands.
The decisions section gets committed on the same branch as this design (no separate branch).
Inventor parks here.