Files
paliad/docs/design-project-metadata-rework-2026-05-20.md
mAi ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived
project codes from the ancestor tree) in one shift.

Migrations:
- mig 112_client_role_rework: widen paliad.projects.our_side CHECK to
  seven sub-roles (claimant / defendant / applicant / appellant /
  respondent / third_party / other); drop legacy 'court' / 'both'
  and backfill rows to NULL (no-op on prod, defensive on staging).
- mig 113_projects_opponent_code: add paliad.projects.opponent_code
  text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as
  the middle segment when assembling auto-derived project codes.

Backend:
- internal/services/project_code.go — new package-level helpers
  BuildProjectCode (single row) + PopulateProjectCodes (bulk, one
  CTE-based round-trip). Walks the existing paliad.projects.path
  ltree; custom paliad.projects.reference on the target wins.
- Wired into ProjectService.List, GetByID, ListAncestors, GetTree,
  LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every
  service entry-point that returns []models.Project / *models.Project
  populates .Code before returning.
- Models: Project.OurSide doc widened; new Project.OpponentCode
  (db:"opponent_code") and Project.Code (db:"-", projection-only).
- CreateProjectInput / UpdateProjectInput accept OpponentCode;
  validateOpponentCode + nullableOpponentCode mirror our_side helpers.
- validateOurSide widens to the seven sub-roles; legacy 'court' /
  'both' rejected at the service layer with a clear error before
  the DB CHECK fires.
- derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent,
  appellant → respondent; third_party / other / NULL pass through.
- submission_vars: project.code added to the placeholder bag.
  ourSideDE / ourSideEN now use the gender-neutral "-Seite" /
  "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...);
  better legal-prose default for a B2B patent practice, matches the
  form labels which already used this shape (cf. head's soft-note on
  Q4).

Frontend:
- ProjectFormFields: opponent_code on a new projekt-fields-litigation
  block (hidden by default, shown when type=litigation); our_side
  moved into projekt-fields-case and re-labelled "Client Role" /
  "Mandantenrolle" with three <optgroup>s + seven options.
- project-form.ts: showFieldsForType toggles the new litigation
  block; readPayload / prefillForm wire opponent_code; our_side
  is now only emitted for type=case.
- fristenrechner: ourSideToPerspective widened to the seven sub-roles
  (Active→claimant, Reactive→defendant, Other→null). ProjectOption
  type literal updated.
- i18n.ts: new projects.field.client_role.* and
  projects.field.opponent_code.* keys (DE+EN). Legacy
  projects.field.our_side.* keys stay one release for cached
  bundles + Verlauf event-history rendering of the new sub-roles.

Tests:
- TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3,
  TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode,
  TestValidateOurSideSubRoles pin the new pure helpers.
- TestOurSideTranslations widened to the seven sub-roles + new
  prose shape; 'court'/'both' arms now return "" (legacy rejected).
- TestDerivedCounterclaimOurSide widened to the new flip map.

Migration slot history (this branch was rebumped twice on 2026-05-20):
mig 110 was claimed by m/paliad#51 (project_type_other, euler);
mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss).
Final slots 112 / 113.

go build && go test ./internal/... && cd frontend && bun run build
all clean.
2026-05-20 14:55:55 +02:00

31 KiB
Raw Permalink Blame History

Project metadata rework — Client Role + auto-derived project codes

Status: design, ready for head review (2026-05-20) Task: t-paliad-222 Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes) Branch: mai/kepler/inventorcoder-project

Pairs two related changes because both touch paliad.projects schema, the project form, and downstream consumers (Fristenrechner Determinator, submission templates, Verlauf, picker / breadcrumb surfaces). One design, two migrations, one coder shift.


§1 Scope & non-goals

In scope:

  • Drop "Wir vertreten" entirely on type='client', 'litigation', 'patent'.
  • Rename to "Client Role" / "Mandantenrolle" on type='case' with new option set (Active / Reactive / Third Party / Other).
  • Widen paliad.projects.our_side CHECK to the new sub-role values; drop 'court' and 'both'; backfill existing rows to NULL.
  • Add paliad.projects.opponent_code text on type='litigation' rows (segment source for project codes).
  • New Go helper services.BuildProjectCode(ctx, projectID) (string, error) that walks the ancestor chain via the existing ltree path and assembles the dotted code. Custom paliad.projects.reference on the project itself wins.
  • Wire the helper into project header, breadcrumb, picker labels, the submission-template variable bag ({{project.code}}), and the Excel export __meta sheet.

Out of scope (handled separately or dropped):

  • Reshaping paliad.parties (per-party role rows are unchanged).
  • New analytics / reports breaking out sub-roles.
  • Bulk-renaming user-facing copy that says "Klägerseite" / "Beklagtenseite" outside the project form.
  • Reverse lookup (project by code) — already works via reference.
  • Audit-history for who changed an override and when — not requested.
  • Bulk regeneration of existing reference strings — manual entries stay intact; auto-derive only fills empty slots.
  • Renaming the our_side DB column — see §2.2 / Q1.

§2 Issue #47 — Client Role rework

§2.1 Current state (verified 2026-05-20)

  • Column: paliad.projects.our_side text, CHECK constraint projects_our_side_check allows ('claimant','defendant','court','both',NULL) (mig 072).
  • Live data audit (SELECT our_side, count(*) FROM paliad.projects GROUP BY our_side): all 12 rows are NULL. Zero rows on 'court' or 'both' — backfill is a no-op. The migration is risk-free on the current dataset.
  • Form: rendered for every project type by frontend/src/components/ProjectFormFields.tsx:156-168 (one <select id="project-our-side"> with five static <option>s, no conditional render).
  • Downstream consumers (verified by grep on our_side / OurSide in internal/ and frontend/src/):
    • frontend/src/client/fristenrechner.ts:2187,2734,3754-3776 — Determinator Slice 3c, ourSideToPerspective() maps claimant → claimant, defendant → defendant, anything else (incl. 'court', 'both', NULL) → null (chip free-pick).
    • internal/services/submission_vars.go:276-278,390-418{{project.our_side_de}} / _en legal-prose forms. ourSideDE / ourSideEN switch on the 4 enum values.
    • internal/services/project_service.go:1083-1104our_side_changed project-event row on writes.
    • internal/services/project_service.go:1228,1372,1955- — CCR counterclaim child default-inverts our_side; nullableOurSide() and isValidOurSide() (project_service.go:1915) gate writes.

§2.2 Decisions

Q1 — Rename column our_side → client_role? Pick: NO. Keep our_side. Renaming forces churn in eleven Go files, the Determinator client bundle (fristenrechner.ts type literal + ourSideToPerspective), all submission-template tests (submission_render_test.go:275), the project-event title key (event.title.our_side_changed), and every {{project.our_side*}} template that exists in the wild on user systems. The label is purely UI; the column name is internal. Future grep stays clean because the new label ("Client Role") and the column (our_side) describe the same concept from different perspectives ("which side the firm represents" = "what role the client plays"). Keeping the column avoids a 200-line mechanical rename with non-trivial risk for zero functional gain. The i18n keys do rename (projects.field.our_sideprojects.field.client_role) so user-facing copy stays clean.

Q2 — Sub-role granularity (7 distinct values vs 3 groups)? Pick: 7 sub-rolesclaimant, defendant, applicant, appellant, respondent, third_party, other. Lawyers care about the specific procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI applications use "Applicant"). Group-level aggregation is trivial at display time (switch role { case claimant, applicant, appellant: return "Active" }). Storing the group only would be a lossy choice we cannot reconstruct from.

Q3 — Project types where the field is visible? Pick: ONLY type='case'. m's wording is unambiguous ("only plays a role in case projects — and even there the question should be 'Client Role'"). Hide on client, litigation, patent, and the generic project type. The client-level "industry / country" block stays as is (those are client-attributes, not procedural roles). The form already has projekt-fields-case conditional render (ProjectFormFields.tsx:143) — moving the role select into that block is a 4-line change.

Q4 — Existing 'court' / 'both' row backfill? Pick: backfill to NULL in the same migration that widens the CHECK. Zero rows in production (verified 2026-05-20), so the backfill is a no-op today; it's there for safety if any test fixture or not-yet-deployed instance has them. No audit-event emission for the backfill (it's schema cleanup, not user action).

Q5 — Determinator perspective mapping for new sub-roles? Pick: Active group → claimant, Reactive group → defendant, Third Party / Other → null (chip free-pick). Concretely:

  • claimant, applicant, appellant → perspective 'claimant'
  • defendant, respondent → perspective 'defendant'
  • third_party, other, NULL → perspective null

This keeps the Determinator's existing claimant-rule / defendant-rule filter logic unchanged; only ourSideToPerspective()'s switch widens.

Q6 — Submission template _de / _en prose for new sub-roles?

value _de (Nominativ) _en
claimant Klägerin Claimant
defendant Beklagte Defendant
applicant Antragstellerin Applicant
appellant Berufungsklägerin Appellant
respondent Antragsgegnerin Respondent
third_party Streithelferin Third Party
other sonstige Verfahrensbeteiligte other party

Existing 'court'/'both' switch arms get deleted (no live rows; if a stale our_side='court' slipped through somehow, the function returns "" — same fallback as today for unknown values).

§2.3 Migration 112_client_role_rework

-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
-- t-paliad-222 / m/paliad#47.
-- Widens projects.our_side CHECK to seven sub-role values and drops
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
-- runs defensively in case any test fixture / staging instance still
-- carries the old values.

BEGIN;

-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
UPDATE paliad.projects
   SET our_side = NULL
 WHERE our_side IN ('court', 'both');

-- 2. Drop the old CHECK, add the widened one. Both are idempotent
--    against partially-applied state.
ALTER TABLE paliad.projects
    DROP CONSTRAINT IF EXISTS projects_our_side_check;

ALTER TABLE paliad.projects
    ADD CONSTRAINT projects_our_side_check
    CHECK (our_side IS NULL OR our_side IN (
        'claimant', 'defendant',
        'applicant', 'appellant',
        'respondent',
        'third_party', 'other'
    ));

COMMENT ON COLUMN paliad.projects.our_side IS
    'Which side the firm represents on this case project (renamed in '
    'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
    'sub-roles, grouped at display time: Active (claimant, applicant, '
    'appellant); Reactive (defendant, respondent); Third Party / Other '
    '(third_party, other). NULL = unknown. Hidden in the form on '
    'non-case project types. Drives the Fristenrechner Determinator '
    'perspective chip (Active→claimant, Reactive→defendant, else null).';

COMMIT;

The down migration restores the original 4-value CHECK and, for defensive symmetry, backfills any new sub-role values to NULL (so the schema is internally consistent when stepped down).

§2.4 Frontend changes

frontend/src/components/ProjectFormFields.tsx:

  1. Move the <div className="form-field"> containing #project-our-side from the always-visible block (line 156) into the projekt-fields-case block (after the court / case-number row).
  2. Rename label data-i18n="projects.field.our_side"projects.field.client_role.
  3. Replace the five flat <option>s with three <optgroup>s + the seven new options + an "Unbekannt" empty option.
  4. Update the hint text to mention the Determinator group mapping (Active/Reactive).

frontend/src/client/i18n.ts — add new keys (DE + EN):

projects.field.client_role                  → "Mandantenrolle" / "Client Role"
projects.field.client_role.hint             → "..."
projects.field.client_role.unset            → "Unbekannt" / "Unknown"
projects.field.client_role.group.active     → "Aktiv (wir greifen an)"  / "Active (we initiate)"
projects.field.client_role.group.reactive   → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
projects.field.client_role.group.other      → "Dritte / Sonstige"        / "Third Party / Other"
projects.field.client_role.claimant         → "Klägerseite"              / "Claimant"
projects.field.client_role.applicant        → "Antragsteller"            / "Applicant"
projects.field.client_role.appellant        → "Berufungsführer"          / "Appellant"
projects.field.client_role.defendant        → "Beklagtenseite"           / "Defendant"
projects.field.client_role.respondent       → "Antragsgegner"            / "Respondent"
projects.field.client_role.third_party      → "Streithelfer / Dritter"   / "Third Party"
projects.field.client_role.other            → "Sonstige Beteiligte"      / "Other party"

The legacy projects.field.our_side.* keys stay deprecated-but-present for one release so any cached browser bundle keeps rendering. They get deleted in a follow-up housekeeping shift once the rollout is confirmed.

frontend/src/client/project-form.ts:182-230 — adjust the payload read/write to only include our_side when the field is in the DOM (non-case forms no longer emit it). The current code does if (v) payload.our_side = v which already handles the "field absent" case gracefully (osSel becomes null, no payload key set).

frontend/src/client/fristenrechner.ts:3754-3776ourSideToPerspective switch widens:

function ourSideToPerspective(os: string | null | undefined): Perspective {
  switch (os) {
    case "claimant":
    case "applicant":
    case "appellant":
      return "claimant";
    case "defendant":
    case "respondent":
      return "defendant";
    default:
      return null;
  }
}

frontend/src/projects-detail.tsx Verlauf — the our_side_changed event description currently renders the raw enum. Update the renderer to use a label lookup so "Mandant: Beklagte → Antragsteller" reads correctly. Same event.title.our_side_changed key stays (the title is "Vertretene Seite geändert" / "Represented side changed", which is still accurate semantically).

§2.5 Backend changes

internal/services/project_service.go:1915isValidOurSide() widens its allowlist:

case "", "claimant", "defendant",
    "applicant", "appellant",
    "respondent",
    "third_party", "other":
    return nil

internal/services/project_service.go:1372derivedCounterclaimOurSide() (CCR flip logic): widen the flip map to mirror the Determinator grouping:

  • claimant ↔ defendant (current behaviour)
  • applicant ↔ respondent
  • appellant → defendant (CCR against an appellant is rare; pick the most-likely procedural posture; can be overridden by explicit flip_our_side=false)
  • third_party / other / NULL → keep as-is (no flip)

internal/services/submission_vars.go:391-418ourSideDE / ourSideEN switch arms add the five new values per the table in §2.2 Q6. 'court' and 'both' arms get deleted.

internal/services/project_service.go:1083-1104our_side_changed audit emission unchanged (it just records old → new on the column).

frontend/build.ts — no change; bundling already picks up projects.field.client_role.* i18n keys via i18n-keys.ts regeneration.

frontend/src/i18n-keys.ts — regenerate via existing scripted path (adds the new keys, keeps the legacy ones as deprecated entries until the housekeeping pass).

§2.6 Tests

  • internal/services/submission_render_test.go:275TestOurSideTranslations widens the table to cover the 7 new values in both DE and EN.
  • internal/services/projection_service_unit_test.go:319TestDerivedCounterclaimOurSide widens to cover the new flip map.
  • New: TestProjectFormHidesOurSideForNonCase — unit test on the project-form payload reader confirms our_side is silently dropped when the form renders for a non-case project type.

§2.7 Acceptance (issue #47)

  • Creating a project of type='client', 'litigation', 'patent', 'project' does not show the field.
  • Creating a project of type='case' shows the field labelled "Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups and seven options.
  • Existing 'court' / 'both' rows (none in prod, but defensive) are migrated to NULL.
  • Submission templates referencing {{project.our_side_de}} / _en render coherent prose for the five new values.
  • Determinator perspective chip pre-fills correctly from each sub-role (Active→claimant, Reactive→defendant, Other→null).
  • CCR counterclaim flip yields a sensible child role for the new sub-roles.
  • go build && go test ./internal/... && cd frontend && bun run build clean.

§3 Issue #50 — Auto-derived project codes

§3.1 Current state (verified 2026-05-20)

  • paliad.projects.reference text exists and is informally used (live values: EXMPL on a client, L-2026-001 on a litigation, C-UPC-0001 on a case, P-EP1111222 on a patent). No format enforcement.
  • paliad.projects.path ltree is maintained by a Postgres trigger (projects.path joined UUIDs root-to-self). Walking ancestors in Go is straightforward: SELECT * FROM paliad.projects WHERE path @> $1::ltree ORDER BY nlevel(path).
  • No opponent field exists anywhere. Opponent text lives only inside the litigation title (e.g. "Siemens AG ./. Huawei Technologies").
  • paliad.proceeding_types.code is dot-separated: upc.inf.cfi, upc.rev.cfi, de.inf.lg, upc.apl.merits, etc. Splitting on . and upper-casing yields INF, REV, LG, APL.MERITS. Suitable as the case segment.
  • paliad.projects.court text is free-text on cases (live values: UPC, UPC CoA, LG München I). Not normalised; use the proceeding_type code instead — it carries the same info structurally.

§3.2 Decisions

Q1 — Litigation opponent source: new column or regex on title? Pick: new column paliad.projects.opponent_code text on litigation rows. Regex on title is brittle ("./.", "v.", "vs", "—", varying order) and the user already knows the short code at creation time. New field with explicit validation (slug-cased, max 16 chars) is clean and takes one form field + one migration. Title stays as the human-readable caption; opponent_code is the machine-readable segment source. NULL → segment skipped silently.

Q2 — Patent segment: always last 3, or last-N variable? Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full digit-stream when shorter. m's example (EP3456789 → 789) is 7 digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to their last 3 just fine — uniqueness inside the same litigation tree is near-certain because the same litigation tree won't hold two patents sharing the same last-3. If it ever does, the user can set a custom reference (Q5). No need for last-4 / last-N logic.

The patent-number regex extracts the digit-stream from any common format (EP1234567, EP 1 234 567, EP1234567A1, WO2020/123456A1): strip non-digits, take last 3 (or whole if shorter), upper-cased.

Q3 — Case segment from proceeding_types.code? Pick: take proceeding_types.code (e.g. upc.inf.cfi), split on ., drop the leading jurisdiction segment, uppercase the rest, join with .. Examples:

  • upc.inf.cfiINF.CFI
  • upc.rev.cfiREV.CFI
  • upc.pi.cfiPI.CFI
  • upc.apl.meritsAPL.MERITS
  • de.inf.lgINF.LG
  • de.inf.olgINF.OLG (appeal instance → segment already encodes "OLG", so we get the appeal level for free; no separate instance segment needed)

The jurisdiction is dropped because the parent client/patent already implies the jurisdiction context. If the user wants explicit jurisdiction in the code, custom reference wins.

If proceeding_type_id is NULL on the case, segment is omitted silently. No fallback to court text — that's free-text and noisy.

Q4 — Override semantics: wholesale or per-segment? Pick: wholesale. When paliad.projects.reference is non-empty on the project the helper is asked about, that string is returned verbatim — no auto-derivation, no string-concatenation, no merging. Per-segment override doubles the implementation complexity for a UX nobody asked for. Users who want partial overrides set the reference on the relevant ancestor and let the rest auto-derive naturally.

Q5 — Where the user types the override? Pick: existing paliad.projects.reference field. Already there, already labelled "Interne Referenz (optional)", already used by users. Adding a second "project_code_override" alongside reference would confuse the form. The hint text gets a small addendum: "Leer lassen für automatischen Code aus dem Projekt-Baum."

Q6 — Collision handling (two cases derive to the same code)? Pick: advisory in v1; no disambiguator. Codes are display-only (not a primary key, not a unique constraint). Real-world collisions inside the same litigation tree are vanishingly rare; if they happen, the user notices in the picker and sets a custom reference on one. Adding -N suffixes silently would mask a data issue the user should see. A future surface could flag duplicates as a project-detail warning, but it's not in v1.

Q7 (new) — Helper signature and call site? Pick: ProjectService.BuildProjectCode(ctx context.Context, projectID uuid.UUID) (string, error). Lives on the existing ProjectService (it needs DB access for the ancestor walk). Internally builds segments with a small projectCodeSegment(p Project) string pure function per type that's table-test-friendly. The helper is called from the projection layer when a project gets serialised for the API (adds a code field to the JSON), so every surface — header, breadcrumb, picker, dashboard tile, Excel export — gets the code for free without each surface re-walking the tree. Pricier than a display-time call but eliminates N+1 walks in list views.

Q8 (new) — Cache strategy? Pick: no cache in v1. Each ancestor walk is one indexed lookup on paliad.projects(path). With 12 projects in prod and order-of-100s in any plausible firm-scale future, this is microsecond-cheap. If profiling later shows it as a hotspot in list views (which fetch many projects), introduce a materialised view paliad.projects_derived_codes(project_id, derived_code) refreshed by trigger on projects writes. Don't pre-optimise.

§3.3 Migration 113_projects_opponent_code

-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
-- t-paliad-222 / m/paliad#50.
-- Add an opponent-code field on litigation projects. Used as the
-- middle segment when assembling auto-derived project codes from the
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
-- skipped silently. No backfill — existing litigation rows simply
-- yield codes without an opponent segment until the user sets one.

BEGIN;

ALTER TABLE paliad.projects
    ADD COLUMN IF NOT EXISTS opponent_code text;

-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint
         WHERE conname = 'projects_opponent_code_check'
           AND conrelid = 'paliad.projects'::regclass
    ) THEN
        ALTER TABLE paliad.projects
            ADD CONSTRAINT projects_opponent_code_check
            CHECK (opponent_code IS NULL
                OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
                    AND type = 'litigation'));
    END IF;
END $$;

COMMENT ON COLUMN paliad.projects.opponent_code IS
    'Short slug for the opposing party on a litigation project '
    '(uppercase letters, digits, dashes, max 16 chars). Used as the '
    'middle segment when BuildProjectCode walks the ancestor tree to '
    'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
    'NULL = segment skipped silently.';

COMMIT;

The down migration drops the constraint then the column.

§3.4 Go helper

New file internal/services/project_code.go:

// Package-level function (not a method) so it can be called from any
// service that already has a *sqlx.DB. ProjectService has a thin
// wrapper that calls into this.
//
// BuildProjectCode assembles the dotted ancestor code for projectID
// from the existing paliad.projects.path ltree. If the target row's
// reference column is non-empty, it wins outright (no derivation).
// Missing ancestor segments are skipped silently — there is no
// "unknown" placeholder.
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)

// projectCodeSegment is the per-type segment derivation. Pure, table-
// test friendly, never touches the DB.
//
//   client     → opts.PreferShortReference (reference if set, else slug(title))
//   litigation → opts.PreferShortReference (opponent_code if set, else "")
//   patent     → last 3 digits of patent_number (full digits if <4)
//   case       → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
//   project    → "" (generic projects don't contribute a segment)
//
// proceedingCode is only needed for case rows; the caller resolves
// it via a single join (or a cached small lookup) before calling.
func projectCodeSegment(p models.Project, proceedingCode string) string

Sanitisation helpers live alongside as unexported funcs:

  • sanitizeClientShort(s string) string — uppercase, strip diacritics via golang.org/x/text/unicode/norm + filter, replace non-alnum with -, trim, cap at 8 chars. Already similar to what internal/util/slug does for the global slug helper.
  • patentLast3(s string) string — strip non-digits, take last 3 characters (or the whole digit-stream when shorter); uppercase. Empty → "".
  • proceedingTail(code string) string — split on ., drop element 0 (jurisdiction), uppercase + join the rest. """".

BuildProjectCode SQL is a single round-trip:

SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
       p.patent_number, p.proceeding_type_id,
       pt.code AS proceeding_code
  FROM paliad.projects p
  LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
 WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
 ORDER BY nlevel(p.path);

It returns the chain root-to-target. The function:

  1. If the last row (the target) has non-empty reference → return it verbatim. Done.
  2. Otherwise walk the chain top-to-bottom, call projectCodeSegment on each row, skip empty segments, join with ., return.

§3.5 Wiring into surfaces

  • internal/services/project_service.go projection — add a Code string field to the read-side struct and populate it in the single fetch path. For list endpoints, do one ancestor-chain query per page (CTE that groups by target id) rather than N+1.
  • internal/services/submission_vars.go:277 — add bag["project.code"] = derefString(p.Code) so submission templates can reference {{project.code}}.
  • frontend/src/components/ProjectHeader.tsx (current header component on /projects/{id}) — render code next to the title (small monospace badge) if non-empty.
  • frontend/src/components/Breadcrumb*.tsx — when rendering the trail, use project.code as the trailing badge per segment if the caller asks for it (opt-in to avoid breaking other consumers).
  • frontend/src/client/project-form.ts and any project-picker typeahead — show code · title in the dropdown labels when code is non-empty.
  • Excel __meta sheet — add a Project Code row (already enumerates project metadata).

The "copy reference" affordance in the header gets a second line: if both reference (user override) and the auto-derived code differ, both are visible (override above, derived below, smaller).

§3.6 Tests

  • TestProjectCodeSegment (table) — every project type × multiple shapes (with/without reference, NULL ancestors, patent_number formats, proceeding codes with 1/2/3 segments).
  • TestBuildProjectCodeFullChain — fixture tree Client → Litigation → Patent → Case yields EXMPL.OPNT.567.INF.CFI.
  • TestBuildProjectCodeRespectsOverride — non-empty reference wins outright.
  • TestBuildProjectCodeMissingAncestors — case directly under client (no litigation, no patent) yields EXMPL.INF.CFI.
  • TestBuildProjectCodeCollisionDoesNotDisambiguate — two sibling cases with identical derived codes both return the same string (v1 contract per Q6).
  • Migration sanity test (existing harness in internal/db/migrations_test.go if present) — up → down → up.

§3.7 Acceptance (issue #50)

  • BuildProjectCode returns EXMPL.OPNT.567.INF.CFI for the reference tree (Client EXMPL → Litigation OPNT → Patent EP1234567 → Case upc.inf.cfi).
  • Setting projects.reference = 'CUSTOM-CODE' on the case returns CUSTOM-CODE verbatim.
  • Missing ancestor segments are skipped silently (no .. collapses, no "?" placeholder).
  • {{project.code}} resolves in submission templates.
  • Project header, breadcrumb, picker, Excel __meta all show the code when set/derived.
  • Litigation form has a new "Opponent Code" field (DE: "Gegner-Kürzel") with the slug pattern validation. Hidden on non-litigation types.
  • go build && go test ./internal/... && cd frontend && bun run build clean.

§4 Open questions for the head

(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something material pushes back. Coder shift only after head signs off.)

  1. §2.2 Q1 — Keep column name our_side? (Recommend YES; rename touches 11+ Go files + bundled-template wire format for zero gain.)
  2. §2.2 Q2 — Store 7 sub-roles? (Recommend YES; group-only is lossy.)
  3. §2.2 Q3 — Hide the field on litigation and patent too, not just on client? (Recommend YES per m's "only on case projects".)
  4. §2.2 Q6 — German prose forms use feminine grammatical gender (Klägerin, Beklagte) per the existing translation table? Or masculine / neutral? (Recommend feminine to match existing ourSideDE — keeps consistency with already-rendered templates.)
  5. §3.2 Q1 — Add a dedicated opponent_code column on litigations? (Recommend YES; regex-on-title is brittle.)
  6. §3.2 Q2 — Patent segment = last 3 digits (variable for <4-digit numbers)? (Recommend YES, matches m's example.)
  7. §3.2 Q3 — Case segment drops the jurisdiction prefix from proceeding_types.code (so upc.inf.cfiINF.CFI, not UPC.INF.CFI)? (Recommend YES — jurisdiction is implied by the ancestor client/patent context.)
  8. §3.2 Q7BuildProjectCode populates a code field on every projected Project JSON (not lazy per-render)? (Recommend YES; simpler consumers, one DB round-trip per list page.)
  9. §3.2 Q8 — No cache / materialised view in v1? (Recommend YES; profile later if list views get slow.)

§5 Implementation order (coder phase)

  1. Mig 112 (client role widen + backfill) → mig 113 (opponent_code). Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump. Run ls internal/db/migrations/ | tail first to verify slot availability (boltzmann's gap-tolerant runner means 110 is fine even if 109 was the last applied).
  2. BackendisValidOurSide, ourSideDE/EN, derivedCounterclaimOurSide, new project_code.go package
    • ProjectService wiring + projection Code field.
  3. FrontendProjectFormFields.tsx (conditional render + new options + opponent_code field on litigation block), i18n.ts keys, fristenrechner.ts ourSideToPerspective widen, header / breadcrumb / picker code-badge wiring.
  4. Tests — pinning tests above; go test ./internal/... clean.
  5. Build verificationgo build && cd frontend && bun run build clean.
  6. Commit per slice — three commits (migration + backend, frontend, tests) keep review tractable.

§6 Risks & rollback

  • Submission templates in the wild. Users may have downloaded / customised submission templates that still reference {{project.our_side_de}} for our_side='court' or 'both'. After this change those values are unreachable, so the template arm returns "". Already the fallback behaviour for unknown values; no breakage, just an empty render. Mention in release notes.
  • Browser cache. Users with a stale bundle still see the old "Wir vertreten" form for one cache-bust cycle. The legacy i18n keys stay until housekeeping (§2.4), so labels still resolve.
  • Migration down path. Stepping down from 110 restores the old 4-value CHECK; new sub-role rows would violate it. The down migration backfills new sub-roles → NULL to stay consistent.
  • Per-tree opponent_code uniqueness. Two litigations under the same client with the same opponent_code would derive identical case codes. Per Q6 we accept this; users see it in the picker and customise reference if it bothers them.
  • No new env vars, no Dokploy compose change — both changes are pure code + schema; deploy is the existing main-push → webhook → Dokploy auto-redeploy path.