Files
paliad/docs/design-submission-generator-v2-2026-05-26.md
mAi 0e1691f00e
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
docs: ratify Q1-Q12 — submission generator v2 design final (m/paliad#141)
All 12 §11 design questions ratified by m on 2026-05-26 via
AskUserQuestion (paliadin-authorised override per instruction msg #2391).

Picks matching inventor recommendations (9 of 12):
 Q1 separate submission_sections table
 Q3 Gitea-backed body + thin DB row
 Q4 contentEditable + Markdown + in-house serializer
 Q5 section anchors + in-house MD->OOXML walker
 Q7 split content_md_de + content_md_en from day 1
 Q8 Go map for per-submission_code section defaults
 Q9 4 visibility tiers (private/team/firm/global)
 Q11 collapsed preview pane by default
 Q12 moot (superseded by Q2 simplification)

Deviations from recommendation (3 of 12):
 Q2 SIMPLIFY further — m: "sounds overengineered". Building blocks
    become plain text paste sources. No building_block_id column on
    sections, no _versions table referenced from sections, no
    refresh-from-library affordance. Slice G dropped.
 Q6 Auto-upgrade all 11 existing drafts at mig-148 apply time (not
    opt-in per draft). v1 fallback render path stays compiled in.
 Q10 *_auto kind removed. Caption/letterhead/signature sections are
    regular prose rows seeded with bag-driven Markdown; lawyer can
    edit/hide. Untouched drafts export identically to v1.

Body sections updated inline (§4.3 schema, §4.4 BB tables, §6.3
seeding, §8.3+8.4 BB insert, slice plan A/C/G, §11 ratification notes,
§14 risks #8+11, §17+18 acceptance + gate). §11 retains the historical
recommendation matrix.

Status: ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for
Slice A coder shift. Inventor parks per hard gate. Head decides hire.

t-paliad-312
2026-05-26 19:04:21 +02:00

77 KiB
Raw Permalink Blame History

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: ALL DESIGN QUESTIONS RATIFIED — final, ready for Slice A coder shift 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.


§ m's decisions (2026-05-26)

m ratified the 12 §11 questions via AskUserQuestion (paliadin override per instruction msg #2391 — "interactive ratification, no doc round-trip"). Picks below; where m deviated from the inventor's recommendation, the design body has been updated inline. The original §11 entries stay as the historical record.

# Topic m's pick vs Recommended?
Q1 Section content storage Separate submission_sections table matches
Q2 Building block insert semantics SIMPLIFY — m: "I don't even understand the question, that sounds a bit overengineered" → drop lineage/refresh machinery. Building blocks are plain text snippets pasted into sections. No building_block_id, no _versions table, no diff/merge UI. ⚠️ rescope
Q3 Base storage Gitea-backed body + thin DB row matches
Q4 Editor surface contentEditable + minimal toolbar + Markdown source-of-truth, in-house serializer matches
Q5 Render pipeline Section anchors in base + in-house MD → OOXML walker (pure Go) matches
Q6 Migration of existing 11 drafts Auto-upgrade all on migration apply — every draft auto-seeded with firm-default base + sections derived from spec. v1 fallback path stays compiled in for safety. ⚠️ deviation — risk noted in §14
Q7 Multi-language storage Split content_md_de + content_md_en from day 1 matches
Q8 Section defaults per submission_code Go map (submission_section_defaults.go) matches
Q9 Building block visibility tiers 4 tiers (private / team / firm / global) matches
Q10 *_auto section editability Fully editable — lawyer can override auto-rendered Rubrum / letterhead / signature text. No special read-only kind. ⚠️ deviation — risk noted in §14
Q11 Preview pane default Collapsed by default, [Vorschau ▾] toggle expands matches
Q12 Refresh-from-library trigger Moot — superseded by Q2's simplification (no lineage tracked, so no "refresh" affordance exists).

Rescope: Q2 + Q12 simplification (combined effect)

The Q2 + Q12 picks collapse the building-block machinery to its simplest form:

  • No building_block_id / building_block_version columns on submission_sections.
  • No submission_building_block_versions table.
  • No "refresh from library" button, no diff view, no Slice G.
  • Building blocks become a one-way clipboard: the lawyer browses the library, clicks "Einfügen", the text appears at the cursor, end of story. The section row keeps no record of where the text came from.
  • The library admin editor (Slice C) still has its own version history (retention-20, like email templates) for audit + accidental-delete recovery — that's an internal admin concern, not a per-section concept.

Rescope: Q6 auto-upgrade

The migration applying submission_sections + submission_drafts.base_id auto-seeds every existing row:

  • base_id set to the firm default base for the draft's proceeding family (matched at migration time via submission_codes_shape / proceeding lookup).
  • submission_sections rows seeded from base.section_spec.defaults per draft, with empty content_md_de/content_md_en for prose/requests/evidence kinds.
  • composer_meta populated with the default section_order so the editor paints immediately on next open.
  • v1 fallback render path stays compiled in (gate: base_id IS NULL OR no sections rows) as a safety net should the auto-seed miss a draft.

Risk: the lawyer's next visit to a previously-edited draft shows the new Composer UI. The lawyer's existing variables jsonb overrides survive untouched; section content starts empty. Lawyer experiences this as "my variable fills are still here, plus a new section editor I can ignore until I want to use it". Acceptable per m. Documented in §14 risk #8.

Rescope: Q10 fully-editable *_auto sections

*_auto kinds disappear from the design. All sections become prose (default seeded from base + bag where the base supplies canonical OOXML), requests, or evidence. The base.docx still ships canonical Rubrum / letterhead / signature OOXML inside each section's anchor — but on first edit the lawyer's content_md replaces it.

UX implication: on draft open, "Rubrum" / "Briefkopf" / "Unterschrift" sections show the auto-rendered content as initial text inside the editor. The lawyer can leave it alone (renders identically to old *_auto behaviour at export), edit it (their MD replaces the base's OOXML at render), or hide it (included=false).

Risk: the lawyer types a party name into the Rubrum manually that doesn't match the variable bag's {{parties.claimant.0.name}}. The exported .docx shows the typed name — variable substitution is a no-op against literal text. Acceptable: the lawyer is in control, this is a feature not a bug. Documented in §14 risk #11.

Status

Design final. Inventor parks per hard gate. Head decides Slice A coder shift.


§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_codeskeleton_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

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

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'   (per Q10: no *_auto kind)
    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. Seeded with base's canonical OOXML rendering for caption/letterhead/signature; editable.
    content_md_en      text NOT NULL DEFAULT '',
    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 prose, anträge, and evidence-style content for the editor's affordances (e.g. Anträge sections may auto-number; evidence sections show a "Beweis: " inline prefix). Per Q10, there is no read-only *_auto kind — every section is lawyer-editable, but caption/letterhead/signature sections are seeded with the base's canonical OOXML rendering so leaving them untouched produces the same output v1 lawyers see today.

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.

Per Q2: there is no building_block_id lineage. Building blocks are plain text snippets; once inserted, the snippet's prose belongs to the section row, full stop.

§4.4 New — submission_building_blocks

Per Q2: building blocks are plain text snippets. No _versions companion table exposed to the section side; admin-side version history lives in a private _admin_versions table for accidental-delete recovery only (mirrors the email-templates retention=20 pattern).

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,
    created_at         timestamptz NOT NULL DEFAULT now(),
    updated_at         timestamptz NOT NULL DEFAULT now(),
    deleted_at         timestamptz,

    UNIQUE (slug, firm)
);

-- Admin-side audit only; not referenced from submission_sections.
CREATE TABLE paliad.submission_building_block_admin_versions (
    id                 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    building_block_id  uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
    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()
);

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 admin Save appends a _admin_versions row + GCs to 20 in-transaction. This is an admin-side concern — sections never reference it. The lawyer's section row is unaware which version of which library block its content originated from.

§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 CREATE TABLE submission_bases + RLS + seed rows for hlc-letterhead, neutral. (Specialist bases land in Slice E migrations.)
147 ALTER TABLE submission_drafts ADD COLUMN base_id uuid REFERENCES submission_bases(id); ADD COLUMN composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
148 CREATE TABLE submission_sections + RLS + indexes. Auto-seed all 11 existing drafts (per Q6) — for each draft: set base_id from firm default for the proceeding family; INSERT one submission_sections row per default in the base's section_spec.defaults, copying seed_md_de / seed_md_en into content_md_de / content_md_en; populate composer_meta.section_order. Idempotent (ON CONFLICT DO NOTHING).
149 CREATE TABLE submission_building_blocks + submission_building_block_admin_versions + RLS + indexes (per Q2, no lineage column on submission_sections).

Slot numbers are flexible; the coder picks contiguous free slots at impl time. The auto-seed (mig 148) is the migration-time write that Q6 ratified — every existing draft gains its Composer surface at apply.


§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:

{
  "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):

// 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 Caption / Letterhead / Signature — seeded but editable (per Q10)

The "auto" concept is gone. Caption, letterhead, and signature sections are regular prose-kind rows. Their distinguishing trait is the seed: on draft create, their content_md_* is pre-filled with a Markdown rendering of the canonical bag-driven content (so a draft that's untouched after create exports identically to v1's auto-rendered Rubrum / letterhead / signature).

  • Letterhead — typically a .docx header part (HL Patents Style header1.xml) is the actual letterhead. The in-body letterhead section seeds with a "Schriftsatz von:" preamble using {{firm.name}}, {{user.display_name}}. Lawyer can edit, hide, or rename freely.
  • Caption (Rubrum) — seeds with paragraphs using {{parties.claimant.0.name}}, {{parties.defendant.0.name}}, {{project.case_number}}, {{project.court}}. Lawyer can edit the prose around the placeholders (e.g. add an intervener block) or override entirely.
  • Signature — seeds with {{firm.signature_block}} + {{user.display_name}}.

When included=false, the section is dropped at render time. When included=true and the lawyer hasn't touched the content, the seeded Markdown renders identically to v1's auto-block — same placeholders resolve, same OOXML output. When the lawyer edits, their Markdown wins.

Seeding mechanism: the base's section_spec.defaults declares a seed_md_de / seed_md_en per section. On draft create, the section row's content_md_* is initialised from the seed. Subsequent base swaps do NOT re-seed (the lawyer may have edited the content already; re-seeding would clobber).

§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                   [+ Baustein] [⋮]  │    │
│  │  (seeded — bearbeitbar)                          │    │
│  └──────────────────────────────────────────────────┘    │
│  ┌──────────────────────────────────────────────────┐    │
│  │ 2. Rubrum                      [+ Baustein] [⋮]  │    │
│  │  (seeded — bearbeitbar)                          │    │
│  └──────────────────────────────────────────────────┘    │
│  ┌──────────────────────────────────────────────────┐    │
│  │ 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 at the cursor with a \n\n separator. The lawyer can drag/edit afterwards.

No lineage badge, no "refresh from library" surface — see §8.4. The picker is a paste source, nothing more.

§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 Insert mechanics — plain text paste (per Q2)

Insert is dumb-paste: clone the block's content_md_<active_lang> into the section row's content_md_<lang> at the cursor offset. End of story. No building_block_id stamp, no version snapshot, no lineage tracking.

Rationale (m's call): the building-block-as-text-snippet mental model maps to how lawyers already think about boilerplate. They don't think about "this passage is a live reference to a library entry" — they think "I pasted this in, I might edit it". The simpler design honours that intuition.

Consequence: library edits do NOT propagate to in-flight drafts (by construction — no link exists). Lawyer who wants the latest version re-opens the picker and inserts again. That's the trade.

§8.4 No refresh-from-library affordance (per Q12, superseded by Q2)

There is no "Aus Bibliothek aktualisieren" button. There is no diff view. The lawyer's content is the lawyer's content; the library is a paste source. If the lawyer wants to re-sync, they delete the section text and re-insert from the library.

Library admins still get the _admin_versions audit trail for accidental-delete recovery on the library side (§4.4) — that's an internal admin concern, not a per-section UX.

§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:

<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_draftscan_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.


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.

m's ratification (2026-05-26): "I don't even understand the question, that sounds a bit overengineered." → SIMPLIFY further than the recommendation. No building_block_id column on sections at all. Building blocks are plain text snippets pasted into sections; once pasted, the prose belongs to the section, no link back to the library. This dissolves Q12 (no refresh-from-library) and Slice G (no diff/merge UI).

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.

m's ratification (2026-05-26): picked (a) Auto-upgrade. The migration walks every existing draft in-transaction, sets base_id from the firm default for its proceeding family, and writes seeded section rows. v1 fallback path stays compiled in as a safety net. Risk noted in §14 #8.

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).

m's ratification (2026-05-26): picked (a) Fully editable. The *_auto kind is removed. Caption, letterhead, and signature sections are regular prose rows seeded with bag-driven Markdown (so an untouched draft exports identically to v1). Lawyer edit replaces the seed at render time. Risk noted in §14 #11.

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.

m's ratification (2026-05-26): moot — Q2's simplification removed the lineage tracking this question presupposed. No "refresh from library" affordance exists. If the lawyer wants the latest version of a block, they delete the section text and re-insert from the picker.


§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. Per Q6 auto-upgrade: the section-seed mig (148) walks every existing draft and writes its base_id + section rows in-transaction.
  • Backend: BaseService (list, get-by-slug, get-default-for-code). SubmissionDraftService.Create seeds base_id + section rows. SubmissionDraftService.Get returns sections in composer_meta.section_order.
  • Frontend: sidebar picker "Vorlagenbasis" with the seeded bases. Section list rendered read-only above the existing preview (no edit yet) — every draft shows the section stack from mig-148 day one.
  • Render: unchanged — still uses v1 resolveSubmissionTemplate chain. base_id is informational this slice; v1 path stays compiled in as the safety net (gate: base_id IS NULL OR no sections rows).
  • Ships: lawyer sees the base concept land, no behavior change at export.

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 now carry sections rows + non-NULL base_id after mig 148; export still produces v1-equivalent output (sections rendered with their seeded Markdown via the seeded base, identical to v1's auto-Rubrum/letterhead/signature).
  • Unit tests cover: base default picker, section-list payload, mig-148 auto-seed idempotency, v1-equivalence after seed.

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_admin_versions tables.
  • Backend: BuildingBlockService (CRUD + visibility filtering + admin-side retention=20). Endpoints: /api/blocks (personal list, search), /api/admin/submission-blocks (admin CRUD), /api/sections/{id}/insert-block (the actual insert mechanic — copy text into section, no lineage stamp).
  • Frontend: /admin/submission-blocks editor (full-page list + edit + admin version log + restore, mirrors /admin/email-templates shape). Per-section + Baustein einfügen modal with filter chips (section_key, proceeding_family, visibility tier reach).
  • Ships: reusable building blocks as paste sources.

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; section row has NO building_block_id reference (per Q2).
  • Library edits to the block do NOT propagate to in-flight drafts (by design — no link).
  • Visibility tiers enforced (private not visible to other users; team requires team membership; firm requires firm match; global open).

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 — Removed per Q2 / Q12 simplification

The original Slice G ("diff & merge for building-block refresh") is dropped. m's ratification of Q2 dissolved the lineage machinery the slice depended on. There is no "refresh from library" affordance — see §8.4.


§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. Auto-upgrade surprise (Q6). The lawyer's next visit to a previously-edited draft shows the new Composer UI (section stack instead of the variable-only sidebar). Could be disorienting on first encounter.

    • Mitigation: existing variables jsonb overrides survive untouched; section content seeded with bag-driven Markdown so export produces v1-equivalent output until the lawyer edits a section. v1 fallback render path stays compiled in (gate: base_id IS NULL OR no sections rows) as a safety net. A one-time "Wir haben Ihre Entwürfe ins Composer-Format übernommen" Hinweis banner on first post-deploy visit explains the change.
  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.
  11. Lawyer-typed content conflicts with variable bag (Q10). With fully-editable caption/letterhead/signature sections, the lawyer might type a party name into the Rubrum that doesn't match {{parties.claimant.0.name}}. Variable substitution is a no-op on literal text — the exported .docx shows the typed name. If the project's party list later changes, the draft's Rubrum is stale.

    • Mitigation: the seed Markdown uses placeholders ({{parties.claimant.0.name}}), so an untouched section auto-tracks the bag. The lawyer's edit is opt-in: typing a literal name is an explicit choice. A small inline hint on the caption section ("Tipp: {{parties.claimant.0.name}} bleibt mit dem Projekt synchron") nudges placeholder usage. No automated detection of literal-vs-placeholder drift — out of scope.

§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.

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. All 12 open design questions ratified by m on 2026-05-26 — see § m's decisions at the top of this doc. Body sections updated inline; §11 retains the historical recommendation matrix.
  3. Slice plan present (A → F, with G dropped per Q2 simplification). Slice B is the smallest "Composer works" milestone.
  4. Migration path documented: auto-upgrade all 11 existing drafts at mig-148 apply time per Q6. v1 fallback render path stays compiled in.
  5. Hard constraints honoured: submission_drafts shape preserved (additive columns only), {{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 (11 risks, including Q6 + Q10 deviations).
  8. Inventor reports ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for Slice A coder shift. Inventor MUST NOT shift to coder. Head gates the inventor→coder transition.

§18 Coder gate

All 12 design questions ratified by m on 2026-05-26 via AskUserQuestion (paliadin-authorised override of the task brief's no-AskUserQuestion rule, per instruction msg #2391). The § m's decisions section at the top of this doc captures every pick; body sections + Slice plan updated inline.

Head's job:

  1. Decide who codes Slice A: same worker as /mai-coder with this design as brief, fresh coder, or rescope.
  2. Park Slice BF until Slice A ships + lawyer validation lands.
  3. Slice G was removed per Q2 — don't spawn a successor for it.

Inventor parks here.