diff --git a/internal/handlers/files.go b/internal/handlers/files.go
index 34e2463..4682091 100644
--- a/internal/handlers/files.go
+++ b/internal/handlers/files.go
@@ -65,8 +65,28 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
},
+ // Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
+ // that carries every placeholder SubmissionVarsService resolves but no
+ // submission_code-specific body structure. Slot between the per-firm
+ // per-code template and the bare HL Patents Style .dotm fallback: every
+ // submission_code without a dedicated template still renders with
+ // variables substituted instead of the macro-only letterhead.
+ skeletonSubmissionSlug: {
+ RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
+ DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ RepoOwner: "m",
+ RepoName: "mWorkRepo",
+ FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
+ },
}
+// skeletonSubmissionSlug names the universal skeleton template inside
+// the shared fileRegistry cache. Exported via a const so handler code
+// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
+// the same string the registry uses.
+const skeletonSubmissionSlug = "submission/_skeleton.docx"
+
// submissionTemplateRegistry maps a deadline-rule submission_code to a
// fileRegistry slug. Lookup order matches the cronus design fallback
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
@@ -189,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
}
+// fetchSubmissionSkeletonBytes returns the cached universal skeleton
+// template bytes plus its provenance SHA. Sits between the per-firm
+// per-submission_code template (fetchSubmissionTemplateBytes) and the
+// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
+// resolveSubmissionTemplate's fallback chain — used for every
+// submission_code that has no dedicated template registered. Same
+// stale-while-revalidate semantics as the rest of the file proxy: first
+// call warms the cache synchronously from mWorkRepo via Gitea; later
+// calls return immediately while a background refresh runs.
+func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
+ entry, ok := fileRegistry[skeletonSubmissionSlug]
+ if !ok {
+ return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
+ }
+ ce := getCacheEntry(skeletonSubmissionSlug)
+
+ ce.mu.RLock()
+ hasData := len(ce.data) > 0
+ needsCheck := time.Since(ce.lastChecked) >= checkInterval
+ ce.mu.RUnlock()
+
+ if !hasData {
+ if err := fileFetch(ce, entry); err != nil {
+ return nil, "", err
+ }
+ } else if needsCheck {
+ go fileCheckAndRefresh(ce, entry)
+ }
+
+ ce.mu.RLock()
+ defer ce.mu.RUnlock()
+ if len(ce.data) == 0 {
+ return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
+ }
+ out := make([]byte, len(ce.data))
+ copy(out, ce.data)
+ _ = ctx
+ return out, ce.sha, nil
+}
+
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
// bytes. Shared accessor used by both the /files/{slug} download path
// (Word auto-update channel) and the submission generator
diff --git a/internal/handlers/submission_drafts.go b/internal/handlers/submission_drafts.go
index 6ae8841..66c8f20 100644
--- a/internal/handlers/submission_drafts.go
+++ b/internal/handlers/submission_drafts.go
@@ -904,16 +904,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
-// §8: per-firm template registered in submissionTemplateRegistry first,
-// then the universal HL Patents Style as the global fallback. The
-// returned SHA is the cache entry's commit SHA so the export audit row
-// can record provenance.
+// §8 plus the t-paliad-259 universal-skeleton slot:
+//
+// 1. per-firm per-submission_code template registered in
+// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
+// specific structure plus the full variable bag.
+// 2. universal _skeleton.docx — same variable bag, no submission_code-
+// specific prose. Catches every code without a dedicated template
+// so the editor preview / generate flow still has variables to
+// substitute instead of falling through to the bare letterhead.
+// 3. universal HL Patents Style .dotm — macro-only letterhead, no
+// placeholders. Final fallback when even the skeleton is unreachable
+// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
+// for resilience.
+//
+// The returned SHA is the cache entry's commit SHA so the export audit
+// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
return nil, "", err
} else if found {
return data, sha, nil
}
+ if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
+ return data, sha, nil
+ } else {
+ log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
+ }
bytes, err := fetchHLPatentsStyleBytes(ctx)
if err != nil {
return nil, "", err
diff --git a/scripts/gen-skeleton-submission-template/main.go b/scripts/gen-skeleton-submission-template/main.go
new file mode 100644
index 0000000..175a9eb
--- /dev/null
+++ b/scripts/gen-skeleton-submission-template/main.go
@@ -0,0 +1,303 @@
+// Universal-skeleton submission template generator (t-paliad-259).
+//
+// One-shot authoring tool that emits a minimal but Word-compatible
+// .docx file exercising every placeholder SubmissionVarsService
+// resolves — without baking in any submission_code-specific prose.
+//
+// Drop the output into m/mWorkRepo at
+//
+// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
+//
+// so paliad's submission generator picks it up via the fallback chain
+// slotted between the per-submission_code template and the bare
+// universal HL Patents Style .dotm. Any submission_code that has no
+// per-firm template still gets a draft populated with variables
+// instead of the macro-only letterhead.
+//
+// Why a separate file from de.inf.lg.erwidg.docx: that one is a
+// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
+// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
+// appeal, that body structure is wrong. The universal skeleton drops
+// the structure and leaves a single neutral body block the lawyer
+// replaces — every variable still resolves regardless of code.
+//
+// Run:
+//
+// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
+//
+// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
+// timestamp).
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+func main() {
+ out := flag.String("out", "_skeleton.docx", "output .docx path")
+ flag.Parse()
+
+ docx, err := buildDocx()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
+ os.Exit(1)
+ }
+ if err := os.WriteFile(*out, docx, 0o644); err != nil {
+ fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
+ os.Exit(1)
+ }
+ fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
+}
+
+var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
+
+func buildDocx() ([]byte, error) {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+
+ add := func(name, body string) error {
+ hdr := &zip.FileHeader{
+ Name: name,
+ Method: zip.Deflate,
+ Modified: fixedTime,
+ }
+ w, err := zw.CreateHeader(hdr)
+ if err != nil {
+ return fmt.Errorf("create %s: %w", name, err)
+ }
+ if _, err := w.Write([]byte(body)); err != nil {
+ return fmt.Errorf("write %s: %w", name, err)
+ }
+ return nil
+ }
+
+ if err := add("[Content_Types].xml", contentTypesXML); err != nil {
+ return nil, err
+ }
+ if err := add("_rels/.rels", rootRelsXML); err != nil {
+ return nil, err
+ }
+ if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
+ return nil, err
+ }
+ if err := add("word/styles.xml", stylesXML); err != nil {
+ return nil, err
+ }
+ if err := add("word/document.xml", buildDocumentXML()); err != nil {
+ return nil, err
+ }
+
+ if err := zw.Close(); err != nil {
+ return nil, fmt.Errorf("finalise zip: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+const contentTypesXML = `
+
+
+
+
+
+`
+
+const rootRelsXML = `
+
+
+`
+
+const documentRelsXML = `
+
+
+`
+
+const stylesXML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`
+
+// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
+// case caption + parties + submission heading + deadline + a single
+// neutral body block. Mirrors the variable bag from SubmissionVarsService
+// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
+// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
+// structure. A lawyer customising this template for a UPC SoC, EPO
+// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
+// renames the party labels — every placeholder still resolves regardless
+// of the submission_code chosen.
+//
+// Every placeholder occupies its own run so the renderer's pass-1
+// (format-preserving, single-run) substitution catches it. The
+// DEMO/SKELETON banner makes it obvious this is a starter template and
+// not approved firm content.
+func buildDocumentXML() string {
+ var b strings.Builder
+ b.WriteString(``)
+ b.WriteString(``)
+ b.WriteString(``)
+
+ skeletonBanner(&b)
+
+ heading1(&b, "{{firm.name}}")
+ plain(&b, "Bearbeiter: {{user.display_name}}")
+ plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
+ plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
+ plainOptional(&b, "{{firm.signature_block}}")
+
+ heading1(&b, "{{project.court}}")
+ plain(&b, "Aktenzeichen: {{project.case_number}}")
+ plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
+ plain(&b, "Instanz: {{project.instance_level}}")
+
+ heading2(&b, "In der Sache")
+ plain(&b, "{{parties.claimant.name}}")
+ plain(&b, "vertreten durch {{parties.claimant.representative}}")
+ bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
+ plain(&b, "")
+ plain(&b, "gegen")
+ plain(&b, "")
+ plain(&b, "{{parties.defendant.name}}")
+ plain(&b, "vertreten durch {{parties.defendant.representative}}")
+ bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
+ plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
+
+ heading2(&b, "Betreff")
+ plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
+ plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
+ plain(&b, "Projekttitel: {{project.title}}")
+ plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
+ plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
+ plain(&b, "Internes Aktenzeichen: {{project.reference}}")
+
+ heading1(&b, "{{rule.name}}")
+ plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
+ plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
+ plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
+
+ heading2(&b, "Frist")
+ plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
+ plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
+ plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
+ plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
+
+ heading2(&b, "Schriftsatztext")
+ plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
+ plain(&b, "")
+ plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
+
+ heading2(&b, "Schlussformel")
+ plain(&b, "{{today.long_de}}")
+ plain(&b, "")
+ plain(&b, "{{user.display_name}}")
+ plain(&b, "{{firm.name}}")
+
+ // Locale-aware verification block — exercises every EN/DE alias the
+ // variable bag carries (today.long_en, deadline.due_date_long_en,
+ // project.our_side_en, project.proceeding.name_en, rule.name_en) and
+ // the bare {{today}} alias. A lawyer customising the template can
+ // delete this block; the renderer round-trips it cleanly today.
+ heading2(&b, "Locale-aware variants (SKELETON)")
+ plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
+ plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
+ plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
+ plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
+ plain(&b, "Today (bare alias): {{today}}")
+
+ b.WriteString(``)
+ return b.String()
+}
+
+func skeletonBanner(b *strings.Builder) {
+ b.WriteString(`SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)`)
+}
+
+func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
+
+func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
+
+func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
+
+func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
+
+func bold(b *strings.Builder, text string) {
+ b.WriteString(``)
+ b.WriteString(``)
+ b.WriteString(xmlEscape(text))
+ b.WriteString(``)
+}
+
+func paragraph(b *strings.Builder, style, text string, italic bool) {
+ b.WriteString(``)
+ if style != "" {
+ b.WriteString(``)
+ }
+ for _, seg := range splitOnPlaceholders(text) {
+ b.WriteString(``)
+ if italic {
+ b.WriteString(``)
+ }
+ b.WriteString(``)
+ b.WriteString(xmlEscape(seg))
+ b.WriteString(``)
+ }
+ b.WriteString(``)
+}
+
+func splitOnPlaceholders(s string) []string {
+ if s == "" {
+ return []string{""}
+ }
+ var out []string
+ for {
+ open := strings.Index(s, "{{")
+ if open < 0 {
+ out = append(out, s)
+ return out
+ }
+ close := strings.Index(s[open:], "}}")
+ if close < 0 {
+ out = append(out, s)
+ return out
+ }
+ end := open + close + 2
+ if open > 0 {
+ out = append(out, s[:open])
+ }
+ out = append(out, s[open:end])
+ s = s[end:]
+ if s == "" {
+ return out
+ }
+ }
+}
+
+func xmlEscape(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, `"`, """)
+ s = strings.ReplaceAll(s, "'", "'")
+ return s
+}