design(t-paliad-154): approval-policy authoring UI

Inventor pass for m/paliad#13. Surfaces the dormant t-138 4-eye system
(zero policies in DB → silent bypass) by adding /admin/approval-policies
with project-picker → 8-cell matrix + partner-unit-defaults section.

12 design questions surfaced sequentially via AskUserQuestion (per dogma)
and locked in §2 of the doc:

1. Surface: /admin/approval-policies only (admin page card on /admin index)
2. Defaults concept: per-partner-unit defaults
3. Multi-unit conflict: most-restrictive wins
4. Tree inheritance: yes (ancestors contribute candidates)
5. Cross-source precedence: most-restrictive across project+ancestor+unit;
   project row overrides outright
6. Suppression sentinel: 'none' value in required_role enum
7. Soft-disable: no, delete-only
8. Audit emission: /admin/audit-log only, not project verlauf
9. Empty-state: admin-only nudge card on /inbox when zero pending+policies
10. Bulk-apply: per-project "Auf Unterprojekte anwenden" button
11. Seed defaults: yes — conservative associate baseline for all partner units
12. Mobile shape: stacked sections per entity_type
13. Form hint: yes, above Speichern button on deadline/appointment new+edit

Migration 062 adds partner_unit_id (XOR with project_id),
'none' to required_role enum, paliad.approval_policy_effective() resolver,
and seeds 8 rows × N partner_units. ApprovalService.LookupPolicy delegates
to the resolver while preserving its calling contract (existing submit/
decide chain unchanged). New admin endpoints for unit-defaults, matrix
view, bulk-apply, and form-time effective lookup. ~3500-4500 LoC, single
PR, 5 commits.

Inventor parked. NOT cronus per memory directive. Awaiting m go/no-go.
This commit is contained in:
m
2026-05-07 23:51:38 +02:00
parent 552c9200bc
commit bb035558be

View File

@@ -0,0 +1,912 @@
# 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 MonFri 917, 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.