Files
paliad/internal/services/approval_service.go
m a3052eb085 feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with  added in Slice E).

  POST /api/paliadin/suggest/deadline
  POST /api/paliadin/suggest/appointment

Wiring:

- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
  always creates an approval_request (bypassing policy lookup) and
  stamps requester_kind='agent' + agent_turn_id. Required-role defaults
  to 'associate' so the deadlock check has a non-NULL threshold; m's
  lock-in for Q11 (every agent suggestion needs the user's eye) means
  bypassing the policy gate is correct here, not a regression.

- The shared `submit` kernel takes an optional agent_turn_id pointer.
  All four lifecycle entry points (SubmitCreate / SubmitUpdate /
  SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
  the turn id. INSERT to approval_requests now writes both
  requester_kind + agent_turn_id atomically (xor-check on the schema
  enforces consistency).

- models.ApprovalRequest grows the two columns + their JSON tags so
  the inbox view + Verlauf renderer can read provenance without an
  extra fetch.

- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
  to the SQL projection; both surfaces (ListPendingForApprover,
  ListSubmittedByUser, GetRequest) inherit the new fields free.

- CreateDeadlineInput + CreateAppointmentInput each get an optional
  AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
  SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
  behaviour is unchanged for every existing caller.

- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
  requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
  RFC3339 + ISO-date validation, 409 + a useful message on
  ErrNoQualifiedApprover.

- Project-event audit metadata gains requester_kind + agent_turn_id so
  the project's Verlauf can render "Paliadin hat eine Frist
  vorgeschlagen " without joining approval_requests (Slice E reads
  this).

SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.

go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the  on existing eye-pill surfaces.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.
2026-05-08 19:59:44 +02:00

1720 lines
68 KiB
Go
Raw 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.

package services
// ApprovalService implements the 4-Augen-Prüfung workflow on
// paliad.deadlines and paliad.appointments (t-paliad-138).
//
// Architecture: write-then-approve (m's Q5 choice). The mutation lands on
// the entity row immediately; the entity carries approval_status='pending'
// + pending_request_id until an approver flips it to 'approved'. Delete is
// the one stage-then-write exception — we mark the row pending instead of
// hard-deleting, then hard-delete on approve / restore on reject.
//
// Submission entry points (Submit{Create,Update,Complete,Delete}) are
// invoked by DeadlineService / AppointmentService inside their existing
// transactions. They:
// 1. Look up the policy for (project, entity_type, lifecycle_event).
// 2. If no policy → no-op (entity stays approval_status='approved').
// 3. If policy → run a deadlock check (qualified approver != requester
// must exist), insert an approval_requests row, mark the entity
// pending, emit a *_approval_requested project_events row.
//
// Decision entry points (Approve / Reject / Revoke) run their own tx and:
// - Approve: validate canApprove(caller, request); flip the entity back
// to approved (or hard-delete for delete-lifecycle); emit
// *_approval_approved.
// - Reject: validate canApprove; revert the entity from pre_image (or
// hard-delete a pending-create); emit *_approval_rejected.
// - Revoke: validate caller == requester; same revert as Reject; emit
// *_approval_revoked.
//
// Self-approval is blocked at three layers:
// 1. canApprove() returns ErrSelfApproval when caller == requester.
// 2. The DB CHECK constraint approval_requests_no_self_approval refuses
// decided_by == requested_by writes.
// 3. The deadlock-check excludes the requester from the qualified-approver
// pool, so the deadlock path can't be silently bypassed.
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// ApprovalService is the workflow orchestrator. It holds no entity-specific
// knowledge — DeadlineService / AppointmentService call its Submit*
// methods, and the Approve / Reject / Revoke paths run direct SQL on the
// entity tables to keep the dependency graph acyclic.
type ApprovalService struct {
db *sqlx.DB
users *UserService
}
// NewApprovalService wires the service.
func NewApprovalService(db *sqlx.DB, users *UserService) *ApprovalService {
return &ApprovalService{db: db, users: users}
}
// LookupPolicy returns the effective approval policy for the given tuple,
// or nil if none applies. Reads inside the same tx as Submit* so policy
// reads see whatever the calling tx may have already written.
//
// Resolution (t-paliad-160): delegates to paliad.approval_policy_effective(),
// which returns at most one row after the most-strict-wins fold over the
// project-row / ancestor-row / unit-default candidates. The split-grammar
// columns are:
//
// - requires_approval — the gate (OR across candidates).
// - min_role — the seniority threshold (MAX along the role
// ladder among the requires_approval=true
// candidates). NULL when the gate is off.
//
// When the gate is off (requires_approval=false OR no candidates), this
// returns nil and the caller skips creating an approval_request entirely.
func (s *ApprovalService) LookupPolicy(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID, entityType, lifecycleEvent string) (*models.ApprovalPolicy, error) {
var row struct {
RequiresApproval bool `db:"requires_approval"`
MinRole sql.NullString `db:"min_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT requires_approval, min_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
if err := txOrDB(tx, s.db).GetContext(ctx, &row, q, projectID, entityType, lifecycleEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil // no candidates → no policy applies
}
return nil, fmt.Errorf("lookup approval policy: %w", err)
}
if !row.RequiresApproval || !row.MinRole.Valid {
return nil, nil // gate off — no approval request needed
}
pid := projectID
return &models.ApprovalPolicy{
ProjectID: &pid,
EntityType: entityType,
LifecycleEvent: lifecycleEvent,
RequiresApproval: true,
MinRole: &row.MinRole.String,
}, nil
}
// hasQualifiedApprover counts users on the project's team-membership path
// (direct OR ancestor) whose (profession, responsibility) tuple meets the
// strict-ladder threshold, plus any global_admin user, plus any partner-
// unit-derived member where the attachment grants authority (t-paliad-139).
// Excludes requesterID.
//
// Returns true if at least one such user exists. The path-walk JOIN matches
// the visibility predicate so an ancestor partner qualifies for a
// descendant's approval, just like they have visibility.
//
// t-paliad-148: peer authority requires BOTH a profession with sufficient
// level AND a responsibility ∈ {lead, member} that opens the gate.
// observer/external rows are excluded even if the user's profession would
// otherwise qualify — that's the point of the project-level gate.
func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx, projectID, requesterID uuid.UUID, requiredRole string) (bool, error) {
q := `WITH path AS (
SELECT string_to_array(p.path, '.')::uuid[] AS ids
FROM paliad.projects p WHERE p.id = $1
)
SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
JOIN path ON pt.project_id = ANY(path.ids)
WHERE pt.user_id <> $2
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
UNION ALL
SELECT 1 FROM paliad.users u
WHERE u.global_role = 'global_admin' AND u.id <> $2
UNION ALL
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
JOIN path ON ppu.project_id = ANY(path.ids)
WHERE pum.user_id <> $2
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
LIMIT 1
) AS ok`
var ok bool
if err := txOrDB(tx, s.db).GetContext(ctx, &ok, q, projectID, requesterID, requiredRole); err != nil {
return false, fmt.Errorf("deadlock check: %w", err)
}
return ok, nil
}
// SubmitCreate is invoked by Deadline/AppointmentService inside their
// create-tx, after the entity row has been INSERTed but before the
// commit. If a (project, entity_type, 'create') policy applies, it inserts
// the approval_requests row, marks the entity pending, and emits the
// *_approval_requested audit event.
//
// payload is the just-inserted entity's field values (used as audit echo).
//
// Returns the new request ID if pending, nil if no policy applied.
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, nil)
}
// SubmitAgentCreate is the agent-drafted variant of SubmitCreate
// (t-paliad-161). Used when Paliadin drafts a row on the user's behalf —
// the request is created UNCONDITIONALLY (even when no policy applies),
// stamped with requester_kind='agent', and linked to the originating
// paliadin_turns row via agent_turn_id.
//
// The unconditional gate is by design: every agent suggestion needs the
// user's eye (m's lock-in for Q11). Bypassing the policy lookup keeps a
// single audit shape — agent-drafted entities never appear "live" in the
// approved column without an approve-decision behind them, regardless of
// whether the project would have skipped 4-eye for a direct user create.
func (s *ApprovalService) SubmitAgentCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID, agentTurnID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, &agentTurnID)
}
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
// carries the date-bearing fields that were just overwritten (per Q4
// allowlist) so a rejection can restore them. payload echoes the new values.
func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
if len(preImage) == 0 {
// Nothing in the date-bearing allowlist actually changed — bypass
// the approval flow entirely (the underlying UPDATE was cosmetic).
return nil, nil
}
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload, nil)
}
// SubmitComplete is invoked after status was flipped to 'completed'
// (deadline) or completed_at was set (appointment). preImage stores the
// pre-completion state so a rejection can revert.
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload, nil)
}
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
// the stage-then-write exception). The entity row stays alive with
// approval_status='pending'; on approve we hard-delete, on reject we just
// clear the pending markers.
//
// preImage stores the full row state so the inbox can render
// "About to delete: Frist X (due 2026-05-12)".
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil, nil)
}
// submit is the shared lifecycle-handling kernel.
//
// agentTurnID, when non-nil, marks the request as agent-drafted: the
// request gets requester_kind='agent' + agent_turn_id=<id>, and the
// policy gate is BYPASSED — every agent suggestion goes into pending
// (m's lock-in for t-paliad-161 Q11). When nil, the standard flow runs
// (look up policy, return nil + no-op when no policy applies).
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any, agentTurnID *uuid.UUID) (*uuid.UUID, error) {
// Resolve required role:
// - User path: lookup policy, no-op if none, error if no qualified
// approver exists for the threshold.
// - Agent path: bypass policy lookup; required_role defaults to the
// associate floor (the conservative seed across paliad's policy
// baseline) so the inbox query for who-can-approve still has a
// non-NULL threshold to fold into the strict ladder. The user
// receiving the suggestion to approve is themselves and they're
// not in the qualified-approver pool (CHECK decided_by != requested_by),
// so the deadlock check needs to verify someone OTHER than the
// suggesting user can approve — which they can, because the
// suggesting user is m (the only Paliadin owner today) and any
// project lead / global_admin not equal to m qualifies.
var requiredRole string
if agentTurnID == nil {
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
if err != nil {
return nil, err
}
if policy == nil {
// No policy applies — entity stays approval_status='approved'. No-op.
return nil, nil
}
// LookupPolicy guarantees MinRole is non-nil whenever a non-nil
// policy is returned (gate on + threshold set).
requiredRole = *policy.MinRole
} else {
// Agent path: associate threshold (the firm-wide seed baseline).
requiredRole = "associate"
}
// Deadlock check: somebody other than the requester must be qualified
// to approve, either via project team membership or as global_admin.
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, requiredRole)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, requiredRole)
}
// Concurrent-pending guard: the entity table has a CHECK / NOT NULL
// guard against double-pending — but we surface a clean error rather
// than letting the UPDATE silently fail. The guard relies on
// approval_status='approved' being the precondition for a fresh
// pending state.
currentStatus, err := s.entityApprovalStatus(ctx, tx, entityType, entityID)
if err != nil {
return nil, err
}
if currentStatus == ApprovalStatusPending {
return nil, ErrConcurrentPending
}
requestID := uuid.New()
preImageJSON, err := marshalJSONOrNull(preImage)
if err != nil {
return nil, fmt.Errorf("marshal pre_image: %w", err)
}
payloadJSON, err := marshalJSONOrNull(payload)
if err != nil {
return nil, fmt.Errorf("marshal payload: %w", err)
}
// Agent fields are nil on the user path, set on the agent path. The
// xor-check on approval_requests_agent_xor enforces that
// requester_kind='agent' implies agent_turn_id IS NOT NULL and vice
// versa, so we always set both columns coherently here.
requesterKind := "user"
var agentTurnArg any
if agentTurnID != nil {
requesterKind = "agent"
agentTurnArg = *agentTurnID
}
insertReqSQL := `INSERT INTO paliad.approval_requests
(id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, required_role, status,
requester_kind, agent_turn_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11)`
if _, err := tx.ExecContext(ctx, insertReqSQL,
requestID, projectID, entityType, entityID, lifecycle,
preImageJSON, payloadJSON, requesterID, requiredRole,
requesterKind, agentTurnArg); err != nil {
return nil, fmt.Errorf("insert approval_request: %w", err)
}
// Mark the entity row pending. The WHERE approval_status='approved'
// (or 'legacy') guard makes the UPDATE atomic vs concurrent pending.
updateEntitySQL := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'pending', pending_request_id = $1, updated_at = now()
WHERE id = $2 AND approval_status IN ('approved','legacy')`,
entityTableName(entityType))
res, err := tx.ExecContext(ctx, updateEntitySQL, requestID, entityID)
if err != nil {
return nil, fmt.Errorf("mark entity pending: %w", err)
}
rows, _ := res.RowsAffected()
if rows != 1 {
// Either the entity vanished or another tx flipped it pending.
return nil, ErrConcurrentPending
}
// Audit emit.
eventType := approvalEventType(entityType, "requested")
descPtr := approvalDescription("requested", requiredRole, lifecycle)
meta := map[string]any{
"approval_request_id": requestID.String(),
"lifecycle_event": lifecycle,
"required_role": requiredRole,
entityType + "_id": entityID.String(),
"requester_kind": requesterKind,
}
if agentTurnID != nil {
meta["agent_turn_id"] = agentTurnID.String()
}
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
return nil, err
}
return &requestID, nil
}
// Approve flips a pending request to 'approved' and applies the lifecycle
// to the entity. Runs in its own transaction.
func (s *ApprovalService) Approve(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusApproved, note)
}
// Reject flips a pending request to 'rejected' and reverts the entity from
// pre_image. Runs in its own transaction.
func (s *ApprovalService) Reject(ctx context.Context, requestID, callerID uuid.UUID, note string) error {
return s.decide(ctx, requestID, callerID, RequestStatusRejected, note)
}
// Revoke is invoked by the requester to undo their own pending submission
// before any approver acts on it. The entity reverts as if the request had
// been rejected, but the request status is 'revoked'. Runs in its own tx.
func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.UUID) error {
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// decide is the shared kernel for Approve / Reject / Revoke. The decision
// kind is derived from the (caller, request) relationship and the requested
// final status:
// - RequestStatusApproved: caller must NOT be requester; admin override or peer.
// - RequestStatusRejected: same authorization rules as Approve.
// - RequestStatusRevoked: caller MUST be requester.
func (s *ApprovalService) decide(ctx context.Context, requestID, callerID uuid.UUID, finalStatus, note string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
var decisionKind string
switch finalStatus {
case RequestStatusApproved, RequestStatusRejected:
kind, err := s.canApprove(ctx, tx, callerID, req)
if err != nil {
return err
}
decisionKind = kind
case RequestStatusRevoked:
if callerID != req.RequestedBy {
return ErrNotApprover
}
decisionKind = DecisionKindPeer // unused for revoke but keeps non-NULL audit
default:
return fmt.Errorf("invalid final status %q", finalStatus)
}
// Apply the lifecycle outcome to the entity.
switch finalStatus {
case RequestStatusApproved:
if err := s.applyApproved(ctx, tx, req, callerID); err != nil {
return err
}
case RequestStatusRejected, RequestStatusRevoked:
if err := s.applyRevert(ctx, tx, req); err != nil {
return err
}
}
// Update the request row.
now := time.Now().UTC()
var trimmedNote *string
if n := strings.TrimSpace(note); n != "" {
trimmedNote = &n
}
updateReqSQL := `UPDATE paliad.approval_requests
SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4,
decision_note = $5, updated_at = $3
WHERE id = $6`
// For revoke, decided_by stays NULL (the requester didn't "decide" to
// approve, they pulled the request) — but a CHECK (decided_by != requested_by)
// would block decided_by=requester anyway. NULL is correct.
var decidedBy any
var decisionKindArg any
if finalStatus != RequestStatusRevoked {
decidedBy = callerID
decisionKindArg = decisionKind
} else {
decidedBy = nil
decisionKindArg = nil
}
if _, err := tx.ExecContext(ctx, updateReqSQL,
finalStatus, decidedBy, now, decisionKindArg, trimmedNote, requestID); err != nil {
return fmt.Errorf("update approval_request: %w", err)
}
// Audit emit.
var verlaufKind string
switch finalStatus {
case RequestStatusApproved:
verlaufKind = "approved"
case RequestStatusRejected:
verlaufKind = "rejected"
case RequestStatusRevoked:
verlaufKind = "revoked"
}
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
}
if finalStatus != RequestStatusRevoked {
meta["decision_kind"] = decisionKind
}
if trimmedNote != nil {
meta["decision_note"] = *trimmedNote
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// canApprove enforces the strict-ladder gate plus the no-self-approval
// rule. Returns the decision_kind ('peer' | 'admin_override' |
// 'derived_peer') the caller should record, or an error.
//
// Resolution order (t-paliad-139 §4.2):
// 1. Self-approval is hard-blocked.
// 2. global_admin always wins ('admin_override').
// 3. Direct or ancestor project_teams membership with sufficient role
// ('peer').
// 4. Partner-unit-derived membership with derive_grants_authority=true
// and a unit_role that maps (via approval_role_from_unit_role) to a
// project_role with sufficient level ('derived_peer').
func (s *ApprovalService) canApprove(ctx context.Context, tx *sqlx.Tx, callerID uuid.UUID, req *models.ApprovalRequest) (string, error) {
if callerID == req.RequestedBy {
return "", ErrSelfApproval
}
user, err := s.users.GetByID(ctx, callerID)
if err != nil {
return "", err
}
if user == nil {
return "", ErrNotApprover
}
if user.GlobalRole == "global_admin" {
return DecisionKindAdminOverride, nil
}
// Path-walk: check direct OR ancestor team membership with a
// responsibility that opens the gate (lead/member) AND a profession
// whose level meets the threshold (t-paliad-148 tuple-with-gate).
q := `SELECT EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level($3)
)`
var ok bool
if err := tx.GetContext(ctx, &ok, q, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("authorization check: %w", err)
}
if ok {
return DecisionKindPeer, nil
}
// t-paliad-139 derivation branch: check authority-granting partner-unit
// attachments on the project's path.
qDerived := `SELECT EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(
(SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level($3)
)`
var derivedOK bool
if err := tx.GetContext(ctx, &derivedOK, qDerived, callerID, req.ProjectID, req.RequiredRole); err != nil {
return "", fmt.Errorf("derived authorization check: %w", err)
}
if derivedOK {
return DecisionKindDerivedPeer, nil
}
return "", ErrNotApprover
}
// applyApproved finalises the lifecycle on the entity row.
func (s *ApprovalService) applyApproved(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest, approverID uuid.UUID) error {
table := entityTableName(req.EntityType)
now := time.Now().UTC()
if req.LifecycleEvent == LifecycleDelete {
// Hard-delete the entity. The approval_requests.entity_id reference
// is a polymorphic uuid (no FK) so it survives the row going away.
// pending_request_id on the entity has ON DELETE SET NULL but the
// entity is the one being deleted, not the request — so this is
// just a plain DELETE.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on approve: %w", err)
}
return nil
}
// Non-delete approve = clear pending markers, set approved_by/at.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = 'approved',
pending_request_id = NULL,
approved_by = $1,
approved_at = $2,
updated_at = $2
WHERE id = $3`, table)
if _, err := tx.ExecContext(ctx, q, approverID, now, req.EntityID); err != nil {
return fmt.Errorf("clear pending on approve: %w", err)
}
return nil
}
// applyRevert undoes the in-flight change on the entity row, restoring it
// from the request's pre_image jsonb. Used by both Reject and Revoke.
func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *models.ApprovalRequest) error {
table := entityTableName(req.EntityType)
switch req.LifecycleEvent {
case LifecycleCreate:
// The entity should never have existed. Hard-delete.
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM paliad.%s WHERE id = $1`, table),
req.EntityID); err != nil {
return fmt.Errorf("delete on reject-create: %w", err)
}
return nil
case LifecycleDelete:
// We never deleted the entity (delete is stage-then-write); just
// clear the pending markers so the row is fully alive again.
q := fmt.Sprintf(`UPDATE paliad.%s
SET approval_status = CASE WHEN approval_status = 'pending'
THEN 'approved' ELSE approval_status END,
pending_request_id = NULL,
updated_at = now()
WHERE id = $1`, table)
if _, err := tx.ExecContext(ctx, q, req.EntityID); err != nil {
return fmt.Errorf("clear pending on reject-delete: %w", err)
}
return nil
case LifecycleUpdate, LifecycleComplete:
// Restore pre_image fields, clear pending markers.
preImage := map[string]any{}
if len(req.PreImage) > 0 {
if err := json.Unmarshal(req.PreImage, &preImage); err != nil {
return fmt.Errorf("unmarshal pre_image: %w", err)
}
}
setClauses, args, err := buildRevertSetClauses(req.EntityType, preImage)
if err != nil {
return err
}
// Always clear pending markers + revert approval_status.
setClauses = append(setClauses,
"approval_status = 'approved'",
"pending_request_id = NULL",
"updated_at = now()")
args = append(args, req.EntityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
table, strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("revert entity from pre_image: %w", err)
}
return nil
default:
return fmt.Errorf("%w: lifecycle %q", ErrUnknownEntityType, req.LifecycleEvent)
}
}
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
// keys are silently dropped to defend against malformed pre_image rows
// (defence-in-depth: callers should already be sending only allowlisted
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
// fields be reverted).
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
add := func(col string, val any) {
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
switch entityType {
case EntityTypeDeadline:
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
// Complete-revert restores status='pending' + completed_at NULL.
// We detect this branch by the presence of a status key; lifecycle
// is the formal source but pre_image is what the caller stored.
if v, ok := preImage["status"]; ok {
add("status", v)
}
if _, ok := preImage["completed_at"]; ok {
// Always NULL on revert — completion didn't really happen.
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
case EntityTypeAppointment:
for _, col := range []string{"start_at", "end_at"} {
if v, ok := preImage[col]; ok {
add(col, v)
}
}
if _, ok := preImage["completed_at"]; ok {
args = append(args, nil)
setClauses = append(setClauses, fmt.Sprintf("completed_at = $%d", len(args)))
}
default:
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
}
if len(setClauses) == 0 {
return nil, nil, fmt.Errorf("%w: empty pre_image for %s", ErrUnknownEntityType, entityType)
}
return setClauses, args, nil
}
// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {
var req models.ApprovalRequest
q := `SELECT id, project_id, entity_type, entity_id, lifecycle_event,
pre_image, payload, requested_by, requested_at, required_role,
status, decided_by, decided_at, decision_kind, decision_note,
created_at, updated_at
FROM paliad.approval_requests
WHERE id = $1
FOR UPDATE`
if err := tx.GetContext(ctx, &req, q, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrRequestNotPending
}
return nil, fmt.Errorf("load request: %w", err)
}
return &req, nil
}
// entityApprovalStatus reads the current approval_status on the entity
// row. Returns "" if the row doesn't exist.
func (s *ApprovalService) entityApprovalStatus(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID) (string, error) {
q := fmt.Sprintf(`SELECT approval_status FROM paliad.%s WHERE id = $1`,
entityTableName(entityType))
var status string
if err := txOrDB(tx, s.db).GetContext(ctx, &status, q, entityID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", fmt.Errorf("read approval_status: %w", err)
}
return status, nil
}
// PendingRequestForEntity returns the request_id + required_role of the
// in-flight approval_request for an entity in approval_status='pending'.
// Returns ("", "", nil) when no pending request is associated. Used by
// the entity services to enrich ErrConcurrentPending into a
// PendingApprovalError that handlers can render as a 409 with structured
// payload.
func (s *ApprovalService) PendingRequestForEntity(ctx context.Context, entityType string, entityID uuid.UUID) (string, string, error) {
q := `SELECT id::text, required_role
FROM paliad.approval_requests
WHERE entity_type = $1 AND entity_id = $2 AND status = 'pending'
ORDER BY requested_at DESC
LIMIT 1`
var row struct {
ID string `db:"id"`
RequiredRole string `db:"required_role"`
}
if err := s.db.GetContext(ctx, &row, q, entityType, entityID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
return "", "", fmt.Errorf("lookup pending request: %w", err)
}
return row.ID, row.RequiredRole, nil
}
// entityTableName resolves the SQL table name for a given entity_type.
// Internal helper — entityType comes from server-side constants, not user
// input, so a panic on an unknown value is a programming error.
func entityTableName(entityType string) string {
switch entityType {
case EntityTypeDeadline:
return "deadlines"
case EntityTypeAppointment:
return "appointments"
default:
panic(fmt.Sprintf("approval: unknown entity_type %q", entityType))
}
}
// approvalEventType returns the project_events.event_type value for a
// given (entity, lifecycle-step) pair. Step is one of "requested" |
// "approved" | "rejected" | "revoked".
func approvalEventType(entityType, step string) string {
return entityType + "_approval_" + step
}
// approvalDescription returns the short audit description string. Frontend
// renders the localized version via translateEvent; this is the raw audit
// row's description column, used as a fallback and for /admin/audit-log.
func approvalDescription(step, requiredRole, lifecycle string) *string {
d := fmt.Sprintf("%s — %s/%s", step, lifecycle, requiredRole)
return &d
}
// txOrDB returns the tx if non-nil, else the db. Lets read helpers run
// either inside a calling tx (for consistency with concurrent writes) or
// standalone for List endpoints.
func txOrDB(tx *sqlx.Tx, db *sqlx.DB) sqlxQueryer {
if tx != nil {
return tx
}
return db
}
// sqlxQueryer is the minimal subset of *sqlx.DB / *sqlx.Tx we need.
// Defined here to avoid adding a public abstraction across the package.
type sqlxQueryer interface {
GetContext(ctx context.Context, dest any, query string, args ...any) error
SelectContext(ctx context.Context, dest any, query string, args ...any) error
QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
}
// marshalJSONOrNull returns []byte("null") JSON-RawMessage style for
// nil/empty maps so callers can pass it directly to a jsonb column without
// branching at every call site.
func marshalJSONOrNull(m map[string]any) ([]byte, error) {
if len(m) == 0 {
return nil, nil
}
return json.Marshal(m)
}
// ============================================================================
// Read paths — inbox + policy CRUD.
// ============================================================================
// ApprovalRequestView is the inbox-friendly projection of an approval
// request: the bare ApprovalRequest plus the contextual labels the inbox
// needs to render a row without further fetches.
type ApprovalRequestView struct {
models.ApprovalRequest
ProjectTitle string `db:"project_title" json:"project_title"`
EntityTitle *string `db:"entity_title" json:"entity_title,omitempty"`
RequesterName string `db:"requester_name" json:"requester_name"`
RequesterEmail string `db:"requester_email" json:"requester_email"`
DeciderName *string `db:"decider_name" json:"decider_name,omitempty"`
DeciderEmail *string `db:"decider_email" json:"decider_email,omitempty"`
}
const approvalRequestViewColumns = `
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
ar.requester_kind, ar.agent_turn_id,
ar.created_at, ar.updated_at,
p.title AS project_title,
CASE WHEN ar.entity_type = 'deadline' THEN d.title
WHEN ar.entity_type = 'appointment' THEN a.title
END AS entity_title,
COALESCE(ru.display_name, ru.email) AS requester_name,
ru.email AS requester_email,
du.display_name AS decider_name,
du.email AS decider_email`
const approvalRequestViewJoins = `
paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
JOIN paliad.users ru ON ru.id = ar.requested_by
LEFT JOIN paliad.users du ON du.id = ar.decided_by
LEFT JOIN paliad.deadlines d ON ar.entity_type = 'deadline' AND d.id = ar.entity_id
LEFT JOIN paliad.appointments a ON ar.entity_type = 'appointment' AND a.id = ar.entity_id`
// InboxFilter narrows the inbox listings.
type InboxFilter struct {
Status string // "" → no filter; otherwise one of RequestStatus*
ProjectID *uuid.UUID
EntityType string // "" → both
Limit int // 0 → 100
}
// ListPendingForApprover returns approval requests where the caller is
// qualified to approve and is not the requester.
func (s *ApprovalService) ListPendingForApprover(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{
"ar.status = 'pending'",
"ar.requested_by <> $1",
// Eligibility (any one branch suffices):
// - caller is global_admin, OR
// - caller has direct/ancestor project_teams membership with
// responsibility ∈ {lead, member} AND profession at or above
// the threshold (t-paliad-148 tuple-with-gate), OR
// - caller is a partner-unit-derived member with derive_grants_authority=true
// on an attachment in the project's path, and the unit_role maps to a
// profession at or above the threshold (t-paliad-139).
`(EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`,
}
args := []any{callerID}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at ASC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list pending for approver: %w", err)
}
return out, nil
}
// ListSubmittedByUser returns approval requests authored by the caller.
// Status filter optional.
func (s *ApprovalService) ListSubmittedByUser(ctx context.Context, callerID uuid.UUID, filter InboxFilter) ([]ApprovalRequestView, error) {
limit := filter.Limit
if limit <= 0 || limit > 200 {
limit = 100
}
conds := []string{"ar.requested_by = $1"}
args := []any{callerID}
if filter.Status != "" {
args = append(args, filter.Status)
conds = append(conds, fmt.Sprintf("ar.status = $%d", len(args)))
}
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conds = append(conds, fmt.Sprintf("ar.project_id = $%d", len(args)))
}
if filter.EntityType != "" {
args = append(args, filter.EntityType)
conds = append(conds, fmt.Sprintf("ar.entity_type = $%d", len(args)))
}
args = append(args, limit)
q := fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY ar.requested_at DESC LIMIT $%d`,
approvalRequestViewColumns, approvalRequestViewJoins,
strings.Join(conds, " AND "), len(args))
var out []ApprovalRequestView
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list submitted by user: %w", err)
}
return out, nil
}
// GetRequest returns one approval request hydrated for the inbox detail
// view. Visibility is gated upstream by the handler (anyone with project
// access can see the request).
func (s *ApprovalService) GetRequest(ctx context.Context, requestID uuid.UUID) (*ApprovalRequestView, error) {
q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $1`,
approvalRequestViewColumns, approvalRequestViewJoins)
var v ApprovalRequestView
if err := s.db.GetContext(ctx, &v, q, requestID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("get approval request: %w", err)
}
return &v, nil
}
// PendingCountForUser returns how many requests await this user's approval.
// Cheap query for the sidebar bell badge.
//
// Eligibility mirrors ListPendingForApprover: global_admin OR direct/
// ancestor project_teams membership with responsibility ∈ {lead, member}
// AND profession meeting the threshold (t-paliad-148) OR partner-unit-
// derived authority (t-paliad-139).
func (s *ApprovalService) PendingCountForUser(ctx context.Context, callerID uuid.UUID) (int, error) {
q := `SELECT COUNT(*)
FROM paliad.approval_requests ar
JOIN paliad.projects p ON p.id = ar.project_id
WHERE ar.status = 'pending'
AND ar.requested_by <> $1
AND (EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $1 AND u.global_role = 'global_admin')
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND pt.responsibility IN ('lead', 'member')
AND paliad.approval_role_level(u.profession) >= paliad.approval_role_level(ar.required_role)
)
OR EXISTS (
SELECT 1 FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = ppu.partner_unit_id
WHERE pum.user_id = $1
AND ppu.project_id = ANY(string_to_array(p.path, '.')::uuid[])
AND ppu.derive_grants_authority = true
AND pum.unit_role = ANY(ppu.derive_unit_roles)
AND paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) >= paliad.approval_role_level(ar.required_role)
))`
var n int
if err := s.db.GetContext(ctx, &n, q, callerID); err != nil {
return 0, fmt.Errorf("pending count: %w", err)
}
return n, nil
}
// ============================================================================
// Policy CRUD — paliad.approval_policies (t-paliad-138 + t-paliad-154).
//
// Two scopes coexist:
//
// - Project rows (project_id IS NOT NULL, partner_unit_id IS NULL):
// the most-specific override for that one project.
// - Unit defaults (project_id IS NULL, partner_unit_id IS NOT NULL):
// firm-wide defaults applied to every project attached
// to that partner unit (via paliad.project_partner_units).
//
// XOR enforced by approval_policies_scope_xor in migration 062.
//
// Audit emission: every set / cleared writes one row to paliad.policy_audit_log
// (Q8 of the locked design — surfaces on /admin/audit-log only, never on
// per-project /verlauf). The actor is always a global_admin.
// ============================================================================
// IsValidPolicyRole returns true iff the value is a valid required_role for
// an approval_policies row. Accepts the strict-ladder roles AND the 'none'
// sentinel that suppresses inherited defaults at project-row level. Distinct
// from IsValidRequiredRole, which is used by the gate (and rejects 'none' as
// a level-0 ineligible value).
func IsValidPolicyRole(role string) bool {
switch role {
case ProfessionPartner, ProfessionOfCounsel, ProfessionAssociate,
ProfessionSeniorPA, ProfessionPA, "none":
return true
}
return false
}
// ListProjectPolicies returns the project-specific policy rows for a single
// project (up to 8: deadline×4 + appointment×4). Does NOT include inherited
// rows or unit defaults — those come via GetEffectivePoliciesMatrix.
func (s *ApprovalService) ListProjectPolicies(ctx context.Context, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, projectID); err != nil {
return nil, fmt.Errorf("list project approval policies: %w", err)
}
return out, nil
}
// ListUnitPolicies returns the unit-default policy rows for a single
// partner unit (up to 8).
func (s *ApprovalService) ListUnitPolicies(ctx context.Context, unitID uuid.UUID) ([]models.ApprovalPolicy, error) {
q := `SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE partner_unit_id = $1
ORDER BY entity_type, lifecycle_event`
var out []models.ApprovalPolicy
if err := s.db.SelectContext(ctx, &out, q, unitID); err != nil {
return nil, fmt.Errorf("list unit approval policies: %w", err)
}
return out, nil
}
// validatePolicyTuple returns ErrInvalidInput if any of the three policy
// strings are out of range for the underlying CHECK constraints.
func validatePolicyTuple(entityType, lifecycle, requiredRole string) error {
if !IsValidPolicyRole(requiredRole) {
return fmt.Errorf("%w: required_role %q", ErrInvalidInput, requiredRole)
}
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
}
switch lifecycle {
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
default:
return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
}
return nil
}
// validatePolicySplit validates the split-grammar tuple (requires_approval,
// min_role). When requires_approval=true, min_role must be one of the
// strict-ladder professions; when false, min_role must be nil.
func validatePolicySplit(entityType, lifecycle string, requiresApproval bool, minRole *string) error {
if entityType != EntityTypeDeadline && entityType != EntityTypeAppointment {
return fmt.Errorf("%w: entity_type %q", ErrInvalidInput, entityType)
}
switch lifecycle {
case LifecycleCreate, LifecycleUpdate, LifecycleComplete, LifecycleDelete:
default:
return fmt.Errorf("%w: lifecycle_event %q", ErrInvalidInput, lifecycle)
}
if requiresApproval {
if minRole == nil || !IsValidRequiredRole(*minRole) {
role := ""
if minRole != nil {
role = *minRole
}
return fmt.Errorf("%w: min_role %q (required when requires_approval=true)", ErrInvalidInput, role)
}
} else if minRole != nil {
return fmt.Errorf("%w: min_role must be NULL when requires_approval=false", ErrInvalidInput)
}
return nil
}
// splitFromLegacy maps the legacy required_role grammar into the
// split-grammar pair. 'none' → (false, nil); else → (true, &role). Used by
// the back-compat Upsert*Policy shims that still take required_role.
func splitFromLegacy(requiredRole string) (bool, *string) {
if requiredRole == "none" {
return false, nil
}
r := requiredRole
return true, &r
}
// legacyFromSplit is the inverse: produce the audit-row required_role
// string. Used so the policy_audit_log keeps the human-readable role
// (or 'none') under the old grammar even after callers cut over to the
// split-grammar API.
func legacyFromSplit(requiresApproval bool, minRole *string) string {
if !requiresApproval || minRole == nil {
return "none"
}
return *minRole
}
// UpsertProjectPolicy creates or replaces a single project-scoped policy
// row using the legacy required_role grammar ('none' → no approval; else
// the strict-ladder role). Thin shim around UpsertProjectPolicySplit kept
// for callers (and tests) that haven't cut over yet.
func (s *ApprovalService) UpsertProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err
}
requiresApproval, minRole := splitFromLegacy(requiredRole)
return s.UpsertProjectPolicySplit(ctx, callerID, projectID, entityType, lifecycle, requiresApproval, minRole)
}
// UpsertProjectPolicySplit creates or replaces a single project-scoped
// policy row using the split-grammar (requires_approval, min_role) shape
// (t-paliad-160). Caller must be global_admin (gate enforced at the
// handler layer). Audit row written via writePolicyAudit using the
// legacy required_role string for compatibility with the existing
// policy_audit_log shape.
func (s *ApprovalService) UpsertProjectPolicySplit(
ctx context.Context, callerID, projectID uuid.UUID,
entityType, lifecycle string, requiresApproval bool, minRole *string,
) (*models.ApprovalPolicy, error) {
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("upsert project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Snapshot pre-existing (requires_approval, min_role) for the audit
// row. The audit log still uses the legacy string format
// (partner|of_counsel|...|none) so we project through legacyFromSplit.
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert project policy: read pre-image: %w", err)
}
var oldRole *string
if preReq.Valid {
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
legacy := legacyFromSplit(preReq.Bool, pm)
oldRole = &legacy
}
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5, $6)
ON CONFLICT (project_id, entity_type, lifecycle_event)
WHERE project_id IS NOT NULL
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
min_role = EXCLUDED.min_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := tx.GetContext(ctx, &p, q,
projectID, entityType, lifecycle,
requiresApproval, minRole, callerID); err != nil {
return nil, fmt.Errorf("upsert project policy: %w", err)
}
// Snapshot project name for the audit row (so cascade-set-null doesn't
// lose the human label).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
// Tolerate name lookup failure — still audit with empty scope_name.
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"project", &projectID, nil, scopeName, entityType, lifecycle,
oldRole, &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert project policy: commit: %w", err)
}
return &p, nil
}
// DeleteProjectPolicy removes a single project-scoped policy row, reverting
// that cell to inherit from ancestors / unit defaults. Audit row written.
func (s *ApprovalService) DeleteProjectPolicy(ctx context.Context, callerID, projectID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete project policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil // nothing to delete — no audit needed
}
return fmt.Errorf("delete project policy: read pre-image: %w", err)
}
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE project_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
projectID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete project policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, projectID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"project", &projectID, nil, scopeName, entityType, lifecycle,
&oldRoleStr, nil); err != nil {
return err
}
return tx.Commit()
}
// UpsertUnitPolicy creates or replaces a single unit-default policy row
// using the legacy required_role grammar. Thin shim around
// UpsertUnitPolicySplit kept for callers / tests that haven't cut over.
func (s *ApprovalService) UpsertUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle, requiredRole string) (*models.ApprovalPolicy, error) {
if err := validatePolicyTuple(entityType, lifecycle, requiredRole); err != nil {
return nil, err
}
requiresApproval, minRole := splitFromLegacy(requiredRole)
return s.UpsertUnitPolicySplit(ctx, callerID, unitID, entityType, lifecycle, requiresApproval, minRole)
}
// UpsertUnitPolicySplit creates or replaces a single unit-default policy
// row using the split-grammar (requires_approval, min_role) shape.
func (s *ApprovalService) UpsertUnitPolicySplit(
ctx context.Context, callerID, unitID uuid.UUID,
entityType, lifecycle string, requiresApproval bool, minRole *string,
) (*models.ApprovalPolicy, error) {
if err := validatePolicySplit(entityType, lifecycle, requiresApproval, minRole); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("upsert unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("upsert unit policy: read pre-image: %w", err)
}
var oldRole *string
if preReq.Valid {
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
legacy := legacyFromSplit(preReq.Bool, pm)
oldRole = &legacy
}
requiredRole := legacyFromSplit(requiresApproval, minRole)
q := `INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES (NULL, $1, $2, $3, $4, $5, $6)
ON CONFLICT (partner_unit_id, entity_type, lifecycle_event)
WHERE partner_unit_id IS NOT NULL
DO UPDATE SET requires_approval = EXCLUDED.requires_approval,
min_role = EXCLUDED.min_role,
updated_at = now()
RETURNING id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by`
var p models.ApprovalPolicy
if err := tx.GetContext(ctx, &p, q,
unitID, entityType, lifecycle,
requiresApproval, minRole, callerID); err != nil {
return nil, fmt.Errorf("upsert unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_set",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
oldRole, &requiredRole); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("upsert unit policy: commit: %w", err)
}
return &p, nil
}
// DeleteUnitPolicy removes a single unit-default policy row.
func (s *ApprovalService) DeleteUnitPolicy(ctx context.Context, callerID, unitID uuid.UUID, entityType, lifecycle string) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("delete unit policy: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
var preReq sql.NullBool
var preMin sql.NullString
if err := tx.QueryRowxContext(ctx,
`SELECT requires_approval, min_role FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle).Scan(&preReq, &preMin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil
}
return fmt.Errorf("delete unit policy: read pre-image: %w", err)
}
var pm *string
if preMin.Valid {
s := preMin.String
pm = &s
}
oldRoleStr := legacyFromSplit(preReq.Bool, pm)
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE partner_unit_id = $1 AND entity_type = $2 AND lifecycle_event = $3`,
unitID, entityType, lifecycle); err != nil {
return fmt.Errorf("delete unit policy: %w", err)
}
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT name FROM paliad.partner_units WHERE id = $1`, unitID); err != nil {
scopeName = ""
}
if err := s.writePolicyAudit(ctx, tx, callerID, "approval_policy_cleared",
"unit", nil, &unitID, scopeName, entityType, lifecycle,
&oldRoleStr, nil); err != nil {
return err
}
return tx.Commit()
}
// allLifecycleEvents enumerates the 8 (entity_type, lifecycle) cells in
// stable display order: Fristen first (create / update / complete / delete),
// then Termine.
var allLifecycleEvents = []struct {
EntityType string
Lifecycle string
}{
{EntityTypeDeadline, LifecycleCreate},
{EntityTypeDeadline, LifecycleUpdate},
{EntityTypeDeadline, LifecycleComplete},
{EntityTypeDeadline, LifecycleDelete},
{EntityTypeAppointment, LifecycleCreate},
{EntityTypeAppointment, LifecycleUpdate},
{EntityTypeAppointment, LifecycleComplete},
{EntityTypeAppointment, LifecycleDelete},
}
// GetEffectivePoliciesMatrix returns one EffectivePolicy per (entity_type,
// lifecycle_event) cell — 8 rows in stable display order. Each row carries
// the resolved (requires_approval, min_role) pair + attribution
// (source ∈ {project, ancestor, unit_default}) + a human-readable
// source name (project title or partner unit name).
//
// requires_approval=false with a non-nil source means the cell has been
// explicitly authored as "no approval needed" at that scope; cells with
// no candidates at all return Source=nil so the admin UI can distinguish
// "inherited off" from "never authored".
func (s *ApprovalService) GetEffectivePoliciesMatrix(ctx context.Context, projectID uuid.UUID) ([]models.EffectivePolicy, error) {
out := make([]models.EffectivePolicy, 0, len(allLifecycleEvents))
for _, c := range allLifecycleEvents {
row, err := s.GetEffectivePolicyOne(ctx, projectID, c.EntityType, c.Lifecycle)
if err != nil {
return nil, err
}
out = append(out, *row)
}
return out, nil
}
// GetEffectivePolicyOne returns the EffectivePolicy for a single cell.
// Used by the form-time hint endpoint on /projects/{id}/deadlines/new etc.
//
// Carries the split-grammar fields: RequiresApproval is the gate, MinRole
// the seniority threshold (NULL when gate off).
func (s *ApprovalService) GetEffectivePolicyOne(ctx context.Context, projectID uuid.UUID, entityType, lifecycle string) (*models.EffectivePolicy, error) {
var row struct {
RequiresApproval bool `db:"requires_approval"`
MinRole sql.NullString `db:"min_role"`
Source sql.NullString `db:"source"`
SourceID *uuid.UUID `db:"source_id"`
}
q := `SELECT requires_approval, min_role, source, source_id
FROM paliad.approval_policy_effective($1, $2, $3)`
if err := s.db.GetContext(ctx, &row, q, projectID, entityType, lifecycle); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
}, nil
}
return nil, fmt.Errorf("effective policy: %w", err)
}
res := &models.EffectivePolicy{
EntityType: entityType,
LifecycleEvent: lifecycle,
RequiresApproval: row.RequiresApproval,
}
if row.MinRole.Valid {
mr := row.MinRole.String
res.MinRole = &mr
}
if row.Source.Valid {
src := row.Source.String
res.Source = &src
}
if row.SourceID != nil {
res.SourceID = row.SourceID
if name, err := s.lookupSourceName(ctx, *row.SourceID, row.Source.String); err == nil {
res.SourceName = &name
}
}
return res, nil
}
// lookupSourceName resolves a source_id to a human label depending on the
// source kind. project / ancestor → projects.title; unit_default →
// partner_units.name. Returns ("", err) if the row vanished.
func (s *ApprovalService) lookupSourceName(ctx context.Context, id uuid.UUID, source string) (string, error) {
var q string
switch source {
case "project", "ancestor":
q = `SELECT title FROM paliad.projects WHERE id = $1`
case "unit_default":
q = `SELECT name FROM paliad.partner_units WHERE id = $1`
default:
return "", fmt.Errorf("unknown source %q", source)
}
var name string
if err := s.db.GetContext(ctx, &name, q, id); err != nil {
return "", err
}
return name, nil
}
// PoliciesExist returns true iff any approval_policies row exists firm-wide
// (project or unit, any cell). Used by the /inbox empty-state nudge to hide
// the "configure policies" card once any policy is set.
func (s *ApprovalService) PoliciesExist(ctx context.Context) (bool, error) {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT EXISTS(SELECT 1 FROM paliad.approval_policies LIMIT 1)`); err != nil {
return false, fmt.Errorf("policies exist check: %w", err)
}
return ok, nil
}
// ApplyMatrixToDescendants copies the source project's effective matrix
// down to every project in `targetIDs` as project-specific rows. Idempotent
// fanout — each target's existing project rows for the 8 cells are first
// DELETEd, then the source's effective values INSERTed (excluding cells
// where the source resolves to no policy and the target already has none).
//
// Validates every target is an actual descendant of source via the project
// path. Self-target (source ∈ targetIDs) is silently skipped. Caller must
// be global_admin (handler-layer gate). Audit row per affected target+cell.
//
// Returns the number of policy-cell writes performed (INSERTs + post-clear
// re-applies).
func (s *ApprovalService) ApplyMatrixToDescendants(ctx context.Context, callerID, sourceProjectID uuid.UUID, targetIDs []uuid.UUID) (int, error) {
if len(targetIDs) == 0 {
return 0, nil
}
// Resolve source's effective matrix (fold inherited values into the
// target's project-scoped rows for predictable behaviour).
matrix, err := s.GetEffectivePoliciesMatrix(ctx, sourceProjectID)
if err != nil {
return 0, fmt.Errorf("apply matrix: source resolve: %w", err)
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("apply matrix: begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
// Validate each target_id is a descendant of source. Anything else =
// caller-bug → ErrInvalidInput.
if err := s.validateDescendants(ctx, tx, sourceProjectID, targetIDs); err != nil {
return 0, err
}
writes := 0
for _, target := range targetIDs {
if target == sourceProjectID {
continue // skip self
}
// Snapshot pre-existing project rows for audit.
oldRows, err := s.snapshotProjectRows(ctx, tx, target)
if err != nil {
return 0, err
}
// Wipe the target's 8 cells (project-scoped only — leaves unit-default
// inheritance intact).
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.approval_policies
WHERE project_id = $1`, target); err != nil {
return 0, fmt.Errorf("apply matrix: clear target %s: %w", target, err)
}
// Apply source's effective values as project-scoped rows. Skip
// cells where the source has no policy at all (no candidates) —
// the target is left to inherit from its own ancestors / unit
// defaults rather than getting a synthetic project row written.
for _, cell := range matrix {
if cell.Source == nil {
continue // no candidates for this cell at the source
}
requiresApproval := cell.RequiresApproval
minRole := cell.MinRole
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.approval_policies
(project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role, created_by)
VALUES ($1, NULL, $2, $3, $4, $5, $6)`,
target, cell.EntityType, cell.LifecycleEvent,
requiresApproval, minRole, callerID); err != nil {
return 0, fmt.Errorf("apply matrix: write target %s cell %s/%s: %w",
target, cell.EntityType, cell.LifecycleEvent, err)
}
writes++
}
// Audit one row per target (set-event with a synthesised payload —
// individual cells are too noisy for the audit timeline).
var scopeName string
if err := tx.GetContext(ctx, &scopeName,
`SELECT title FROM paliad.projects WHERE id = $1`, target); err != nil {
scopeName = ""
}
// Use lifecycle='create' as a stand-in marker for the bulk apply
// audit row — the meaningful payload is "matrix copied from source".
// The audit row is informational; the per-cell set/clear are not
// re-emitted for bulk to avoid log spam.
_ = oldRows // pre-image not currently surfaced; reserved for future
if err := s.writePolicyAuditRaw(ctx, tx, callerID, "approval_policy_set",
"project", &target, nil, scopeName, "deadline", "create",
nil, strPtr(fmt.Sprintf("bulk-apply from source=%s", sourceProjectID))); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("apply matrix: commit: %w", err)
}
return writes, nil
}
// validateDescendants checks that every target_id is on the source's
// descendant subtree (path LIKE source.path || '.%'). Returns ErrInvalidInput
// listing offending IDs if any are not descendants.
func (s *ApprovalService) validateDescendants(ctx context.Context, tx *sqlx.Tx, sourceID uuid.UUID, targetIDs []uuid.UUID) error {
if len(targetIDs) == 0 {
return nil
}
q := `WITH src AS (
SELECT path FROM paliad.projects WHERE id = $1
)
SELECT p.id::text
FROM paliad.projects p, src
WHERE p.id = ANY($2)
AND p.path NOT LIKE src.path || '.%'`
rows, err := tx.QueryxContext(ctx, q, sourceID, pqUUIDArray(targetIDs))
if err != nil {
return fmt.Errorf("apply matrix: validate descendants: %w", err)
}
defer rows.Close()
var bad []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
bad = append(bad, id)
}
if len(bad) > 0 {
return fmt.Errorf("%w: not descendants of %s: %s",
ErrInvalidInput, sourceID, strings.Join(bad, ", "))
}
return nil
}
// snapshotProjectRows reads the current project-scoped policy rows for a
// project. Used as audit pre-image during ApplyMatrixToDescendants.
func (s *ApprovalService) snapshotProjectRows(ctx context.Context, tx *sqlx.Tx, projectID uuid.UUID) ([]models.ApprovalPolicy, error) {
var rows []models.ApprovalPolicy
if err := tx.SelectContext(ctx, &rows,
`SELECT id, project_id, partner_unit_id, entity_type, lifecycle_event,
requires_approval, min_role,
created_at, updated_at, created_by
FROM paliad.approval_policies
WHERE project_id = $1`, projectID); err != nil {
return nil, fmt.Errorf("snapshot project rows: %w", err)
}
return rows, nil
}
// writePolicyAudit writes one paliad.policy_audit_log row inside the calling
// tx. tx may be nil in which case we run on s.db directly.
func (s *ApprovalService) writePolicyAudit(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
return s.writePolicyAuditRaw(ctx, tx, actorID, eventType, scopeType,
projectID, partnerUnitID, scopeName, entityType, lifecycle, oldRole, newRole)
}
// writePolicyAuditRaw expects a non-nil tx (the audit row must commit
// atomically with the data mutation).
func (s *ApprovalService) writePolicyAuditRaw(
ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
eventType, scopeType string, projectID, partnerUnitID *uuid.UUID,
scopeName, entityType, lifecycle string,
oldRole, newRole *string,
) error {
q := `INSERT INTO paliad.policy_audit_log
(actor_id, event_type, scope_type, project_id, partner_unit_id,
scope_name, entity_type, lifecycle_event,
old_required_role, new_required_role)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
if _, err := tx.ExecContext(ctx, q,
actorID, eventType, scopeType, projectID, partnerUnitID,
scopeName, entityType, lifecycle, oldRole, newRole); err != nil {
return fmt.Errorf("write policy audit: %w", err)
}
return nil
}
// strPtr is a small helper for inline string literals.
func strPtr(s string) *string { return &s }
// pqUUIDArray converts a []uuid.UUID to the pq array format used by the
// sqlx driver. Reuses the github.com/lib/pq Array helper.
func pqUUIDArray(ids []uuid.UUID) any {
strs := make([]string, len(ids))
for i, id := range ids {
strs[i] = id.String()
}
return pq.Array(strs)
}