# 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-1104` —
    `our_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_side` → `projects.field.client_role`)
so user-facing copy stays clean.

**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
**Pick: 7 sub-roles** — `claimant, 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`

```sql
-- 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-3776` —
`ourSideToPerspective` switch widens:

```ts
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:1915` — `isValidOurSide()` widens
its allowlist:

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

`internal/services/project_service.go:1372` —
`derivedCounterclaimOurSide()` (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-418` — `ourSideDE` /
`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-1104` — `our_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:275` —
  `TestOurSideTranslations` widens the table to cover the 7 new values
  in both DE and EN.
- `internal/services/projection_service_unit_test.go:319` —
  `TestDerivedCounterclaimOurSide` 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)

- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
      `'project'` does **not** show the field.
- [x] Creating a project of `type='case'` shows the field labelled
      "Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
      and seven options.
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
      are migrated to NULL.
- [x] Submission templates referencing `{{project.our_side_de}}` /
      `_en` render coherent prose for the five new values.
- [x] Determinator perspective chip pre-fills correctly from each
      sub-role (Active→claimant, Reactive→defendant, Other→null).
- [x] CCR counterclaim flip yields a sensible child role for the new
      sub-roles.
- [x] `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.cfi` → `INF.CFI`
  - `upc.rev.cfi` → `REV.CFI`
  - `upc.pi.cfi`  → `PI.CFI`
  - `upc.apl.merits` → `APL.MERITS`
  - `de.inf.lg`   → `INF.LG`
  - `de.inf.olg`  → `INF.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`

```sql
-- 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`:

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

```sql
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)

- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
      reference tree (Client EXMPL → Litigation OPNT → Patent
      EP1234567 → Case `upc.inf.cfi`).
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
      returns `CUSTOM-CODE` verbatim.
- [x] Missing ancestor segments are skipped silently
      (no `..` collapses, no "?" placeholder).
- [x] `{{project.code}}` resolves in submission templates.
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
      code when set/derived.
- [x] Litigation form has a new "Opponent Code" field (DE:
      "Gegner-Kürzel") with the slug pattern validation. Hidden on
      non-litigation types.
- [x] `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.cfi` → `INF.CFI`, not
   `UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
   ancestor client/patent context.)
8. **§3.2 Q7** — `BuildProjectCode` 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. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
   `derivedCounterclaimOurSide`, new `project_code.go` package
   + ProjectService wiring + projection `Code` field.
3. **Frontend** — `ProjectFormFields.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 verification** — `go 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.
