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.
913 lines
39 KiB
Markdown
913 lines
39 KiB
Markdown
# 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.
|