Files
paliad/docs/design-submission-page-2026-05-22.md
mAi f7374a67cd docs(submissions): t-paliad-238 design — dedicated Submissions/Schriftsätze page
Adds docs/design-submission-page-2026-05-22.md (~600 lines) — a
dedicated submission-draft page at /projects/{id}/submissions/{code}/draft
with sidebar variable editor + read-only HTML preview + .docx export.

Reuses (resurrects from git) the deleted Slice 1 backend that t-paliad-230
ripped out — SubmissionVarsService (3677c81 + 1765d5e), in-house
SubmissionRenderer (8ea3509), TemplateRegistry (3677c81). All four
compile against today's services with zero API drift.

New schema: paliad.submission_drafts keyed on
(project_id, submission_code, user_id, name) with RLS via can_see_project.

Three slices: A = schema + page + variables-only export against universal
.docx; B = per-submission_code templates with fallback-chain registry;
C = toggleable passages.

Four material picks escalated to head in §11 (template authoring effort,
paliad.documents row, server-vs-client preview, inter-user draft
visibility). All other open questions defaulted to inventor (R)
recommendations from task brief.

No code. Read-only design phase per inventor → coder gate.
2026-05-22 23:43:51 +02:00

55 KiB

Design — Dedicated Submission/Schriftsätze page (t-paliad-238)

Author: cronus (inventor) Date: 2026-05-22 Issue: m/paliad (mai task t-paliad-238) Branch: mai/cronus/inventor-dedicated Status: DESIGN READY FOR REVIEW Prior art: docs/design-submission-generator-2026-05-19.md (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.


§0 TL;DR

Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) .docx of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.

This design adds a dedicated submission page at /projects/{id}/submissions/{code}/draft where the lawyer:

  1. Picks (or creates) a named draft for one (project, submission_code).
  2. Sees a sidebar with every {{placeholder}} the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
  3. Sees a read-only preview pane showing the merged document body as HTML.
  4. Clicks Export → .docx to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.

Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as paliad.submission_drafts rows so the lawyer can come back next week, multiple drafts per submission code, RLS through paliad.can_see_project.

Reuses the deleted Slice 1 backend (SubmissionVarsService from commit 3677c81, in-house SubmissionRenderer from 8ea3509, patent_number_upc helper from 1765d5e) wholesale — those files are salvageable from git history and slot back in with one new service (SubmissionDraftService) and the new schema. Reuses the internal/handlers/files.go Gitea proxy pattern for per-submission_code templates in m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).

Three slices: A = schema + new page + variables-only export against the universal .dotm (one slice; ships the editor end-to-end); B = per-submission_code .docx templates with the fallback-chain registry (template authoring is the bottleneck, not code); C = toggleable passages (boilerplate sections the lawyer can include/exclude before export).

Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.


§1 Premises verified live (2026-05-22)

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
Today's /api/projects/{id}/submissions is format-only; no variables. internal/handlers/submissions.go:155-245: handler fetches the universal hl-patents-style.dotm from the in-process fileRegistry cache, calls services.ConvertDotmToDocx, writes one system_audit_log row, streams. No project data merged. frontend/src/client/submissions.ts confirms the client side: POST → blob → download. The richer engine (SubmissionVarsService + TemplateRegistry + SubmissionRenderer) was reverted to format-only in commit d86cac0 (t-paliad-230).
Original Slice 1 backend is preserved in git history and salvageable. git show 3677c81:internal/services/submission_vars.go (484 LoC, 7-namespace placeholder bag), git show 8ea3509:internal/services/submission_render.go (in-house run-fragmentation-aware merger, ~315 LoC), git show 3677c81:internal/services/submission_templates.go (442 LoC fallback-chain registry), git show 1765d5e:internal/services/submission_vars.go (Slice 2 added {{project.patent_number_upc}} helper). All four files compile against today's services (ProjectService, PartyService, UserService, branding.Name) — no API drift since 2026-05-20.
paliad.projects carries everything the variable bag needs. internal/models/project.go:123Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber. Unchanged since the original design.
paliad.parties carries party data scoped per project. internal/models/project.go:567 (Party{ID, ProjectID, Name, Role, Representative, ContactInfo}); PartyService.ListForProject(ctx, userID, projectID) already exists at internal/services/party_service.go. Visibility flows from ProjectService.GetByIDcan_see_project.
paliad.deadline_rules published rows resolve by submission_code. The same query the format-only handler uses (internal/handlers/submissions.go:255-280) — lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions.
Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1 → 106. ls internal/db/migrations/…118_paliadin_aichat_conversation. The next free number for this branch's migration is 119 (collisions only if another worktree commits 119 first, in which case the coder picks the next unused).
paliad.can_see_project(uuid) is the canonical RLS predicate. Mig 055; every other table that gates on project visibility uses it. The new submission_drafts table follows the same pattern.
The Schriftsätze tab already exists on project detail. frontend/src/projects-detail.tsx:91 (data-tab="submissions"), section #tab-submissions at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route GET /projects/{id}/submissions exists at internal/handlers/handlers.go:472 and renders the same project detail page with the submissions tab pre-selected (#tab-submissions URL fragment + tab activation). No new top-level route needed; this design adds a deeper route /projects/{id}/submissions/{code}/draft and /draft/{draftID}.
internal/handlers/files.go carries the Gitea proxy + SHA-cache pattern. Same template the original Slice 1 design lifted (in templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9 per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale.
lukasjarosch/go-docx is NOT a deal we made. The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit 8ea3509) went with an in-house renderer because the library refuses to replace sibling {{a}} ./. {{b}} placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. This design reuses the in-house engine, no new Go dependency. Memory entry ca6de586 corroborates the engine decision verbatim.
FIRM_NAME defaults to "HLC", overridable. internal/branding.Name (read once at process start). Templates land under templates/HLC/... for the default; the fallback chain handles per-firm overrides without code change.
The PoC Paliadin is owner-gated; the submission page is NOT. internal/services/paliadin.go:52PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com". This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts.
The entity-table row contract is enforced. .claude/CLAUDE.md → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern.

Doc-vs-live conflicts found: none material. docs/project-status.md doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.


§2 m's decisions (2026-05-22)

The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.

# Question (from task brief) Default adopted Source
Q1 Page location Deep page under project — /projects/{id}/submissions/{code}/draft and …/draft/{draftID} (R) — keeps URL self-describing and shareable with the project.
Q2 State persistence Server-side draft, paliad.submission_drafts keyed on (project_id, submission_code, user_id, name) with autosave (R) — multiple drafts per code, named; resumable across sessions.
Q3 Variable layer Resurrect submission_vars.go from commit 3677c81 + 1765d5e (incl. patent_number_upc); resolve at export time (R) — proven shape, ~30 placeholders, 7 namespaces.
Q4 Customization surface (UI) Structured sidebar (variable list, editable values) + read-only HTML preview pane (R) — sidebar drives the merge; preview reflects the result.
Q5 Template source (a) per-submission_code .docx in m/mWorkRepo/templates/{FIRM_NAME}/... via Gitea proxy + fallback chain (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle.
Q6 Customization options beyond variables v1: variables only. Toggleable passages = Slice C (R) — citation insertion waits for the sources system.
Q7 Migration / data shape See §4 (R) — followed task brief's column list, refined for RLS + cascade + index.
Q8 Export endpoint POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export.docx (R) — reuses ConvertDotmToDocx strip-macros path but adds merge step.
Q9 Schriftsätze tab integration Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to /projects/{id}/submissions/{code}/draft (R) — additive, no churn for users who only want the firm style.
Q10 Variable-merge library In-house renderer from commit 8ea3509 (no new Go module) (R) — the 2026-05-19 design recommended lukasjarosch/go-docx, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus.

Inventor-defaulted (not in the (R) matrix; clear right answer):

# Topic Default Reasoning
D1 Authorization paliad.can_see_project(project_id) only. No profession floor. Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream.
D2 Missing-placeholder behaviour [KEIN WERT: {key}] / [NO VALUE: {key}] in the rendered preview AND in the exported .docx, per DefaultMissingMarker(lang) from 8ea3509 Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing.
D3 Editor surface for templates Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction.
D4 Audit trail One paliad.system_audit_log row per export (event_type='submission.exported') + one paliad.project_events row (event_type='submission_exported') so the export surfaces in Verlauf / SmartTimeline. Draft create/update do NOT audit — autosave noise would dominate the log. Mirrors the existing submission.generated row from internal/handlers/submissions.go:333-339. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint.
D5 Preview engine Server-side merge → render to HTML for preview pane. Same SubmissionRenderer walks the .docx, but for the preview it strips <w:r> / <w:p> to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word.
D6 Draft autosave cadence Debounce 500ms after the lawyer stops typing in a variable field; PATCH …/drafts/{draftID} with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per user_id). Standard textarea autosave; the data is the lawyer's own draft, not a shared object.
D7 Variable contract surfacing Each placeholder in the sidebar shows: dotted key (e.g. project.case_number), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state.
D8 Draft naming First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side.

§3 Architecture overview

┌─────────────────────────────────────────────────────────────────────────────┐
│  Project detail (existing) — /projects/{id} with #tab-submissions          │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │ Schriftsätze tab                                                      │ │
│  │  Klageerwiderung                  [Bearbeiten ↗] [Generieren ↓]      │ │
│  │  Schriftsatz der Klägerin (SoC)   [Bearbeiten ↗] [Generieren ↓]      │ │
│  │  Replik                           [Bearbeiten ↗] [Generieren ↓]      │ │
│  └────────┬──────────────────────────────────────────┬─────────────────────┘ │
│           │ "Bearbeiten" deep-link                    │ "Generieren" =       │
│           │                                           │ existing one-click   │
│           ▼                                           │ format-only export   │
└───────────┼───────────────────────────────────────────┼──────────────────────┘
            │                                           │
            ▼                                           │
┌──────────────────────────────────────────────────────────────────────────────┐
│  NEW Submission draft page — /projects/{id}/submissions/{code}/draft         │
│  (lands on most-recent draft for this user, or creates "Entwurf 1")          │
│                                                                              │
│  ┌──── Sidebar (sticky left) ────────┐  ┌──── Preview pane (right) ───────┐ │
│  │ Schriftsatz: Klageerwiderung      │  │  [HTML-rendered merge of       │ │
│  │ Entwurf 1 ▼  [+ Neuer Entwurf]    │  │   template + variables +       │ │
│  │ ───────────────────────────────── │  │   overrides]                    │ │
│  │ project.case_number               │  │                                 │ │
│  │   2 O 123/25  [override?]         │  │  Klage gegen die                │ │
│  │ parties.claimant.name             │  │  Beklagte BMW AG, vertreten     │ │
│  │   BMW AG  [override?]             │  │  durch …                        │ │
│  │ deadline.due_date                 │  │                                 │ │
│  │   2026-06-12  [override?]         │  │  Sehr geehrte Damen und Herren, │ │
│  │ ...                               │  │                                 │ │
│  │                                   │  │  [KEIN WERT: project.our_side]  │ │
│  │ [✎ Bearbeiten] inline             │  │                                 │ │
│  └───────────────────────────────────┘  └──────────────────────────────────┘ │
│                                                                              │
│                          [⬇ Als .docx exportieren]                           │
└──────────────────────────────────┬───────────────────────────────────────────┘
                                   │
                                   ▼
              POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────────────┐
│  handlers/submission_drafts.go (NEW)                                         │
│   1. Auth: ProjectService.GetByID → can_see_project                          │
│   2. Load draft row (RLS via project visibility)                             │
│   3. SubmissionVarsService.Build (project + parties + rule + next-Frist)     │
│   4. Apply draft.overrides on top of bag                                     │
│   5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA           │
│      (Slice A: skips registry, fetches the universal .dotm directly)         │
│   6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes      │
│   7. Audit: system_audit_log + project_events                                │
│   8. Stream .docx with Content-Disposition: attachment                       │
└──────────────────────────────────────────────────────────────────────────────┘

No backend changes to today's Schriftsätze tab — its list endpoint + one-click generate stay exactly as they are. The new page is additive.


§4 Schema (paliad.submission_drafts)

Migration 119_submission_drafts.up.sql (next free number on this branch; coder bumps if 119 is taken at write time).

CREATE TABLE paliad.submission_drafts (
    id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
    project_id      uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
    submission_code text NOT NULL,
    user_id         uuid NOT NULL REFERENCES paliad.users(id)    ON DELETE CASCADE,
    name            text NOT NULL,                                -- "Entwurf 1", lawyer-renameable

    overrides       jsonb NOT NULL DEFAULT '{}'::jsonb,           -- { "project.case_number": "2 O 999/25", ... }
                                                                  -- empty value = "don't override, use bag"
                                                                  -- present key  = "use this verbatim"

    last_exported_at  timestamptz,                                -- NULL until first export
    last_exported_sha text,                                       -- template SHA at last export (audit aid)

    created_at      timestamptz NOT NULL DEFAULT now(),
    updated_at      timestamptz NOT NULL DEFAULT now(),

    CONSTRAINT submission_drafts_unique_per_user
        UNIQUE (project_id, submission_code, user_id, name)
);

CREATE INDEX submission_drafts_project_user_idx
    ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);

ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;

CREATE POLICY submission_drafts_visible
    ON paliad.submission_drafts
    FOR ALL
    USING (paliad.can_see_project(project_id));

-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
CREATE TRIGGER submission_drafts_updated_at
    BEFORE UPDATE ON paliad.submission_drafts
    FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();

No changes to paliad.deadline_rules. The task brief floats a template_body_de/_en Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.

No changes to paliad.documents. The original Slice 1 design wrote an audit-only row (file_path NULL) per generation; this design does notsystem_audit_log + project_events carry the audit trail, and paliad.documents is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the documents row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.

4.1 RLS read-vs-write

can_see_project is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is NOT a requirement in v1 — the unique constraint includes user_id and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.

4.2 Down migration

DROP TABLE IF EXISTS paliad.submission_drafts;

No data loss concern at design time — feature ships without legacy drafts.


§5 Service layer

5.1 Resurrect from git (no new code)

internal/services/submission_vars.go         RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
internal/services/submission_render.go       REPLACE the format-only convert with the in-house renderer from 8ea3509.
                                              KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
                                              strip macros from the universal .dotm before the merge step runs.
internal/services/submission_templates.go    RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
                                              NOT wired in Slice A (universal .dotm only); wired in Slice B.

The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (ProjectService, PartyService, UserService, branding.Name); zero API drift since their deletion. The resurrection is a copy-paste from git show, plus a one-line wiring in cmd/server/main.go + internal/handlers/handlers.go.

5.2 New service — SubmissionDraftService

// internal/services/submission_draft_service.go (NEW, ~300 LoC)

type SubmissionDraftService struct {
    db       *sqlx.DB
    projects *ProjectService
}

type SubmissionDraft struct {
    ID              uuid.UUID
    ProjectID       uuid.UUID
    SubmissionCode  string
    UserID          uuid.UUID
    Name            string
    Overrides       PlaceholderMap   // jsonb → map[string]string
    LastExportedAt  *time.Time
    LastExportedSHA *string
    CreatedAt, UpdatedAt time.Time
}

// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
// Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)

// Get returns a single draft by ID, gated on project visibility.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)

// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)

// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)

// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)

// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error

// MarkExported updates last_exported_at + last_exported_sha after a successful export.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error

DraftPatch is a struct { Name *string; Overrides *PlaceholderMap } — nil pointer = "no change", non-nil = "set to this". Overrides is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.

5.3 Wiring

// cmd/server/main.go (additions, no replacements)
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
varsSvc  := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
// Slice B only:
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)

No new env var. GITEA_TOKEN is already documented in CLAUDE.md and used by internal/handlers/files.go.


§6 UI surface

6.1 Page layout

/projects/{id}/submissions/{code}/draft lands on the user's latest draft for that (project, code). /projects/{id}/submissions/{code}/draft/{draftID} opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft EnsureLatest vs Get returns.

┌──────────────────────────────────────────────────────────────────────────────┐
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH                                  │
│   Schriftsatz: Klageerwiderung (DE.ZPO.276.1)  •  Entwurf 1                  │
│                                            [⬇ Als .docx exportieren]        │
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
│ Entwurf 1   ▼                              │  [HTML-rendered merge]         │
│   • Entwurf 1  (zuletzt 23 Mai 2026)       │                                │
│   • Entwurf 2  (zuletzt 20 Mai 2026)       │  An das Landgericht München I  │
│   • [+ Neuer Entwurf]                      │  Pacellistr. 5                 │
│                                            │  80333 München                 │
│ ─────────────────────────────────────────  │                                │
│ Variablen                                  │  In der Sache                  │
│   firm.name           HLC                  │                                │
│   project.case_number 2 O 123/25  [✎]     │  BMW AG, vertreten durch …    │
│   project.court       LG München I  [✎]   │     — Klägerin —              │
│   parties.claimant.name BMW AG     [✎]    │                                │
│   parties.defendant.name Bosch GmbH [✎]   │  gegen                         │
│   parties.defendant.representative           │                                │
│       Dr. Maria Schmidt              [✎]   │  Bosch GmbH …                  │
│   deadline.due_date   2026-06-12    [✎]   │     — Beklagte —              │
│   rule.legal_source_pretty                 │                                │
│       § 276 Abs. 1 ZPO              ✓     │  Sehr geehrte Damen und Herren,│
│   …                                        │                                │
│                                            │  [KEIN WERT: project.our_side] │
│ ─────────────────────────────────────────  │                                │
│ [Entwurf umbenennen] [Entwurf löschen]     │                                │
└────────────────────────────────────────────┴────────────────────────────────┘

Sidebar grouping (top-to-bottom, locale-aware labels):

  1. Schriftsatz (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
  2. Mandanten & Parteien (parties.*)
  3. Verfahren (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
  4. Frist (deadline.* — due_date, computed_from)
  5. Kanzlei & Datum (firm., user., today.*)

Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.

6.2 Preview pane

Read-only HTML. The same SubmissionRenderer.Render(...) call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks <w:p> / <w:r> runs and emits <p> / inline <strong> / <em> based on <w:b> / <w:i> flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.

This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel RenderHTML path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected submission_render.go.

6.3 Routing + handlers

GET  /projects/{id}/submissions/{code}/draft                  → page (lands on latest, creates if none)
GET  /projects/{id}/submissions/{code}/draft/{draftID}        → page (specific draft)
GET  /api/projects/{id}/submissions/{code}/drafts             → list drafts for current user
POST /api/projects/{id}/submissions/{code}/drafts             → create new draft → returns row + redirect target
GET  /api/projects/{id}/submissions/{code}/drafts/{draftID}   → single draft + resolved bag + HTML preview
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID}  → update name / overrides; returns new preview
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
     → .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)

Page-route handler is handleSubmissionDraftPage in internal/handlers/submission_drafts.go — calls handleProjectsDetailPage shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: new TSX page at frontend/src/submission-draft.tsx rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (#tab-submissions is the tab; /draft/{id} is a distinct page).

6.4 Schriftsätze tab — additive change

- // Each row: [Generieren ↓]
+ // Each row: [Bearbeiten ↗] [Generieren ↓]

[Bearbeiten ↗]window.location.href = "/projects/{id}/submissions/{code}/draft". [Generieren ↓] stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", [Generieren] is the path; for users who want a merged draft, [Bearbeiten] is the path.

Per the .entity-table row contract in CLAUDE.md, the row itself becomes clickable (navigates to /draft), with the Generieren button stopping propagation. The entity-table--readonly modifier is removed.


§7 Variable contract (v1 placeholder set)

Reproduced from the resurrected submission_vars.go (commits 3677c81 + 1765d5e). The sidebar's "Variablen" section enumerates this list in the exact same order as addProjectVars / addPartyVars / etc., grouped per §6.1.

firm.name                              — branding.Name (HLC or FIRM_NAME override)
firm.signature_block                   — empty in v1 (Phase 2 affordance)

today                                  — 2026-05-22 (ISO, Europe/Berlin)
today.iso                              — ISO short
today.long_de                          — "22. Mai 2026"
today.long_en                          — "22 May 2026"

user.display_name                      — paliad.users.display_name
user.email                             — paliad.users.email
user.office                            — paliad.users.office

project.title                          — paliad.projects.title
project.reference                      — paliad.projects.reference
project.case_number                    — paliad.projects.case_number
project.court                          — paliad.projects.court
project.patent_number                  — DE/inline form "EP 1 234 567 B1"
project.patent_number_upc              — UPC parenthesised form "EP 1 234 567 (B1)"  (Slice 2 helper, 1765d5e)
project.filing_date                    — ISO date
project.grant_date                     — ISO date
project.our_side                       — claimant | defendant
project.our_side_de                    — "Klägerin" | "Beklagte"
project.our_side_en                    — "Claimant" | "Defendant"
project.instance_level                 — lg | olg | bgh | cfi | …
project.client_number                  — paliad.projects.client_number
project.matter_number                  — paliad.projects.matter_number
project.proceeding.code                — e.g. "de.inf.lg"
project.proceeding.name                — locale-aware (DE: Verletzungsklage am Landgericht)
project.proceeding.name_de             — explicit DE
project.proceeding.name_en             — explicit EN

parties.claimant.name                  — first paliad.parties row with role='claimant'
parties.claimant.representative
parties.defendant.name                 — first row with role='defendant'
parties.defendant.representative
parties.other.name                     — first non-claimant/defendant row
parties.other.representative

rule.submission_code                   — "de.inf.lg.erwidg"
rule.name                              — locale-aware ("Klageerwiderung" / "Statement of Defence")
rule.name_de
rule.name_en
rule.legal_source                      — "DE.ZPO.276.1"
rule.legal_source_pretty               — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
rule.primary_party                     — claimant | defendant | court | both
rule.event_type                        — filing | hearing | decision

deadline.due_date                      — ISO of next pending deadline for this rule on this project
deadline.due_date_long_de              — "12. Juni 2026"
deadline.due_date_long_en              — "12 June 2026"
deadline.original_due_date             — ISO if extended
deadline.computed_from                 — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
deadline.title                         — deadline.title
deadline.source                        — "rule" | "manual" | …

Variable bag construction is SubmissionVarsService.Build(ctx, in) — exactly the function in 3677c81's submission_vars.go, no changes.

7.1 Override semantics

The lawyer's overrides map (jsonb in submission_drafts) shadows the bag at export time:

bag := varsSvc.Build(ctx, ...).Placeholders   // ~30 keys, resolved from project state
for k, v := range draft.Overrides {            // lawyer's edits
    if v == "" {
        delete(bag, k)                          // empty override means "force missing marker"
    } else {
        bag[k] = v                              // non-empty override replaces
    }
}
docx := renderer.Render(templateBytes, bag, missingMarker(lang))

Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the [KEIN WERT: …] marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.


§8 Template authoring (mWorkRepo layout, naming, fallback chain)

8.1 Slice A — universal .dotm only

Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. The .dotm body must carry {{placeholder}} tokens — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:

  • 8.1.a — author one universal template (m/mWorkRepo/templates/_base/_universal.docx or similar) with {{firm.name}}, {{rule.name}}, {{project.case_number}}, {{parties.claimant.name}}, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
  • 8.1.b — author one Klageerwiderung-shaped template (m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx) and route Slice A's export to that path for submission_code='de.inf.lg.erwidg', with a hard-coded fall-back to _universal.docx for any other code. This is essentially Slice A + B's first template — wins both rounds.

Inventor recommendation: 8.1.b. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".

8.2 Slice B — fallback-chain registry

Layout reproduced from the 2026-05-19 design §5.1:

m/mWorkRepo (existing repo, already proxied)
└── templates/
    ├── HLC/                                      # FIRM_NAME-keyed override dir
    │   ├── de.inf.lg.erwidg.docx                 # Slice A target (per 8.1.b above)
    │   ├── de.inf.lg.klage.docx                  # Slice B addition
    │   ├── upc.inf.cfi.soc.docx                  # Slice B addition
    │   └── upc.inf.cfi.sod.docx                  # Slice B addition
    ├── _base/                                    # Cross-firm baseline
    │   ├── de.inf.lg.erwidg.docx                 # base equivalent (Slice C+)
    │   ├── de.inf.lg.docx                        # proceeding-family fallback
    │   ├── upc.inf.cfi.docx
    │   ├── _skeleton.docx                        # ultra-generic fallback
    │   └── _universal.docx                       # the v1 Slice A "any code" template
    └── README.md                                 # placeholder reference for template authors

Naming: {submission_code}.docx. Family fallback uses the first three dot-segments (de.inf.lg from de.inf.lg.erwidg). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).

8.3 Lookup algorithm

// services/submission_templates.go  (resurrected from 3677c81)
func (r *TemplateRegistry) candidates(submissionCode string) []string {
    family := familyOf(submissionCode)
    out := []string{
        fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
        fmt.Sprintf("templates/_base/%s.docx", submissionCode),
    }
    if family != "" && family != submissionCode {
        out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
    }
    out = append(out, "templates/_base/_skeleton.docx")
    return out
}

Gitea proxy: same internal/handlers/files.go shape. 5-min SHA refresh, in-process cache, GITEA_TOKEN for auth. The original submission_templates.go already implements this end-to-end; the coder re-applies it from git show 3677c81.

8.4 No template at all — Slice A vs Slice B

Slice A: the universal template always resolves; ErrNoTemplate is impossible.

Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + "Vorlagen-Repository nicht erreichbar" in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at _skeleton.docx, this only fires when the mWorkRepo itself is misconfigured.

8.5 Template authoring task lands outside this design

Inventor flags but does not assign: HLC must author the per-submission_code .docx templates. Slice A's _universal.docx is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. Template authoring runs in parallel with engine code; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.

This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.


§9 Slice plan

Slice A — schema + new page + variables-only export against universal .docx

Ships the editor end-to-end with one template.

Deliverable Files
Migration 119 — submission_drafts table + RLS + trigger internal/db/migrations/119_submission_drafts.{up,down}.sql
SubmissionVarsService resurrected internal/services/submission_vars.go (from 3677c81 + Slice 2 patch 1765d5e)
SubmissionRenderer resurrected with new RenderHTML internal/services/submission_render.go (from 8ea3509); adds RenderHTML(...) string for preview
SubmissionDraftService internal/services/submission_draft_service.go (NEW)
Handlers (page + 7 API endpoints) internal/handlers/submission_drafts.go (NEW)
Wiring cmd/server/main.go, internal/handlers/handlers.go
Page TSX frontend/src/submission-draft.tsx (NEW)
Client bundle frontend/src/client/submission-draft.ts (NEW)
Schriftsätze tab update frontend/src/projects-detail.tsx (rows get [Bearbeiten]), frontend/src/client/submissions.ts (handler)
i18n new keys under projects.detail.submissions.draft.* and submissions.draft.* (page-level)
One template at m/mWorkRepo/templates/_base/_universal.docx (8.1.b → also templates/HLC/de.inf.lg.erwidg.docx) mWorkRepo, separate PR by m
Tests internal/services/submission_render_test.go (resurrected + RenderHTML), internal/services/submission_vars_test.go (round-trip), handler smoke

Acceptance:

  1. Opening /projects/{id}/submissions/de.inf.lg.erwidg/draft lands on the user's latest draft (or creates "Entwurf 1").
  2. Sidebar renders ~30 placeholders, pre-filled from project state.
  3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
  4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
  5. Clicking "Als .docx exportieren" downloads a merged .docx (universal template + project + lawyer overrides).
  6. system_audit_log row appears on export (event_type='submission.exported').
  7. project_events row appears on export and surfaces in Verlauf.
  8. RLS: caller without can_see_project gets 404 on the page and 404 on every draft API.
  9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
  10. go build ./... && go vet ./... && go test ./... && bun run build clean.

Slice B — per-submission_code templates + fallback chain

Engine is unchanged from Slice A; this slice wires TemplateRegistry into the export endpoint and lights up per-code templates.

Deliverable Files
TemplateRegistry resurrected internal/services/submission_templates.go (from 3677c81)
Handler swaps Slice A's fetchHLPatentsStyleBytes for templateRegistry.Resolve(code) internal/handlers/submission_drafts.go
has_template boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) internal/handlers/submissions.go
Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD mWorkRepo PR by m
Tests for fallback chain internal/services/submission_templates_test.go (resurrect from history if it existed; otherwise new)

Acceptance:

  1. Pushing m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx makes the SoC draft page resolve that template within 5 min (or instantly via POST /api/files/refresh).
  2. has_template=false rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
  3. last_exported_sha on submission_drafts records which SHA the lawyer exported against.
  4. Misconfigured repo (every fallback 404s) → 503 with clear error.

Slice C — toggleable passages

Lawyer can include/exclude boilerplate sections before export.

Deliverable Notes
passages jsonb column on submission_drafts migration 120 (or whatever's free at land time): passages jsonb NOT NULL DEFAULT '{}'::jsonb{"intro": true, "patent_validity_attack": false, "non_infringement": true}.
Template syntax for passage blocks {{#passage intro}}…{{/passage}} — start/end markers, merger drops the block when the corresponding passages.{key} is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly.
Sidebar UI "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage.
Template author API templates/README.md documents the passage syntax + a worked example.

Acceptance: turning off non_infringement in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.

Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), /admin/submission-templates variable contract sidebar.


§10 Out of scope

  • AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
  • PDF export. v1 ships .docx only; the lawyer's Word does the PDF step.
  • Multi-user collaboration on a single draft. Each draft is owner-scoped (user_id).
  • Real-time co-editing. Last-write-wins per draft; no operational transforms.
  • An in-paliad WYSIWYG editor for .docx content. Preview is read-only; final edits happen in Word.
  • A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
  • Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
  • Citation insertion from the sources system. Waits for the sources surface m parked.
  • Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
  • Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
  • Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.

§11 Material picks escalated to head

Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.

Q-E1 — Template authoring effort

Slice A needs at least one custom-authored template (_universal.docx or de.inf.lg.erwidg.docx) carrying {{placeholder}} tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.

Inventor pick: ship Slice A with one lawyer-authored template (8.1.b: templates/HLC/de.inf.lg.erwidg.docx) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.

Material because: without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?

Q-E2 — paliad.documents row on export

The original Slice 1 design wrote an audit-only paliad.documents row (file_path NULL, doc_type='generated_submission') per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.

Inventor pick: no paliad.documents write. system_audit_log + project_events carry the audit trail. The documents table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).

Material because: if head agrees, we skip a column repurpose (ai_extracted jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.

Q-E3 — Preview render — server or client?

Server-side: RenderHTML(...) on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).

Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and diverges the preview from the export shape (export still goes server-side).

Inventor pick: server-side. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.

Material because: if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of substituteInDocumentXML. Bigger build, but no round-trip latency on every keystroke.

Q-E4 — Inter-user draft visibility

Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).

Inventor pick: owner-scoped (status quo of this design). The unique constraint includes user_id; the List endpoint filters by current user.

Material because: if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to (project_id, submission_code, name) (drop user_id), the RLS already covers the read path (can_see_project), and submission_drafts becomes a project-team resource. This is a Phase-shape change — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.


§12 Implementation notes

For the coder, not for head.

  • Resurrection is git show, not "re-write". The four file revisions (3677c81:internal/services/submission_vars.go, 1765d5e:internal/services/submission_vars.go for the Slice 2 patch, 8ea3509:internal/services/submission_render.go, 3677c81:internal/services/submission_templates.go) can be applied via git checkout 3677c81 -- internal/services/submission_vars.go etc. The coder should verify each compiles against today's cmd/server/main.go wiring before applying.
  • Renderer's RenderHTML is new. The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits <p> / <strong> / <em> / <br>. ~120 LoC on top of the resurrected file. Same regex (placeholderRegex), same run-merge logic, different writer.
  • Sidebar variable schema needs a label table. The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds services/submission_var_labels.go with a map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN} for the ~30 keys. (Mirrors internal/services/email_template_variables.go shape — same lawyer-facing pattern paliad already ships at /admin/email-templates.)
  • Autosave race. The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
  • Empty-override semantics in the jsonb. overrides = {"project.case_number": ""} means "force missing marker". overrides = {} (key absent) means "fall back to bag". The service code distinguishes — careful with omitempty.
  • i18n key audit. Add projects.detail.submissions.action.edit, submissions.draft.title, submissions.draft.export, submissions.draft.sidebar.{firm,project,parties,deadline,user}.group, submissions.draft.rename, submissions.draft.delete, submissions.draft.new, etc. Roughly 35 new keys in DE + EN.
  • entity-table row contract. Schriftsätze tab today carries entity-table--readonly. Slice A removes that modifier and adds a row-click handler that navigates to /projects/{id}/submissions/{code}/draft, skipping clicks on the inner [Generieren] button. Matches the pattern in frontend/src/client/checklists.ts, client/projects-detail.ts, client/deadlines.ts.
  • Migration 119 may collide. Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.

§13 Acceptance gate

Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):

  • The head decides whether to hire the same worker as /mai-coder with this design as the brief, or a fresh coder.
  • A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
  • Slices B and C are SEPARATE tasks — not auto-spawned.

Inventor parks here.