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 ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "maps" "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=, 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, "") } // EditPendingEntity lets the REQUESTER of a pending approval_request revise // the in-flight entity (e.g. tweak the title or due_date on a pending // create) without withdrawing the request. t-paliad-252 / m/paliad#83 added // this as the non-destructive sibling of Revoke — m's mental model is // "withdraw deletes the event; let me edit the event instead, keep the // approval request alive". // // Authorization: caller MUST be the original requested_by (no approver can // edit on the requester's behalf — that would collapse into SuggestChanges). // Request status MUST be pending. // // Allowlist: uses the WIDER counter-allowlist already maintained for // SuggestChanges (buildCounterSetClauses) — every editable field on the // entity, not just the date-bearing approval triggers. Unknown keys are // silently dropped. Returns ErrSuggestionRequiresChange when fields carries // no allowlisted key for the entity_type (would be a no-op write). // // Side effects in one tx: entity columns updated (and event_type_ids junction // rewritten for deadlines), approval_request.payload merged with the new // values so the approver sees what was revised, and a distinct // `_approval_edited_by_requester` project_event emitted so the // Verlauf shows the revision separately from the original *_requested row. // // The approval_request stays pending; entity.approval_status stays pending. // The approver inbox sees a fresh updated_at + the merged payload. func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error { tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck 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) } if callerID != req.RequestedBy { return ErrNotApprover } // Validate the counter-allowlist intersect produces at least one // settable column. applyEntityUpdate also wraps this check; pre-checking // here lets us emit a cleaner error before opening the entity-write. if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil { // Already wraps ErrSuggestionRequiresChange for empty / title-cleared // cases. Propagate verbatim. return err } // Apply the field updates to the entity row via the shared // counter-allowlist path (same as SuggestChanges). if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil { return err } // Merge new fields into the request payload so the approver's inbox // reflects what the requester revised to. Keys overwrite; event_type_ids // is replaced wholesale per the same semantics applyEntityUpdate uses // for the junction rewrite. var existing map[string]any if len(req.Payload) > 0 { if err := json.Unmarshal(req.Payload, &existing); err != nil { return fmt.Errorf("unmarshal payload: %w", err) } } if existing == nil { existing = map[string]any{} } maps.Copy(existing, fields) merged, err := json.Marshal(existing) if err != nil { return fmt.Errorf("marshal merged payload: %w", err) } now := time.Now().UTC() if _, err := tx.ExecContext(ctx, `UPDATE paliad.approval_requests SET payload = $1, updated_at = $2 WHERE id = $3`, merged, now, requestID); err != nil { return fmt.Errorf("update payload: %w", err) } // Audit emit. Distinct event_type so the Verlauf surfaces the revision // separately from the original *_requested or any decision row. verlaufKind := "edited_by_requester" eventType := approvalEventType(req.EntityType, verlaufKind) descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent) editedKeys := sortedKeys(fields) meta := map[string]any{ "approval_request_id": req.ID.String(), "lifecycle_event": req.LifecycleEvent, req.EntityType + "_id": req.EntityID.String(), "edited_fields": editedKeys, } if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil { return err } return tx.Commit() } // sortedKeys returns m's keys in stable alphabetical order so the audit-log // metadata is byte-for-byte stable across calls (helps when diffing audit // logs or asserting on them in tests). func sortedKeys(m map[string]any) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } // Use the stdlib sort; the slice is small (≤ counter-allowlist size). sortStrings(out) return out } // sortStrings: indirection so we don't add a new top-level import group. // In Go 1.21+ slices.Sort exists; this package is currently importing // strings + standard libs and adding "sort" would re-fan the imports. // Kept as a one-line wrapper to localise the dependency if a later move // to slices.Sort feels right. func sortStrings(s []string) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j-1] > s[j]; j-- { s[j-1], s[j] = s[j], s[j-1] } } } // SuggestChanges is the fourth approval action (t-paliad-216). The caller // proposes a counter-payload + optional free-text note; in one transaction // we close the old request as 'changes_requested', revert the entity from // pre_image, then immediately spawn a NEW 'pending' approval_request // authored by the caller carrying counter_payload as the new payload. The // new row enters the normal pending flow — anyone eligible (including the // original requester) can approve, reject, or suggest changes back on it. // 4-Augen still holds: the suggesting caller is now the new row's // requested_by, so self-approval is blocked by the standard 3-layer guard. // // Authorization is the same as Approve/Reject on the OLD row (canApprove). // The new row's deadlock check (qualified-approver-exists-other-than- // caller) runs before the new INSERT so we never spawn an unapprovable // request. // // counterPayload must differ from the old row's payload OR a non-empty // note must be present — a no-op suggestion (same values, no note) is // indistinguishable from "I have no opinion" and is rejected with // ErrSuggestionRequiresChange. counterPayload field shape is the same // allowlist used by Submit*/applyRevert (the date-bearing columns per // entity_type); unknown keys are silently dropped at apply time. // // SuggestChanges is only valid for lifecycle in (update, complete). For // create the original entity would be deleted by applyRevert, leaving no // row to apply a counter to. For delete the original is "remove this // entity" — a counter-proposal would be a different lifecycle entirely. // Both return ErrSuggestionLifecycleInvalid; the caller (handler) maps // it to 400. // // Returns the new request ID on success. func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerID uuid.UUID, counterPayload map[string]any, note string) (*uuid.UUID, error) { trimmedNote := strings.TrimSpace(note) tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() //nolint:errcheck old, err := s.getRequestForUpdate(ctx, tx, requestID) if err != nil { return nil, err } if old.Status != RequestStatusPending { return nil, fmt.Errorf("%w: status=%s", ErrRequestNotPending, old.Status) } if old.LifecycleEvent != LifecycleUpdate && old.LifecycleEvent != LifecycleComplete { return nil, fmt.Errorf("%w: lifecycle=%s", ErrSuggestionLifecycleInvalid, old.LifecycleEvent) } // No-op guard: counter must differ from old.payload OR note must be present. payloadDiffers, err := payloadsDiffer(old.Payload, counterPayload) if err != nil { return nil, err } if !payloadDiffers && trimmedNote == "" { return nil, ErrSuggestionRequiresChange } // Authorization on the OLD row: caller must satisfy canApprove (same // gate as Approve/Reject). Self-approval blocks here too. decisionKind, err := s.canApprove(ctx, tx, callerID, old) if err != nil { return nil, err } now := time.Now().UTC() counterJSON, err := marshalJSONOrNull(counterPayload) if err != nil { return nil, fmt.Errorf("marshal counter_payload: %w", err) } // Validate counter has at least one counter-allowlisted field for the // entity type — otherwise the entity-update below would be a no-op // and the new row would just resubmit the SAME values, which is a // degenerate case we should reject cleanly. Only run this check when // the payload "differs" (i.e. caller actually provided something). // Note: validates against the WIDER counter-allowlist (t-paliad-217 // Slice B), not the date-only revert-allowlist. if payloadDiffers { if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil { // buildCounterSetClauses already wraps ErrSuggestionRequiresChange // for the "no allowlisted fields" + empty-title cases. Propagate. return nil, err } } // 1. Close the OLD row as changes_requested. var noteArg any if trimmedNote != "" { noteArg = trimmedNote } updateOldSQL := `UPDATE paliad.approval_requests SET status = $1, decided_by = $2, decided_at = $3, decision_kind = $4, decision_note = $5, counter_payload = $6, updated_at = $3 WHERE id = $7` if _, err := tx.ExecContext(ctx, updateOldSQL, RequestStatusChangesRequested, callerID, now, decisionKind, noteArg, counterJSON, requestID); err != nil { return nil, fmt.Errorf("close old request: %w", err) } // 2. Revert the entity from old.pre_image (same as Reject). if err := s.applyRevert(ctx, tx, old); err != nil { return nil, err } // 3. Deadlock check on the NEW row: someone other than the caller // must be qualified to approve. Original requester is no longer // excluded (they're a regular team member now from the new row's // POV), so they count if their role is sufficient. ok, err := s.hasQualifiedApprover(ctx, tx, old.ProjectID, callerID, old.RequiredRole) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("%w: required role %q", ErrNoQualifiedApprover, old.RequiredRole) } // 4. Re-apply the counter_payload to the entity row (write-then-approve). // Reuses buildRevertSetClauses (date-allowlist translation). Always // runs because we validated payloadDiffers + a valid set of keys // above; even when only a note was provided (payloadDiffers=false), // the original payload is re-applied for symmetry with Submit*. applyPayload := counterPayload if !payloadDiffers { // Counter is identical to original — resubmit the same values as // the new row's payload so the standard Submit* shape holds. if err := json.Unmarshal(old.Payload, &applyPayload); err != nil { return nil, fmt.Errorf("unmarshal original payload: %w", err) } } if err := s.applyEntityUpdate(ctx, tx, old.EntityType, old.EntityID, applyPayload); err != nil { return nil, err } // 5. INSERT the NEW pending row, authored by the caller, with // previous_request_id pointing back at the old row. newID := uuid.New() applyPayloadJSON, err := marshalJSONOrNull(applyPayload) if err != nil { return nil, fmt.Errorf("marshal new payload: %w", err) } insertNewSQL := `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, previous_request_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 'user', NULL, $10)` if _, err := tx.ExecContext(ctx, insertNewSQL, newID, old.ProjectID, old.EntityType, old.EntityID, old.LifecycleEvent, []byte(old.PreImage), applyPayloadJSON, callerID, old.RequiredRole, requestID); err != nil { return nil, fmt.Errorf("insert new approval_request: %w", err) } // 6. Mark the entity pending pointing at the new row. 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(old.EntityType)) res, err := tx.ExecContext(ctx, updateEntitySQL, newID, old.EntityID) if err != nil { return nil, fmt.Errorf("mark entity pending: %w", err) } rows, _ := res.RowsAffected() if rows != 1 { return nil, ErrConcurrentPending } // 7. Emit *_approval_changes_suggested for the OLD row's transition. suggestedEvent := approvalEventType(old.EntityType, "changes_suggested") suggestedDesc := approvalDescription("changes_suggested", old.RequiredRole, old.LifecycleEvent) suggestedMeta := map[string]any{ "approval_request_id": requestID.String(), "new_request_id": newID.String(), "lifecycle_event": old.LifecycleEvent, "decision_kind": decisionKind, old.EntityType + "_id": old.EntityID.String(), } if trimmedNote != "" { suggestedMeta["decision_note"] = trimmedNote } if counterJSON != nil { suggestedMeta["counter_payload"] = json.RawMessage(counterJSON) } if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, suggestedEvent, suggestedEvent, suggestedDesc, suggestedMeta); err != nil { return nil, err } // 8. Emit *_approval_requested for the NEW row (same shape as Submit*). requestedEvent := approvalEventType(old.EntityType, "requested") requestedDesc := approvalDescription("requested", old.RequiredRole, old.LifecycleEvent) requestedMeta := map[string]any{ "approval_request_id": newID.String(), "previous_request_id": requestID.String(), "lifecycle_event": old.LifecycleEvent, "required_role": old.RequiredRole, "requester_kind": "user", old.EntityType + "_id": old.EntityID.String(), } if err := insertProjectEventWithMeta(ctx, tx, old.ProjectID, callerID, requestedEvent, requestedEvent, requestedDesc, requestedMeta); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit: %w", err) } return &newID, nil } // applyEntityUpdate writes the counter_payload fields onto the entity // row (t-paliad-217 Slice B). Uses the WIDER counter-allowlist // (buildCounterSetClauses) — every editable field on the entity, not // just the date-allowlist that triggers approval. Handles // event_type_ids as a junction-table rewrite when present in payload. func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error { if len(payload) == 0 { return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange) } // 1. Column-level updates via the counter-allowlist. setClauses, args, err := buildCounterSetClauses(entityType, payload) if err != nil { return err } if len(setClauses) > 0 { setClauses = append(setClauses, "updated_at = now()") args = append(args, entityID) q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`, entityTableName(entityType), strings.Join(setClauses, ", "), len(args)) if _, err := tx.ExecContext(ctx, q, args...); err != nil { return fmt.Errorf("apply counter payload to entity: %w", err) } } // 2. event_type_ids junction rewrite (deadline only). if entityType == EntityTypeDeadline { if raw, ok := payload["event_type_ids"]; ok { ids, err := parseUUIDList(raw) if err != nil { return fmt.Errorf("%w: invalid event_type_ids: %v", ErrSuggestionRequiresChange, err) } if err := rewriteDeadlineEventTypes(ctx, tx, entityID, ids); err != nil { return err } } } return nil } // parseUUIDList accepts either []any (from json.Unmarshal of a JSON // array) or []string and returns a []uuid.UUID. Empty list = explicit // clear; nil-typed list also empty. func parseUUIDList(raw any) ([]uuid.UUID, error) { if raw == nil { return nil, nil } arr, ok := raw.([]any) if !ok { // Fallback: caller serialized as []string directly. if sarr, ok := raw.([]string); ok { out := make([]uuid.UUID, 0, len(sarr)) for _, s := range sarr { id, err := uuid.Parse(s) if err != nil { return nil, fmt.Errorf("not a UUID: %q", s) } out = append(out, id) } return out, nil } return nil, fmt.Errorf("expected array, got %T", raw) } out := make([]uuid.UUID, 0, len(arr)) for _, v := range arr { s, ok := v.(string) if !ok { return nil, fmt.Errorf("expected string in array, got %T", v) } id, err := uuid.Parse(s) if err != nil { return nil, fmt.Errorf("not a UUID: %q", s) } out = append(out, id) } return out, nil } // payloadsDiffer returns true iff the candidate counter map decodes to a // value that differs from the old row's payload jsonb. Used by // SuggestChanges to detect "no-op suggestion". Both NULL or both empty // map = identical → false. Comparison is by canonical re-marshal so // jsonb-key-ordering doesn't poison the equality check. func payloadsDiffer(old models.NullableJSON, candidate map[string]any) (bool, error) { if len(candidate) == 0 && len(old) == 0 { return false, nil } if len(candidate) == 0 || len(old) == 0 { return true, nil } var oldMap map[string]any if err := json.Unmarshal(old, &oldMap); err != nil { return false, fmt.Errorf("unmarshal old payload: %w", err) } oldCanonical, err := json.Marshal(oldMap) if err != nil { return false, fmt.Errorf("re-marshal old payload: %w", err) } candCanonical, err := json.Marshal(candidate) if err != nil { return false, fmt.Errorf("marshal candidate payload: %w", err) } return !bytes.Equal(oldCanonical, candCanonical), nil } // 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 for the Reject / Revoke path. Only the date-bearing // t-paliad-138 §Q4 allowlist 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). // // This is intentionally NARROWER than buildCounterSetClauses (which // handles the SuggestChanges counter-payload). Reject restores ONLY what // was originally captured in pre_image; SuggestChanges can write any // counter-allowlist field the approver chose to author. 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 } // buildCounterSetClauses translates a SuggestChanges counter_payload jsonb // into SQL SET fragments for the entity row (t-paliad-217 Slice B). This // is the WIDER counter-allowlist — m's 2026-05-20 lock-in: every "real" // editable field on the entity is in scope for a counter-proposal, not // just the date-allowlist that triggers approval (t-paliad-138 §Q4). // // Unknown keys are silently dropped — defence-in-depth against a hostile // counter_payload making it past the handler's body decode. Returns an // error iff zero allowlisted fields are present (caller surfaces as // ErrSuggestionRequiresChange when paired with an empty note). // // event_type_ids is NOT a column on paliad.deadlines — it's a junction // table (paliad.deadline_event_types). applyEntityUpdate handles it // separately; this function silently ignores the key. func buildCounterSetClauses(entityType string, counter 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))) } // addText accepts string keys and stores either a non-NULL string or // NULL when the caller explicitly cleared the value with an empty // string. Used for the optional-text columns (description, notes, // location, etc.). addText := func(col string, raw any) { if raw == nil { args = append(args, nil) } else { s, _ := raw.(string) if s == "" { args = append(args, nil) } else { args = append(args, s) } } setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args))) } switch entityType { case EntityTypeDeadline: // Date allowlist (existing). for _, col := range []string{"due_date", "original_due_date", "warning_date"} { if v, ok := counter[col]; ok { add(col, v) } } // Required text (NOT NULL on the column — refuse empty). if v, ok := counter["title"]; ok { s, _ := v.(string) if strings.TrimSpace(s) == "" { return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange) } add("title", s) } // Nullable text (empty string clears). for _, col := range []string{"description", "notes", "rule_code"} { if v, ok := counter[col]; ok { addText(col, v) } } case EntityTypeAppointment: // Datetime allowlist (existing). for _, col := range []string{"start_at", "end_at"} { if v, ok := counter[col]; ok { add(col, v) } } if v, ok := counter["title"]; ok { s, _ := v.(string) if strings.TrimSpace(s) == "" { return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange) } add("title", s) } for _, col := range []string{"description", "location", "appointment_type"} { if v, ok := counter[col]; ok { addText(col, v) } } default: return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType) } // event_type_ids is handled outside this function (junction-table // write). Its presence alone in the counter doesn't count as "zero // fields" — applyEntityUpdate inspects len(setClauses)==0 against the // combined picture, not this return value. if len(setClauses) == 0 { if _, ok := counter["event_type_ids"]; !ok { return nil, nil, fmt.Errorf("%w: no allowlisted fields in counter for %s", ErrSuggestionRequiresChange, entityType) } } return setClauses, args, nil } // rewriteDeadlineEventTypes replaces the deadline_event_types junction // rows for a deadline with the provided list (t-paliad-217 Slice B). // Empty list clears the junction (the deadline has no event-type tags). // nil list = no-op (caller didn't include event_type_ids in the counter). // // We don't validate the event_type ids exist — the FK to paliad.event_types // catches that with an ON DELETE CASCADE-safe failure. Caller wraps in tx. func rewriteDeadlineEventTypes(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, ids []uuid.UUID) error { if _, err := tx.ExecContext(ctx, `DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil { return fmt.Errorf("clear deadline_event_types: %w", err) } if len(ids) == 0 { return nil } values := make([]string, 0, len(ids)) args := make([]any, 0, len(ids)+1) args = append(args, deadlineID) for i, id := range ids { values = append(values, fmt.Sprintf("($1, $%d)", i+2)) args = append(args, id) } q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` + strings.Join(values, ", ") if _, err := tx.ExecContext(ctx, q, args...); err != nil { return fmt.Errorf("insert deadline_event_types: %w", err) } return 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, requester_kind, agent_turn_id, counter_payload, previous_request_id, 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. // // ViewerCanApprove + ViewerIsRequester are per-viewer eligibility flags // computed against the $1 callerID bound at query time (t-paliad-202). // The frontend uses them to grey out the action buttons it knows the // server would reject, replacing the previous click-then-alert UX. 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"` ViewerCanApprove bool `db:"viewer_can_approve" json:"viewer_can_approve"` ViewerIsRequester bool `db:"viewer_is_requester" json:"viewer_is_requester"` // NextRequestID is the forward-pointer from a changes_requested row // to the new pending row spawned by SuggestChanges (t-paliad-216). // Hydrated via correlated subquery on previous_request_id; the // partial index approval_requests_previous_idx keeps the lookup O(1). // NULL on every row that hasn't been counter-proposed. NextRequestID *uuid.UUID `db:"next_request_id" json:"next_request_id,omitempty"` } // approvalEligibilitySQL is the SELECT-and-WHERE-compatible boolean // expression that returns true iff the user bound to $1 is qualified to // approve the approval_requests row aliased `ar` on the project aliased // `p` (i.e. the SELECT must include `paliad.approval_requests ar JOIN // paliad.projects p ON p.id = ar.project_id`). The three eligibility // branches mirror canApprove (line 484): // // - $1 is global_admin, OR // - $1 has direct/ancestor project_teams membership with responsibility // ∈ {lead, member} AND a profession at or above the threshold // (t-paliad-148 tuple-with-gate), OR // - $1 has partner-unit-derived authority (t-paliad-139). // // Self-authorship is NOT subtracted here — callers add the // `ar.requested_by <> $1` predicate when they want the strict // "can approve" semantics (the inbox WHERE) or fold it into the // SELECT (viewer_can_approve column). Keeping the two predicates // separate lets the same fragment serve both ListPendingForApprover's // filter and the per-row viewer flag without duplicating SQL. const approvalEligibilitySQL = `( 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) ) )` // approvalRequestViewColumns binds $1 = callerID via the two viewer_* // flags. Every caller must pass the caller's UUID as the first arg. 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.counter_payload, ar.previous_request_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, (ar.status = 'pending' AND ar.requested_by <> $1 AND ` + approvalEligibilitySQL + `) AS viewer_can_approve, (ar.requested_by = $1) AS viewer_is_requester, (SELECT nxt.id FROM paliad.approval_requests nxt WHERE nxt.previous_request_id = ar.id ORDER BY nxt.requested_at DESC LIMIT 1) AS next_request_id` 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 predicate (the three branches mirror canApprove and // the viewer_can_approve SELECT expression — same fragment, single // source of truth). approvalEligibilitySQL, } 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, with viewer_can_approve / viewer_is_requester resolved for // callerID. Visibility is gated upstream by the handler (anyone with // project access can see the request). func (s *ApprovalService) GetRequest(ctx context.Context, callerID, requestID uuid.UUID) (*ApprovalRequestView, error) { // $1 = callerID (binds the viewer_* flags); $2 = requestID. q := fmt.Sprintf(`SELECT %s FROM %s WHERE ar.id = $2`, approvalRequestViewColumns, approvalRequestViewJoins) var v ApprovalRequestView if err := s.db.GetContext(ctx, &v, q, callerID, 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 ` + approvalEligibilitySQL 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) }