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

39 KiB
Raw Blame History

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:

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

    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.

    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.

    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-policiesdist/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:

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

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

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

/* 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).


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.