Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.
approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.
CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.
derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.
Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
Commit 2 of 8 — the workflow engine for the 4-Augen-Prüfung. Wires the
service into the handlers.Services bundle so commit 3 can call into
SubmitCreate/Update/Complete/Delete from DeadlineService and
AppointmentService.
Public surface:
- Submit{Create,Update,Complete,Delete} — invoked by Deadline /
AppointmentService inside their existing tx. Looks up policy,
runs the deadlock check, inserts paliad.approval_requests, marks
the entity pending, emits the *_approval_requested project_events
audit row.
- Approve / Reject / Revoke — top-level operations (own tx). Approve
finalises the lifecycle (clears pending markers + sets approved_by
for non-delete; hard-deletes for delete). Reject / Revoke revert
the entity from pre_image (delete a pending-create, restore date
fields, NULL completed_at).
- ListPendingForApprover / ListSubmittedByUser / GetRequest /
PendingCountForUser — read paths the inbox + bell will hit in
commit 5.
- ListPolicies / UpsertPolicy / DeletePolicy — CRUD for the
authoring page in commit 4.
Self-approval is blocked at three layers:
1. canApprove() returns ErrSelfApproval when caller == requester.
2. The DB CHECK constraint approval_requests_no_self_approval.
3. The deadlock check excludes the requester from the pool.
Strict-ladder helper levelOf(role) mirrors the SQL function added in
migration 054. Path-walk authorization: ancestors with eligible roles
qualify for descendant requests (matches the visibility predicate).
Tests:
- Pure-Go: levelOf strict-ladder semantics, IsValidRequiredRole,
approvalEventType. All pass under `go test`.
- Live-DB (TEST_DATABASE_URL): no-policy noop; submit→approve cycle;
reject-create deletes; reject-update restores pre_image;
no-qualified-approver fail; revoke flow; policy CRUD roundtrip.
Skipped when TEST_DATABASE_URL is unset, mirroring the existing
audit_service_test pattern.
No call sites in DeadlineService / AppointmentService yet — that's
commit 3. Paliad continues to behave identically until that lands.