# Approval-policy authoring UI — design

**Task:** t-paliad-154
**Issue:** m/paliad#13
**Inventor:** hilbert (2026-05-07)
**Branch:** mai/hilbert/inventor-approval-policy
**Status:** READY FOR REVIEW

---

## §0 — One-paragraph summary

cronus shipped the t-138 4-eye backend on 2026-05-06: tables, service layer,
HTTP API, audit events, the `/inbox` shell. The whole thing has been **dormant
in production since** because `paliad.approval_policies` has zero rows, and no
UI exists to author policies. m hit this hard 2026-05-07 22:55 — created a
deadline expecting a request on `/approvals`, got nothing. This design fills
the gap with **two coordinated changes**: (a) a backend extension to support
**per-partner-unit defaults** layered with **project-tree inheritance**, both
resolved most-restrictive, with an explicit `'none'` sentinel for project-level
opt-out; (b) a single new admin page `/admin/approval-policies` with a
project-picker → 8-cell matrix and a partner-unit defaults section, plus
in-context hints on the deadline/appointment forms when 4-eye applies. v1
ships seeded conservative defaults for every existing partner unit so the gate
starts working on next deploy without per-project authoring.

---

## §1 — What's already built (verified live, 2026-05-07)

cronus's t-138 implementation is complete and merged. Verified premises:

- **Schema (migration 054, applied):** `paliad.approval_policies` with
  `(id, project_id, entity_type, lifecycle_event, required_role, created_at,
  updated_at, created_by)` + UNIQUE composite on `(project_id, entity_type,
  lifecycle_event)`. RLS enforces SELECT via `can_see_project(project_id)`,
  WRITE via `global_role='global_admin'`. Read-only check on the live DB
  via the migration file at `internal/db/migrations/054_approvals.up.sql:75`.
- **Required-role enum (post-059):** `partner | of_counsel | associate |
  senior_pa | pa`. The `'lead' → 'partner'` rename happened in migration 059
  (t-148, kepler) — verified at `internal/db/migrations/059_profession_vs_responsibility.up.sql:166-172`.
  Mirrors `paliad.users.profession` (firm-wide career tier), not
  `paliad.project_teams.responsibility` (project-level role) — the gate keys
  on profession because that's how the strict ladder
  `paliad.approval_role_level()` works.
- **HTTP API (admin-gated):** three handlers in
  `internal/handlers/approvals.go` register at `internal/handlers/handlers.go:421-426`:
  - `GET /api/projects/{id}/approval-policies` → list
  - `PUT /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → upsert
  - `DELETE /api/projects/{id}/approval-policies/{entity_type}/{lifecycle}` → clear

  All three wrapped with `auth.RequireAdminFunc(users, ...)`.
- **`LookupPolicy`** (`internal/services/approval_service.go:69-83`) does
  **not walk the project tree** today. It SELECTs the exact
  `(project_id, entity_type, lifecycle_event)` tuple and returns the row or
  nil. Tree inheritance is brand-new in this design.
- **Audit:** approval-request submission and decisions emit
  `paliad.project_events` rows; **policy CRUD does not**. Verified at
  `internal/services/approval_service.go:255` (request emits) — no
  `insertProjectEvent` call inside `UpsertPolicy`/`DeletePolicy` at lines
  913-948.
- **Partner-unit substrate (t-139, migration 055, applied):**
  - `paliad.partner_units (id, name, lead_user_id, office, ...)` — verified
    at `internal/services/partner_unit_service.go:29`.
  - `paliad.project_partner_units (project_id, partner_unit_id,
    derive_grants_authority, derive_unit_roles)` — verified at
    `internal/db/migrations/055_hierarchy_aggregation.up.sql:47`.
- **Admin index pattern:** `/admin` is a card-grid of single-purpose admin
  sub-pages — team, partner-units, audit-log, email-templates, event-types,
  broadcasts. Verified at `frontend/src/admin.tsx:60-91`. New approval-policy
  card slots into the same grid.
- **Migration tracker:** last applied is **061**
  (`paliad.user_card_layouts`). Next is **062** — this design's migration.

---

## §2 — m's locked decisions (2026-05-07 23:00)

12 questions surfaced via AskUserQuestion (per dogma, not as a markdown
list). Locked verbatim — quoted as-asked + answer:

### Q1 — Surface placement

> Where should approval policies be authored? The backend admin-gates the
> CRUD endpoints, so anywhere we surface authoring is admin-only by
> definition.

**Locked: Admin page only.** New `/admin/approval-policies` card on the
admin index. Single page with two sections: (a) Partner-unit defaults,
(b) Project picker → 8-cell matrix. Per-project tab is **out**. Project
visibility into effective rules happens at form-time (Q12 below), not as a
permanent tab.

### Q2 — Default-policy concept

> With ~30 projects and 8 cells each, authoring is tedious. Should we add
> firm-wide defaults that individual projects override?

**Locked: Per partner-unit defaults.** Schema gets a nullable
`partner_unit_id`, project_id becomes nullable, XOR check enforces a row
applies to one or the other. Reuses the t-139 partner-unit infra. No
firm-wide defaults — one less concept.

### Q3 — Multi-unit conflict resolution

> A project attached to multiple partner units with conflicting unit
> defaults — e.g. Munich Lit unit defaults to deadline:create=partner,
> Düsseldorf to deadline:create=associate. What does the gate require?

**Locked: Most-restrictive wins.** Take MAX(`approval_role_level`) across
all unit defaults for the project. Conservative — 4-eye exists to prevent
quiet errors, the higher bar wins.

### Q4 — Tree inheritance

> Projects also live in a tree. Should an ancestor project's policy inherit
> DOWN the project tree to descendants when they have no own row, or only
> via partner-unit defaults?

**Locked: Both — tree inheritance AND unit defaults.** Three sources
contribute to the candidate set: project-specific rows, ancestor rows,
unit defaults.

### Q5 — Cross-source precedence

> When tree-inheritance and unit-defaults both produce a candidate, which
> wins?

**Locked: Most-restrictive across ALL sources.** Project-specific row
overrides outright (any value, including `'none'`). When no project row,
take MAX(level) across all ancestor rows + all unit defaults. Symmetric
with the multi-unit rule.

### Q6 — Explicit suppression sentinel

> A project-specific row always wins. To set 'this project explicitly
> bypasses 4-eye on deadline:create' overriding a partner-unit default of
> 'partner', we need a sentinel.

**Locked: `'none'` value in `required_role` enum.** Add `'none'` to the
CHECK constraint. Cell renders as "Keine Genehmigung erforderlich". Project
row with `required_role='none'` returns nil from `LookupPolicy` —
suppresses defaults explicitly. Single column, single concept.

### Q7 — Soft-disable vs delete

> Per-policy enable/disable toggle vs delete-only. With audit-log emission
> already locked in (Q8), do we still need soft-disable?

**Locked: Delete-only.** One row = one rule. "This rule used to apply" is
answered by the audit log. KISS.

### Q8 — Audit emission

> Should policy changes emit project_events?

**Locked: Only on `/admin/audit-log`, not on per-project `/verlauf`.**
New event types `approval_policy_set` and `approval_policy_cleared`
emitted via the existing audit-log path (not via the project-events
union). Project verlauf stays focused on entity-level history.

### Q9 — Empty-state on /inbox

> When admin opens /inbox and pending list is empty AND no policies exist,
> show a one-tap nudge?

**Locked: Yes — admin-only card.** Conditional on `me.global_role ===
'global_admin' && pending.length === 0 && !any_policies_exist`. Card links
to `/admin/approval-policies`. Solves the discoverability gap m hit.

### Q10 — Bulk-apply

> Bulk action on the admin page so an admin can fan a Mandant's matrix
> down to its 12 sub-projects without 96 clicks?

**Locked: Yes — "Auf Unterprojekte anwenden" button per project row.**
Click → confirm modal listing affected descendants → applies the source
project's full matrix to all descendants. Idempotent.

### Q11 — Seed defaults on first deploy

> Should v1 ship seeded defaults, or strictly opt-in?

**Locked: Seed conservative defaults for every partner_unit.** Migration
inserts 8 rows per existing partner_unit:

| entity | lifecycle | required_role |
| :--- | :--- | :--- |
| deadline | create | associate |
| deadline | update | associate |
| deadline | delete | associate |
| deadline | complete | none |
| appointment | create | associate |
| appointment | update | associate |
| appointment | delete | associate |
| appointment | complete | none |

Rationale: marking-as-done is low-risk; the planning ops (create/edit/delete
the date itself) need 4-eye. `none` on `complete` is an explicit "no gate"
sentinel, not a missing row — so MAX-across-sources still works correctly.

### Q12 — Mobile shape

> 8-cell matrix is too wide for narrow viewports.

**Locked: Two stacked sections — Fristen, Termine, each as 4-row list.**
On viewports ≥ 700px: 2-row × 4-col matrix. On viewports < 700px: vertical
section per entity_type with full-width dropdown rows.

### Q13 — Form-time hint visibility

> Should we surface 4-eye to users authoring deadlines, before they save?

**Locked: Yes — hint on the deadline-form.** Above the Speichern button on
`/projects/{id}/deadlines/new` and `/projects/{id}/appointments/new`,
render: "4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
Genehmigungsantrag (associate-Level) ausgelöst." Pulled from new
`GET /api/projects/{id}/approval-policies/effective` endpoint at form load.

---

## §3 — Backend extensions

### §3.1 — Migration 062

`internal/db/migrations/062_approval_policy_unit_defaults.up.sql`:

```sql
-- t-paliad-154: approval-policy authoring UI substrate.
--
-- Extends t-138's paliad.approval_policies with:
--   1. partner_unit_id column for unit-default rows (XOR with project_id)
--   2. 'none' sentinel value for required_role (explicit suppression)
--   3. paliad.approval_policy_effective() resolver — tree + unit + most-restrictive
--   4. Conservative seed defaults for every existing partner_unit

-- 1. partner_unit_id column + nullable project_id + XOR check.
ALTER TABLE paliad.approval_policies
    ALTER COLUMN project_id DROP NOT NULL,
    ADD COLUMN partner_unit_id uuid
        REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
    ADD CONSTRAINT approval_policies_scope_xor CHECK (
        (project_id IS NOT NULL AND partner_unit_id IS NULL) OR
        (project_id IS NULL AND partner_unit_id IS NOT NULL)
    );

-- Replace UNIQUE (project_id, ...) with two partial unique indexes since
-- project_id is now nullable.
ALTER TABLE paliad.approval_policies
    DROP CONSTRAINT IF EXISTS approval_policies_project_id_entity_type_lifecycle_event_key;

CREATE UNIQUE INDEX approval_policies_project_unique
    ON paliad.approval_policies (project_id, entity_type, lifecycle_event)
    WHERE project_id IS NOT NULL;

CREATE UNIQUE INDEX approval_policies_unit_unique
    ON paliad.approval_policies (partner_unit_id, entity_type, lifecycle_event)
    WHERE partner_unit_id IS NOT NULL;

CREATE INDEX approval_policies_unit_idx
    ON paliad.approval_policies (partner_unit_id);

-- 2. 'none' sentinel.
ALTER TABLE paliad.approval_policies
    DROP CONSTRAINT IF EXISTS approval_policies_required_role_check;
ALTER TABLE paliad.approval_policies
    ADD CONSTRAINT approval_policies_required_role_check
    CHECK (required_role IN (
        'partner', 'of_counsel', 'associate', 'senior_pa', 'pa', 'none'
    ));

-- approval_role_level('none') already returns 0 (the ELSE branch). No
-- function change needed.

-- 3. Resolver function.
--
-- Returns the effective policy for (project, entity_type, lifecycle):
--   1. project-specific row → wins outright (any value including 'none')
--   2. else MAX(approval_role_level) across:
--        - all ancestor project rows on the path
--        - all unit-default rows for partner units attached to project
--   3. else NULL (no candidates) → no policy applies
--
-- Returns at most one row. Caller can detect "no policy" via empty result.
CREATE OR REPLACE FUNCTION paliad.approval_policy_effective(
    p_project_id  uuid,
    p_entity_type text,
    p_lifecycle   text
) RETURNS TABLE (
    required_role text,
    source        text,         -- 'project' | 'ancestor' | 'unit_default'
    source_id     uuid          -- project_id for project/ancestor, partner_unit_id for unit_default
)
LANGUAGE plpgsql STABLE AS $$
BEGIN
    -- Step 1: project-specific row.
    RETURN QUERY
    SELECT ap.required_role, 'project'::text, ap.project_id
      FROM paliad.approval_policies ap
     WHERE ap.project_id = p_project_id
       AND ap.entity_type = p_entity_type
       AND ap.lifecycle_event = p_lifecycle;
    IF FOUND THEN
        RETURN;
    END IF;

    -- Step 2: MAX across ancestor + unit_default.
    RETURN QUERY
    WITH path AS (
        SELECT string_to_array(p.path, '.')::uuid[] AS ids
          FROM paliad.projects p WHERE p.id = p_project_id
    ),
    ancestor_rows AS (
        SELECT ap.required_role,
               'ancestor'::text AS src,
               ap.project_id AS sid,
               paliad.approval_role_level(ap.required_role) AS lvl
          FROM paliad.approval_policies ap, path
         WHERE ap.project_id = ANY(path.ids)
           AND ap.project_id <> p_project_id
           AND ap.entity_type = p_entity_type
           AND ap.lifecycle_event = p_lifecycle
    ),
    unit_rows AS (
        SELECT ap.required_role,
               'unit_default'::text AS src,
               ap.partner_unit_id AS sid,
               paliad.approval_role_level(ap.required_role) AS lvl
          FROM paliad.approval_policies ap
          JOIN paliad.project_partner_units ppu
            ON ppu.partner_unit_id = ap.partner_unit_id
         WHERE ppu.project_id = p_project_id
           AND ap.entity_type = p_entity_type
           AND ap.lifecycle_event = p_lifecycle
    )
    SELECT a.required_role, a.src, a.sid
      FROM (SELECT * FROM ancestor_rows
            UNION ALL
            SELECT * FROM unit_rows) a
     ORDER BY a.lvl DESC, a.src ASC  -- 'ancestor' < 'unit_default' alphabetically; ancestor wins ties for stable attribution
     LIMIT 1;
END;
$$;

COMMENT ON FUNCTION paliad.approval_policy_effective(uuid, text, text) IS
    'Effective approval policy resolver (t-paliad-154). '
    'project-specific row wins outright; else MAX(level) across ancestors '
    'and unit-defaults attached to project; else no policy.';

-- 4. Seed conservative defaults for every existing partner_unit.
INSERT INTO paliad.approval_policies (
    project_id, partner_unit_id, entity_type, lifecycle_event, required_role
)
SELECT NULL, pu.id, t.entity_type, t.lifecycle_event, t.required_role
  FROM paliad.partner_units pu
  CROSS JOIN (
    VALUES
        ('deadline',    'create',   'associate'),
        ('deadline',    'update',   'associate'),
        ('deadline',    'delete',   'associate'),
        ('deadline',    'complete', 'none'),
        ('appointment', 'create',   'associate'),
        ('appointment', 'update',   'associate'),
        ('appointment', 'delete',   'associate'),
        ('appointment', 'complete', 'none')
  ) AS t(entity_type, lifecycle_event, required_role)
ON CONFLICT DO NOTHING;
```

`062_approval_policy_unit_defaults.down.sql` reverses each step
(deletes seeded rows, drops the function, drops indexes, drops the
column + constraint, restores the original UNIQUE + CHECK).

### §3.2 — Service-layer changes

`internal/services/approval_service.go` changes (additive — existing
callers keep working):

- **Rewire `LookupPolicy`** to call the resolver. New body:
  ```go
  func (s *ApprovalService) LookupPolicy(ctx, tx, projectID, entityType, lifecycleEvent) (*models.ApprovalPolicy, error) {
      var row struct {
          RequiredRole string    `db:"required_role"`
          Source       string    `db:"source"`
          SourceID     uuid.UUID `db:"source_id"`
      }
      q := `SELECT required_role, source, source_id
              FROM paliad.approval_policy_effective($1, $2, $3)`
      err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent)
      if errors.Is(err, sql.ErrNoRows) || row.RequiredRole == "none" {
          return nil, nil  // no policy applies
      }
      if err != nil { return nil, fmt.Errorf("lookup approval policy: %w", err) }
      // Synthetic ApprovalPolicy — preserves the calling contract.
      return &models.ApprovalPolicy{
          ProjectID:      projectID,
          EntityType:     entityType,
          LifecycleEvent: lifecycleEvent,
          RequiredRole:   row.RequiredRole,
      }, nil
  }
  ```
  The submit/decide chain at lines 142-380 continues to work unchanged.
  `'none'` returning nil means: project explicitly opted out, no request
  is created on save.

- **New `GetEffectivePoliciesMatrix(ctx, projectID)`** returns 8 rows
  (one per `entity_type × lifecycle_event`), each with attribution. Used
  by the admin page and the form-hint endpoint.
  ```go
  type EffectivePolicy struct {
      EntityType     string
      LifecycleEvent string
      RequiredRole   *string  // nil if no policy
      Source         *string  // nil if no policy
      SourceID       *uuid.UUID
  }
  func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx, projectID) ([]EffectivePolicy, error)
  ```
  Implementation: 8 calls to the resolver in a single round-trip via
  `unnest()` join, or a small batch loop — both fine for ≤8 cells.

- **Extend `UpsertPolicy` signature** to accept `partnerUnitID *uuid.UUID`
  alongside `projectID *uuid.UUID`. Existing callers pass projectID + nil.
  New callers (unit-default endpoints) pass nil + unit ID.
  ```go
  func (s *ApprovalService) UpsertPolicy(ctx, callerID,
      projectID, partnerUnitID *uuid.UUID,
      entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error)
  ```
  Same for `DeletePolicy`. Validates exactly one of (projectID, partnerUnitID)
  is set.

- **New `ApplyMatrixToDescendants(ctx, callerID, sourceProjectID,
  targetIDs []uuid.UUID)`**: copies all eight rows of `sourceProjectID`'s
  effective matrix to each `targetIDs[i]` as project-specific rows. Inside
  one transaction. Validates `targetIDs` are actual descendants via the
  ltree path predicate. Returns the count of (project, cell) writes
  performed. Skips cells where source is `'none'` and target already has
  no row (idempotent). Emits one audit-log event per write.

- **Audit emission** in `UpsertPolicy` + `DeletePolicy` + `ApplyMatrixToDescendants`:
  call existing `AuditService.Record` (the same path `/admin/audit-log`
  uses). New event type strings: `approval_policy_set`, `approval_policy_cleared`.
  Metadata: scope (project|partner_unit), scope_id, entity_type, lifecycle,
  old_required_role (for set), new_required_role (for set). The audit
  service already handles JSON metadata; no schema change.

  **No project_events emission** (per Q8 lock-in). Project verlauf stays
  focused on entity-level lifecycle.

### §3.3 — HTTP handlers

`internal/handlers/approvals.go` extensions:

- **Existing routes stay** at `handlers.go:421-426` (gated by
  `RequireAdminFunc`).

- **New unit-default routes** (also `RequireAdminFunc`-gated, registered
  in the same admin block at handlers.go:386-427):
  - `GET    /api/admin/partner-units/{unit_id}/approval-policies` — list
    all rows for that unit.
  - `PUT    /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — upsert.
  - `DELETE /api/admin/partner-units/{unit_id}/approval-policies/{entity_type}/{lifecycle}` — clear.
  - `GET    /api/admin/approval-policies/seeded` — quick existence check
    used by the `/inbox` admin nudge ("are any policies set firm-wide?").

- **New endpoint for matrix view** (admin page):
  - `GET /api/admin/approval-policies/matrix?project_id=...` — returns
    `[]EffectivePolicy` (8 rows with attribution).

- **New endpoint for form hint** (gateOnboarded, NOT admin-only — every
  user authoring a deadline needs to see this):
  - `GET /api/projects/{id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
    — returns one `EffectivePolicy` row.

- **New endpoint for bulk apply**:
  - `POST /api/admin/approval-policies/apply-to-descendants` — body
    `{source_project_id: uuid, target_project_ids: [uuid, ...]}`. Validates,
    applies, returns counts.

- **New endpoint for project tree** (admin page picker — already exists
  in part):
  - `GET /api/admin/projects/tree-flat` — flat array of all projects with
    `id, name, parent_id, depth, path` for the picker. Reuses
    `ProjectService.ListAllForAdmin` (already present at
    `internal/services/project_service.go` — admin-scoped tree).

- **New page handler**:
  - `GET /admin/approval-policies` → `dist/admin-approval-policies.html`
    (server-static shell, hydrated on load).

---

## §4 — Frontend

### §4.1 — Admin page `/admin/approval-policies`

New files:
- `frontend/src/admin-approval-policies.tsx` — page shell. Sections:
  1. Header: "Genehmigungsrichtlinien" + tool-subtitle.
  2. **"Partner-Unit-Standards"** — accordion list of partner units
     (fetched from `/api/partner-units`). Each row expandable into the
     8-cell matrix (Fristen × 4 lifecycle, Termine × 4 lifecycle), each
     cell a `<select>` with options `partner | of_counsel | associate |
     senior_pa | pa | none | ❌ keine Regel` (last = delete the row).
  3. **"Projekt-spezifisch"** — project picker (search + flat tree dropdown
     reusing `ProjectIndentRow` component from t-149). Below, the same
     8-cell matrix for the selected project, each cell showing the
     **effective** value with a small attribution chip:
     `Projekt` (own row, dark) / `Geerbt von Mandant Acme Corp` (light,
     italic) / `Standard von Partner Unit Munich Lit` (light, italic) /
     `Keine Regel` (faint).
  4. **"Auf Unterprojekte anwenden"** button per project row, opens
     confirm modal with descendant list.

- `frontend/src/client/admin-approval-policies.ts` — orchestration.
  Fetches partner-units, project tree, matrix on selection. Saves on
  cell change (`PUT` with required_role; `DELETE` when set to "keine
  Regel"). Re-fetches matrix after save for fresh effective view.
  Bulk-apply confirm modal + POST.

### §4.2 — Admin index card

`frontend/src/admin.tsx`: add a new card to the available section:

```tsx
<a href="/admin/approval-policies" className="card card-link">
  <div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
  <h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
  <p data-i18n="admin.card.approval_policies.desc">4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.</p>
</a>
```

`ICON_SHIELD` (new SVG) — small shield icon, matches the visual weight of
ICON_USERS / ICON_BUILDING.

### §4.3 — `/inbox` empty-state nudge

`frontend/src/inbox.tsx`: extend the `<div className="entity-empty"
id="inbox-empty">` block with a hidden admin-only sub-block:

```tsx
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
  <h3 data-i18n="inbox.empty.admin.title">Noch keine Richtlinien aktiv?</h3>
  <p data-i18n="inbox.empty.admin.body">Konfiguriere, welche Lifecycle-Events 4-Augen-Prüfung erfordern.</p>
  <a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin.cta">
    Genehmigungspflichten konfigurieren
  </a>
</div>
```

`frontend/src/client/inbox.ts`: when rendering empty state, fire
`/api/admin/approval-policies/seeded`. If response says `{any: false}` AND
user is `global_admin`, reveal the nudge. Otherwise hide.

### §4.4 — Form-time hint on deadline + appointment new/edit

`frontend/src/deadlines-new.tsx` + `frontend/src/appointments-new.tsx`
(also the edit forms): add a hint container above the form-actions:

```tsx
<div className="approval-hint" id="approval-hint" style="display:none">
  <span className="approval-hint-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD_SMALL }} />
  <span id="approval-hint-text" />
</div>
```

Client TS: on form load, GET
`/api/projects/{project_id}/approval-policies/effective?entity_type=deadline&lifecycle=create`
(or `update` for edit). If result is non-null and `required_role !== 'none'`,
fill the hint:
> 4-Augen-Prüfung erforderlich: nach dem Speichern wird ein
> Genehmigungsantrag (associate-Level) ausgelöst. Geerbt von Partner Unit
> Munich Lit.

Same for appointments.

### §4.5 — Mobile shape

CSS in `frontend/src/styles/global.css`:

```css
/* Desktop: 2-row × 4-col matrix */
.approval-matrix {
    display: grid;
    grid-template-columns: 8rem repeat(4, 1fr);
    gap: 0.5rem;
}

@media (max-width: 700px) {
    .approval-matrix { display: block; }
    .approval-matrix-section {
        margin-bottom: 1.5rem;
    }
    .approval-matrix-section h3 {
        margin: 0 0 0.5rem 0;
        font-size: 1.05rem;
    }
    .approval-matrix-row {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 0.5rem 0;
        border-bottom: 1px solid var(--paliad-border-soft);
    }
    .approval-matrix-row select { width: 50%; }
}
```

The TSX renders BOTH structures (matrix grid + section list); CSS toggles
based on viewport. Same pattern as the entity-table → entity-list mobile
flip in `frontend/src/client/projects-detail.ts`.

### §4.6 — i18n keys

~75 new keys in `frontend/src/client/i18n.ts` (DE primary, EN secondary).
Major buckets:

- `admin.card.approval_policies.title` / `.desc`
- `approvals.policy.heading` / `.subtitle` / `.empty`
- `approvals.policy.section.units` / `.projects`
- `approvals.policy.entity.deadline` / `.appointment`
- `approvals.policy.lifecycle.create` / `.update` / `.complete` / `.delete`
- `approvals.policy.required.partner` / `.of_counsel` / `.associate` / `.senior_pa` / `.pa` / `.none` / `.no_rule`
- `approvals.policy.source.project` / `.ancestor` / `.unit_default`
- `approvals.policy.bulk.cta` / `.modal.title` / `.modal.confirm` / `.modal.cancel` / `.modal.target_count` / `.modal.affected_list`
- `approvals.policy.unit_picker.placeholder` / `.project_picker.placeholder`
- `approvals.policy.cell.save_msg` / `.delete_msg` / `.error_msg`
- `inbox.empty.admin.title` / `.body` / `.cta`
- `deadlines.form.approval_hint.create` / `.update`
- `appointments.form.approval_hint.create` / `.update`
- `approvals.policy.audit.set` / `.cleared` (for `/admin/audit-log` rendering)

---

## §5 — Resolution semantics (worked examples)

Helps the implementer + reviewers reason about edge cases.

### Example A — straight unit default

**Setup:** Project P attached to one partner unit U. U has unit-default
`deadline:create=associate`. P has no own row, no ancestor with a row.

**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: ancestor_rows = ∅. unit_rows = [{associate, level=3}]. MAX = associate.
- Result: `(required_role='associate', source='unit_default', source_id=U.id)`.

LookupPolicy returns `&ApprovalPolicy{RequiredRole: "associate", ...}`.
SubmitCreate creates a pending request needing associate sign-off.

### Example B — most-restrictive across two unit defaults

**Setup:** Project P attached to U1 (deadline:create=partner) and U2
(deadline:create=associate). No project row, no ancestor row.

**Effective for P, deadline:create:**
- Step 1: no project row.
- Step 2: unit_rows = [{partner, lvl=5}, {associate, lvl=3}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U1.id)`.

### Example C — most-restrictive across tree + unit

**Setup:** Project hierarchy: Mandant M (deadline:create=of_counsel) → Litigation L → Patent P. P attached to unit U (deadline:create=partner).

**Effective for P, deadline:create:**
- Step 1: no row on P.
- Step 2: ancestor_rows = [{of_counsel, lvl=4 (from M)}]. unit_rows = [{partner, lvl=5}]. MAX = partner.
- Result: `(required_role='partner', source='unit_default', source_id=U.id)`.

### Example D — explicit suppression at project level

**Setup:** Same as Example C, but admin sets P's own row to
`required_role='none'` (carve-out for this single Patent — e.g. a low-stakes
auxiliary case).

**Effective for P, deadline:create:**
- Step 1: project row exists with `required_role='none'`. RETURN.
- Result: `(required_role='none', source='project', source_id=P.id)`.

LookupPolicy returns nil (the `'none'` short-circuit). SubmitCreate skips.

### Example E — most-restrictive incl. ancestor

**Setup:** Mandant M (deadline:create=partner). Litigation L below M, no
own row, attached to unit U (deadline:create=pa).

**Effective for L, deadline:create:**
- Step 1: no row on L.
- Step 2: ancestor_rows = [{partner, lvl=5}]. unit_rows = [{pa, lvl=1}]. MAX = partner.
- Result: `(required_role='partner', source='ancestor', source_id=M.id)`.

The Mandant-level rule cascades down — the typical "set once at the
client root" pattern.

---

## §6 — Implementation phasing

Single PR (~3500-4500 LoC). Five commits, ordered for readability:

1. **Migration 062 + resolver function + seed.** No Go code change.
   Schema is forward-compatible: existing `LookupPolicy` (still scanning
   the table directly) keeps working until commit 2 swaps it. Verify
   migration with TEST_DATABASE_URL + reset.

2. **ApprovalService rewire.** New `LookupPolicy` body via resolver, new
   `GetEffectivePoliciesMatrix`, extended `UpsertPolicy`/`DeletePolicy`
   signatures, new `ApplyMatrixToDescendants`, audit emission. Unit
   tests (table-driven): resolver fall-through cases A-E above; bulk-apply
   idempotency; `'none'` short-circuit; XOR check.

3. **HTTP handlers.** Wire new admin routes + form-hint endpoint +
   matrix endpoint. Hand-roll `models.ApprovalPolicy` extensions
   (PartnerUnitID, Source, SourceID nullable fields). Update existing
   `handleListApprovalPolicies` to return matrix shape (with attribution)
   instead of raw rows.

4. **Frontend admin page.** `admin-approval-policies.tsx` + `.ts`. Cells
   render with attribution chips. Bulk-apply confirm modal. Build wires
   the new bundle into `frontend/build.ts`. CSS for the matrix grid +
   mobile sections.

5. **Frontend touch-ups + i18n.** Admin index card. Inbox empty-state
   admin nudge. Deadline/appointment form hints (`/api/projects/{id}/approval-policies/effective`
   call + hint render). ~75 i18n keys DE+EN. CSS finalization.

Optional split point: 1+2+3 (backend + schema, "policies authoring works
via curl") and 4+5 (UI). Recommended single PR — 4+5 are the part that
makes the feature reachable to m, and shipping backend-only re-exposes
the issue m hit.

---

## §7 — Tests

**Backend (Go, table-driven):**
- `approval_service_test.go` extensions for the resolver:
  - Project row only → returns project row.
  - Project row 'none' → returns nil from LookupPolicy.
  - Two unit defaults → most-restrictive.
  - Ancestor row + unit default → most-restrictive across both.
  - Project row + ancestor + unit defaults → project row wins.
  - No candidates → returns nil.
  - 'none' as unit-default value (low-priority — unusual but allowed) →
    loses to any non-none.
- `ApplyMatrixToDescendants` tests:
  - Source has 8 cells → target gets 8 cells.
  - Source has 5 cells (3 cleared) → target gets 5 cells; existing target
    rows for the other 3 are deleted (idempotent fanout, not append).
  - Target is not actually a descendant → returns ErrInvalidInput.
  - Self-target (target == source) → no-op.
- `UpsertPolicy` XOR validation: both NULL → ErrInvalidInput; both set →
  ErrInvalidInput.
- Audit emission: each set/clear writes one `paliad.audit_log` row with
  the right event type + scope.

**Live-DB integration tests (TEST_DATABASE_URL):**
- Migration 062 up + seed populates 8 rows × N partner_units. Down
  reverses. Idempotent on re-up.
- Resolver function returns expected attribution for the 5 worked
  examples above.

**Frontend:**
- `admin-approval-policies` smoke tests (Playwright): load page, select
  partner unit, change a cell, verify save → DB. Select project, verify
  attribution chips. Bulk-apply happy path.
- Form-hint on `/projects/{id}/deadlines/new` shows when policy applies,
  hides when it doesn't.

---

## §8 — Trade-offs flagged

1. **Seed defaults touch live data on first deploy.** Every existing
   partner_unit gains 8 policy rows. m's locked-in choice (Q11) — but
   worth flagging that the moment migration 062 runs in production, the
   4-eye gate becomes active for every project attached to a partner
   unit. Mitigation: deploy after announcing to the team. Conservative
   `associate` baseline means most users (associate, of_counsel, partner)
   can both submit AND approve, so the operational impact is "your save
   creates a pending request that any teammate can sign off in /inbox"
   rather than "your save is blocked". The bell-icon + sidebar badge
   from t-138 surfaces it.

2. **Seed `'none'` on `complete` is structurally invisible.** A
   unit-default of `'none'` always loses MAX to any non-none source
   (level 0 vs ≥1). So the seed `appointment.complete=none` rows are
   essentially "no rule" — they don't appear in `LookupPolicy` results.
   We seed them anyway for **UI consistency**: when an admin opens the
   matrix, they see 8 cells filled with values, not 4 cells filled +
   4 cells empty. Documenting this as intentional.

3. **'ancestor' source attribution can be ambiguous when multiple
   ancestors have rows.** The resolver picks the highest-level row;
   if Mandant=of_counsel and Litigation=partner, attribution surfaces
   `source='ancestor', source_id=Litigation`. The Mandant rule is
   silently overridden. The UI chip says "Geerbt von Litigation X" with
   no hint that the Mandant also has a rule. Cost: minor — admin can
   navigate to the Mandant's matrix and see its row directly. Mitigation
   option (deferred): the matrix-endpoint for the admin page returns
   the FULL stack of contributing rows per cell, so the chip can say
   "Strengste von 3 Quellen". Worth doing if v1 attribution feels
   confusing in practice.

4. **Audit lives only in `/admin/audit-log`, not in project verlauf.**
   Per Q8 lock-in. Minor side effect: a non-admin user wondering "why
   does my deadline now need approval?" can't see the policy-set event
   on the project's verlauf. They have to check the deadline-form hint
   (which says "Geerbt von Partner Unit Munich Lit") and ask an admin
   for the change history. Acceptable trade-off — most users don't need
   policy change history, only admins who set them.

5. **Bulk-apply destroys target's existing project-specific rows for the
   8 cells.** Idempotent fanout means setting source to "matrix M" makes
   targets match M, including DELETE of any pre-existing target rows
   that aren't in M. This is by design (otherwise re-applying a partially-
   reduced source wouldn't actually reduce). Confirm modal lists the
   affected rows clearly: "12 Projekte, 8 Felder pro Projekt, ggf.
   bestehende Werte überschrieben". One audit-log row per write so the
   change is fully traceable.

6. **Mobile section list duplicates the matrix data structure in the
   DOM.** TSX renders both the grid table and the stacked sections; CSS
   toggles based on viewport. Slight DOM bloat (16 cells × 2 = 32 form
   nodes per partner unit) but matches the entity-table → entity-list
   pattern already used elsewhere. Alternative (single DOM rendered
   responsively via flex/grid-flow) is uglier in TSX.

---

## §9 — Files the implementer will touch

**Backend (Go):**
- `internal/db/migrations/062_approval_policy_unit_defaults.up.sql` (new)
- `internal/db/migrations/062_approval_policy_unit_defaults.down.sql` (new)
- `internal/services/approval_service.go` (rewire `LookupPolicy`, add
  `GetEffectivePoliciesMatrix`, `ApplyMatrixToDescendants`, extend
  `UpsertPolicy`/`DeletePolicy`)
- `internal/services/approval_service_test.go` (new resolver tests, bulk-apply tests, XOR tests)
- `internal/models/approval.go` (extend `ApprovalPolicy` with optional
  `PartnerUnitID`, `Source`, `SourceID`)
- `internal/handlers/approvals.go` (new unit-default + matrix + form-hint + bulk-apply handlers)
- `internal/handlers/handlers.go` (route registration for the new endpoints + `/admin/approval-policies` page)

**Frontend (TS/TSX):**
- `frontend/src/admin-approval-policies.tsx` (new)
- `frontend/src/client/admin-approval-policies.ts` (new)
- `frontend/src/admin.tsx` (add card)
- `frontend/src/inbox.tsx` (admin-nudge block)
- `frontend/src/client/inbox.ts` (gate + reveal nudge)
- `frontend/src/deadlines-new.tsx` + `frontend/src/client/deadlines-new.ts` (hint render)
- `frontend/src/appointments-new.tsx` + `frontend/src/client/appointments-new.ts` (hint render)
- `frontend/src/styles/global.css` (matrix grid + mobile sections + attribution chip)
- `frontend/src/client/i18n.ts` (~75 new keys × 2 langs)
- `frontend/build.ts` (new bundle entry: admin-approval-policies)

**Estimate:** ~3500-4500 LoC (matches t-138 + t-144 design phases — small
admin page, small migration, mostly mechanical wiring + CSS + i18n).

---

## §10 — Recommended implementer

Pattern-fluent Sonnet — substrate is well-trodden:

- Admin-page pattern → `frontend/src/admin-partner-units.tsx` is the
  canonical reference (partner-unit picker → details panel; same shape
  here with project picker → matrix panel).
- Project-detail edit-in-place → `client/projects-detail.ts` for the
  `<select>`-on-row-click affordance pattern.
- ltree path-walk in SQL → `internal/services/visibility.go` and the
  existing `paliad.can_see_project()` are the reference pattern.
- Audit emission → `internal/services/audit_service.go` (already plumbed).
- Form-hint above Speichern → similar to the t-148 profession hint
  on `frontend/src/projects-detail.tsx:130` (`team-profession-hint`).

**NOT cronus** per memory directive (paliad). **NOT noether** (parked on
t-151 and t-144). **NOT godel** (just fired on t-149). **NOT hilbert
(me)** — I'm parked after this design; head decides if I take the
coder shift on the same worktree (mai/hilbert/inventor-approval-policy)
or hands it to a fresh coder.

---

## §11 — Out of scope (deferred to follow-ups)

- **Per-policy time-window** — "this rule applies only Mon–Fri 9–17, after
  hours skip 4-eye". Some firms do this. Deferred: another column would
  be cheap, but no signal yet that anyone wants it.
- **Per-user exemptions** — "Alice is on PTO, route around her". Same
  shape as today's `decision_kind='admin_override'` escape hatch — already
  available via global_admin.
- **Multi-step approvals** — "needs partner THEN of_counsel sign-off".
  cronus's t-138 is single-step by design (Q3 of t-138 locked it). Not
  in scope here.
- **Policy templates / copy-from-other-project** — beyond bulk-apply-to-
  descendants. If needed, would slot into the admin page as a
  "Vorlage anwenden" affordance. Not v1.
- **Per-event_type policies** — "deadline.create with event_type='Klage'
  needs partner; everything else of_counsel". The existing schema is
  per-(entity_type, lifecycle_event); event-type granularity would
  require an extra column + index. No signal yet.

---

**END OF DESIGN.**

Inventor stays parked. Awaits m's go/no-go on the 12 locked decisions
before any coder shift. Hand-off via head once green.
