Files
paliad/docs/design-approval-policy-ui-2026-05-07.md
m bb035558be 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.
2026-05-07 23:51:38 +02:00

913 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.