Approval System¶
A flexible, multi-tier approval system for FarmCove Delta that supports configurable approval workflows per project for different record types (transactions, contracts, documents, etc.).
Table of Contents¶
- Overview
- Core Concepts
- Architecture
- Configuration Guide
- Approval Flows
- Business Rules
- Permissions
- Database Schema
- Views Reference
- Category Seed Data
- User Interface
Overview¶
The approval system enables project administrators to define approval workflows that records must pass through before being finalized. Key features include:
- Per-Project Configuration: Each project can have different approval rules
- Type/Subtype Granularity: Configure approvals for specific record types (e.g., only Invoices, not Expenses)
- Multi-Tier Workflows: Sequential approval levels with different approvers at each tier
- Conditional Tiers: Tiers can be skipped based on conditions (e.g., amount thresholds)
- Entity-Based Approvers: Approvers are project team members (entities with user accounts)
- Full Audit Trail: Every approval action is tracked with timestamps and notes
- Query/Discussion: Approvers can query records and communicate with submitters
- Soft Approvals: Automatic review workflow for records with processing issues but no formal approval configuration
Architecture Diagram¶
+---------------------------------------------------------------------------+
| PROJECT |
| "Summer Film 2024" |
| approvals_enabled = true |
+---------------------------------------------------------------------------+
|
v
+---------------------------------------------------------------------------+
| APPROVAL CONFIGURATION |
| Type: transactions / Subtype: Invoice |
+---------------------------------------------------------------------------+
|
+-----------------------+-----------------------+
v v v
+-------------------+ +-------------------+ +-------------------+
| TIER 1 | | TIER 2 | | TIER 3 |
| amount > 100 | | amount > 1000 | | amount > 5000 |
| | | | | |
| Approvers: | | Approvers: | | Approvers: |
| - John (Entity) | | - Finance Dir | | - CFO |
| - Jane (Entity) | | (Entity) | | (Entity) |
+-------------------+ +-------------------+ +-------------------+
Core Concepts¶
Approval Configuration¶
Defines that a specific type/subtype combination requires approvals for a project. For example:
- Project "Summer Film 2024" + Type "transactions" + Subtype "Invoice" = Approval required
- Project "Summer Film 2024" + Type "transactions" + Subtype "Expense" = No configuration = No approval needed
Tiers¶
Sequential approval levels within a configuration. Each tier:
- Has a tier number (1, 2, 3...) determining order
- Can have optional conditions that determine if approval is needed
- Has one or more approvers assigned
- Only ONE approval per tier is sufficient (other approvers are marked as skipped)
Conditions¶
Optional rules per tier that determine if approval is needed. Stored as JSONB:
{
"logic": "ANY",
"rules": [
{
"field": "amount",
"operator": "gt",
"value": "1000"
}
]
}
logic: "ANY" (OR) or "ALL" (AND)field: "amount" | "entity" | "country" | "sensitive_data"operator: "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "in" | "not_in" | "is_set"
Multi-value operators (in / not_in) store their selections comma-joined in the rule's value (e.g. "GB,IN"); the evaluator splits on commas (an optional value_list array takes precedence when present).
If conditions are not met, the tier is automatically skipped.
Country conditions (country)¶
country is a multi-select field (valueType 'country', operators in / not_in) over the reference countries list; rules store ISO country codes. The ConditionBuilder pins a special "No country set" option (NO_COUNTRY_OPTION.value = __no_country__, from src/constants/common.ts) at the top of the list so records without a country can be explicitly routed instead of falling through unrouted.
At evaluation time the matched value comes from the record, not the rule: submitTransactionForApproval resolves the direction-aware counterparty entity (inbound → entity_id, outbound → customer_entity_id), reads its address country (entities.address_id → addresses.country_code), and injects it as conditionData.country — falling back to the __no_country__ sentinel when the transaction has no entity, the entity has no address, or the address has no country. A tier whose in rule includes "No country set" therefore matches those records.
Flag fields (sensitive_data)¶
sensitive_data is a flag field (valueType 'flag', operator is_set): a self-contained predicate with no user-entered operator/value — the ConditionBuilder renders only a legend, and the rule's value is '' and ignored. Its truth is supplied at evaluation time, not stored in the rule: a tier with a sensitive_data rule activates only when the record is sensitive. submitForApproval derives this as getRecordRequiredPermissions(recordType, recordId).length > 0 (the record's effective required-permissions, cascade-aware) and threads it as the isSensitiveRecord arg through getApplicableTiers → evaluateTierConditions, which injects it into the rule-evaluation data (flag applied last so the server value always wins).
To add another condition field, extend TransactionConditionField + its field-config and ConditionOperator + its operator-config in src/constants/approval.ts, AND add the operator to the explicit z.enum([...]) in conditionRuleSchema (src/schemas/approvalConfiguration.schema.ts) or validation rejects the rule.
Multi-value fields store comma-joined strings, not value_list¶
entity and country conditions use the in / not_in operators and are edited via MultiSelect. The builder stores the selection comma-joined in the rule's value (e.g. "GB,IN"), NOT in the optional value_list array. The evaluator's getRuleValueList (utils/approval.ts) therefore splits value on commas, preferring value_list only when present. Selectable values (entity UUIDs, ISO country codes, the __no_country__ sentinel) never contain commas, so the split is safe. When adding a new multi-value field, rely on this comma convention — don't reach for value_list.
Condition builder value inputs by valueType¶
ConditionBuilder renders the value control from the field's valueType: currency → CurrencyInput (project currency symbol via project.currency_code), entity/country → MultiSelect (or SearchableSelect for a single entity), flag → legend only, else a number Input. The amount field is currency, not number.
Pending-changes (amber highlight) is driven explicitly via savedConditions threaded from TierEditor (the tier's last-saved conditions). valueHasPendingChanges(rule, index) returns false when the rule matches its saved counterpart, true when it differs, and undefined (fall back to the control's own detection) when there's no saved counterpart. This is required because the tier editor is not remounted on save (see optimistic save below), so the inputs' built-in compare-against-mount detection can never clear itself. CurrencyInput in particular MUST be given an explicit hasPendingChanges — it reformats its display value on focus, which would otherwise trip a false amber. All condition controls also carry bg-background so they render white (not transparent) on load.
Tier save is an optimistic cache update (no refetch remount)¶
useSaveApprovalTiersMutation does NOT invalidate the tierDetails query on success for an edited tier. A refetch swaps the query-data identity and remounts the whole open tier editor — a visible "old values flash" and a reset of every field's pending-changes baseline. Instead the mutation optimistically patches the tierDetails cache in place (onMutate → setQueryData, matched by approval_tier_id), rolls back on error (onError → the snapshot), and only refetches tierDetails in onSettled when a new (temp- id) tier was created — that case genuinely needs the server-assigned id. Callers (TypeTabContent, SubtypeConfigCard) build the optimistic view row with tierEditorStateToDetailsView(tier, identity) and pass it as optimisticTiers. This matches the optimistic onMutate/setQueryData/onError-rollback pattern the other approval config mutations in that hook already use.
Approvers¶
Entities (project team members) assigned to approve at each tier:
- Must have a user account (
user_id IS NOT NULL) - Must have an active project relationship
- One approval per tier is sufficient
Approval Request¶
Created when a record is submitted for approval. Tracks:
- The record being approved (type + ID)
- Current tier being evaluated
- Overall status (Pending, Approved, Rejected, Queried)
- Submitter information
Approval Instance¶
Individual approval assignments per approver. Each instance tracks:
- Which approver is assigned
- Whether the tier condition was met
- The approver's decision and any notes
- Timestamps for all actions
Soft Approval¶
A special approval flow for records that have processing issues but no formal approval configuration. Instead of auto-approving, the system creates a soft approval request for review:
- Trigger: Record has
pendingIssuesCount > 0but no matching approval configuration - Approvers: Users with the
approval:soft:viewpermission (project owners automatically have this) - Single Tier: Soft approvals have only one tier (tier_number = 1)
- No Configuration:
approval_configuration_idis NULL,is_soft_approvalis TRUE - Tab Label: Displayed as "Issues Review" in the approval UI
This ensures records with problems are reviewed by designated users rather than being auto-approved.
The soft-approval path is also where a sensitive record lands when no tier can route it (every applicable tier is emptied by the sensitivity clearance filter, or by self-approval prevention). The soft pool gets the same self-approval + clearance filters applied. See "Unroutable sensitive records" under Business Rules for what happens when even the soft pool holds no clearance-holding approver.
Configuration Guide¶
Step 1: Enable Approvals for Project¶
Set projects.approvals_enabled = true for the project.
Step 2: Enable Type (Optional)¶
Create an approval_configuration with subtype_category_id = NULL to enable the type:
- project_id: Your project
- type_category_id: Category for 'transactions'
- subtype_category_id: NULL (type-level enablement)
Step 3: Create Subtype Configuration¶
Create an approval_configuration for each subtype that needs approvals:
- project_id: Your project
- type_category_id: Category for 'transactions'
- subtype_category_id: Category for 'Invoice'
Step 4: Add Tiers¶
Create approval_tiers for each approval level:
-- Tier 1: Manager approval for amounts > $100
INSERT INTO approval_tiers (approval_configuration_id, tier_number, name, conditions)
VALUES (config_id, 1, 'Manager Approval',
'{"logic": "ANY", "rules": [{"field": "amount", "operator": "gt", "value": "100"}]}');
-- Tier 2: Finance Director for amounts > $1000
INSERT INTO approval_tiers (approval_configuration_id, tier_number, name, conditions)
VALUES (config_id, 2, 'Finance Director',
'{"logic": "ANY", "rules": [{"field": "amount", "operator": "gt", "value": "1000"}]}');
Step 5: Assign Approvers¶
Add approval_tier_approvers for each tier:
INSERT INTO approval_tier_approvers (approval_tier_id, entity_id)
VALUES
(tier1_id, john_entity_id),
(tier1_id, jane_entity_id),
(tier2_id, finance_director_entity_id);
Approval Flows¶
Example 1: $3000 Invoice - Full Approval Flow¶
Setup:
- Tier 1: amount > $100 -> John, Jane
- Tier 2: amount > $1000 -> Finance Director
- Tier 3: amount > $5000 -> CFO
Flow:
- Invoice created ($3000) -> Transaction status: "Processed"
- System finds approval configuration -> Creates approval_request
- Tier 1 Evaluation: $3000 > $100? YES
- Creates instances for John and Jane (status: Pending)
- Transaction status -> "Submitted for Approval"
- John Approves
- John's instance -> Approved
- Jane's instance -> Skipped (reason: "Approved by another approver")
- Move to Tier 2
- Tier 2 Evaluation: $3000 > $1000? YES
- Creates instance for Finance Director (status: Pending)
- Finance Director Approves
- Instance -> Approved
- Move to Tier 3
- Tier 3 Evaluation: $3000 > $5000? NO
- Creates instance for CFO (status: Skipped, condition_met: false)
- No more tiers -> Request status: Approved
- Transaction status -> "Approved"
Example 2: $50 Invoice - Auto-Approved¶
Flow:
- Invoice created ($50)
- System finds approval configuration
- All Tiers Evaluated:
- Tier 1: $50 > $100? NO -> Auto-skip
- Tier 2: $50 > $1000? NO -> Auto-skip
- Tier 3: $50 > $5000? NO -> Auto-skip
- All tiers skipped -> Request status: Approved (no human intervention)
Example 3: Query Flow¶
- John queries the transaction (creates message with approval_request_id)
- John's instance status -> "Queried"
- Request status -> "Queried"
- Message thread continues until clarified
- John approves after clarification -> Flow continues normally
Example 4: Rejection Flow¶
- Finance Director rejects at Tier 2
- Instance status -> "Rejected" with decision_note
- Request status -> "Rejected" with reject_reason
- All remaining instances -> "Skipped" (reason: "Request rejected")
- Transaction status -> "Rejected"
Example 5: Higher Tier Early Approval¶
CFO (Tier 3 approver) can see and approve items still at Tier 1:
- Creates/updates CFO's instance -> "Approved"
- All lower tier instances -> "Skipped" (reason: "Approved by higher tier approver")
- Request -> "Approved"
Example 6: Soft Approval - Transaction with Processing Issues¶
Scenario: An Expense transaction is created but has no approval configuration. However, AI processing flagged an issue (e.g., missing justification).
Flow:
- Transaction created -> AI processing completes with 1 pending issue
- System checks for approval configuration -> None found
- System checks
pendingIssuesCount-> 1 issue found - System creates soft approval:
- Creates
approval_requestwithis_soft_approval = true,approval_configuration_id = NULL - Finds users with
approval:soft:viewpermission - Creates
approval_instancefor each (tier_number = 1, approval_tier_id = NULL) - Transaction status -> "In Approval"
- Users see item in "Issues Review" tab
- User reviews the processing issue and approves/rejects/queries
- On approval -> Transaction status: "Approved"
Key Differences from Regular Approval:
- No
approval_configurationrecord exists - Triggered by processing issues, not configuration
- Single tier only (no multi-tier workflow)
- Approvers determined by permission, not tier assignment
Business Rules¶
Hierarchical Enable Logic¶
Approvals must be enabled at three levels:
1. Project Level: projects.approvals_enabled = true?
+-- NO -> No approvals for anything in this project
+-- YES -> Continue
2. Type Level: approval_configuration (project, type, NULL) exists and is_active?
+-- NO -> No approvals for this type
+-- YES -> Continue
3. Subtype Level: approval_configuration (project, type, subtype) exists?
+-- NO -> No approval needed for this subtype
+-- YES -> Apply tiers from this configuration
Tier Evaluation Rules¶
- Tiers evaluated in order (1, 2, 3...)
- If conditions = NULL/empty -> Tier ALWAYS requires approval
- If conditions not met -> Tier auto-skipped
- Multiple conditions with
logic: "ANY"-> OR (any triggers tier) - Multiple conditions with
logic: "ALL"-> AND (all must match)
Approver Rules¶
- Approvers are entities with
user_id IS NOT NULL - Must have active
project_relationshipon the project - ONE approval per tier is sufficient
- Other approvers marked as skipped when one approves
Sensitive-data clearance = "can the approver see the record"¶
Wherever this document says an approver "holds the required clearance" for a sensitive record, the check is: can that user see the record? — evaluated by the record_is_visible_to_user DB function, which applies the exact per-rule-scope semantics of the RLS predicate row_is_visible_to_caller: an organisation-level sensitive rule is satisfied by the user's organisation-scope clearance, a project-level rule by their project-scope clearance, each rule independently. Non-sensitive records (empty effective required permissions) skip the check entirely; entity-only approvers (no linked user) never clear a sensitive record.
This single definition backs the tier-walk filter (filterApproversByClearance), the soft-pool filter, tier re-evaluation, the decision-time gate (approverHasClearance), and the reconcile's hasEligibleApproverForRecord. Do NOT re-introduce the older approach of flattening every rule's required_permissions into one union and comparing it against project-scope clearance only — that wrongly excluded approvers whose clearance comes from an organisation-scope grant (e.g. organisation:owner holding sensitive_data:organisation:view) even though RLS showed them the record.
Self-Approval Prevention¶
An approver can never approve a record they are a party to — this is automatic and requires no tier condition.
- At submission, the submitting service supplies
ApprovalSubmissionContext.excludedApproverEntityIds: every entity that is a party to the record. For a transaction (getTransactionPartyEntityIdsinutils/transaction.ts) this isentity_id,customer_entity_id, and thereimbursement_relationship/expense_relationshipentity — so it covers supplier/customer invoices AND the person a reimbursement/expense is for (whose entity lives on the relationship, notentity_id). submitForApprovaldrops any tier approver whoseentity_idis in that set (filterOutExcludedApprovers) and records them as aSkippedinstance withskipped_reason = 'Skipped — this approver is named on the transaction and cannot approve their own record'. This runs BEFORE the sensitivity clearance filter.- If a tier's only approver is a party, the whole tier is skipped and routing auto-advances to the next tier (mirrors the clearance-empty-tier behaviour). If NO tier can route, the request falls through to soft approval — where the party is likewise excluded from the soft-approver pool.
- Because exclusion is applied to the right approvers at submission, a manual
entity-condition rule is NOT needed to prevent self-approval. (Theentitycondition still exists for other routing purposes.)
Higher Tier Access¶
- Tier N approvers can see ALL items from Tier 1 to N
- Early approval by higher tier skips all lower tiers
Instances are created up-front; tiers are re-evaluated on each approval¶
All tiers' instances are created at submission (so a higher-tier approver can approve early — see above). Because the record can change while an approval is in flight, each approval re-evaluates the tiers above the approver against the record's current state instead of trusting the frozen submission-time decision (reevaluateUpcomingTiers in approval/base.ts):
- The record's current inputs are resolved via the
buildEvaluationInputsprocessor (registered per record type; for transactionsgetTransactionApprovalEvaluationInputsrebuildsconditionData+ party set from the live transaction). Sensitivity is re-read viagetRecordRequiredPermissions. - For each upcoming tier, eligibility = condition met AND ≥1 approver who is not a party AND holds any required clearance. Newly-eligible tiers that were
Skippedare revived toPending; newly-ineligiblePendinginstances areSkipped. Tiers with an already-decided/queried instance are left untouched. - Example this fixes: tier 2 was skipped at submission because the record was sensitive and its approver lacked clearance; the tier-1 approver removes the sensitive flag and approves — tier 2 is revived and becomes active instead of the request auto-approving past it.
Eligibility gate (decision time)¶
submitApprovalDecision re-checks the acting instance before recording an approval (assertInstanceEligibleToApprove): the tier's condition must currently hold, the approver must not be a party, and they must hold any required clearance — otherwise the decision is rejected. This stops a later-tier approver from approving a tier whose conditions aren't met, while still allowing the intentional "higher tier approves early" flow for eligible tiers. (Reject/query are not gated.)
The clearance check applies to soft (null-tier) instances too, so an uncleared instance-holder can never approve — this is the second layer that backs the unroutable-sensitive flow below.
Unroutable sensitive records (chain cannot clear the record)¶
A sensitive record whose proper approval channel cannot clear it does not silently auto-approve, and raises the sensitive_no_eligible_approver issue (unroutableSensitive: true on the submission result) in any of these cases:
- Clearance-caused fallback — a config's tiers matched but the CLEARANCE filter emptied at least one of them (
causedByClearanceon the sensitivity fallback), so the record reaches the soft pool because of sensitivity. The issue is raised even when cleared soft reviewers exist — the soft pool is a stop-gap, not the configured chain; the issue is the signal to the soft approver (usually the owner) to add a clearance-holding approver to the chain or remove the sensitive flag. The soft request is created from the cleared subset when one exists, else from the full self-approval-filtered pool. - No cleared soft reviewer (no config) — the record entered the soft path directly (no formal config) and not even the soft pool holds a cleared reviewer: the request is created from the full self-approval-filtered pool so the failure stays visible.
- Empty soft pool — there are no soft approvers at all: no request can be created without reviewers, so the record's status is left unchanged and the result carries
submitted: false.
Routing soft purely for non-sensitivity reasons — no config with pending issues, tier conditions unmet, or tiers emptied by self-approval prevention alone — never raises the issue.
In both cases submitTransactionForApproval raises a sensitive_no_eligible_approver processing issue (under a service-role context, since processing_issues has no authenticated INSERT grant; the issue's first-class project_id column scopes the reconcile sweep). This gives two layers that together prevent approval while no eligible approver exists:
- Issue gate (0b) — the issue is non-ignorable (
can_ignore = falseon itsprocessing_issue_templatesrow), so it is included ingetTransactionNonIgnorableIssueKeysand blocks the approve decision until resolved. - Live clearance assert (0c) — even without the issue,
assertInstanceEligibleToApprovere-checks clearance live at decision time, so an uncleared soft instance-holder can never approve. The moment they are granted clearance, they can.
Reconcile and re-route. reconcileUnroutableSensitiveApprovals(projectId, actingUserId) (approval/server.ts) runs TWO sweeps under one service-role client (the acting user may lack RLS access to other users' issues/requests; the actor is recorded via explicit params, never auth.uid()). Each record/request is processed in its own try/catch so one failure never aborts the reconcile.
Sweep 1 — issue-driven. It sweeps every pending issue whose key is in APPROVAL_RECONCILE_ISSUE_KEYS (constants/approval.ts) — the set of keys whose resolution condition is "an eligible approver now exists", currently just sensitive_no_eligible_approver. The sweep is record-type-agnostic: it derives the record type per issue row (issue.processable_type) and dispatches to that type's resubmitForApproval processor via the processor registry, so any record type with a registered processor is handled (only transactions today; the missing-processor branch logs and continues). For each record it re-checks routing eligibility via hasEligibleApproverForRecord — the TIER verdict is authoritative when a config applies (a cleared soft reviewer does NOT resolve the issue; the record must become clearable by its own chain), the soft pool counts only when no config exists, and it short-circuits to eligible when the record is no longer sensitive or no tier's conditions apply. When a record is now routable it:
- cancels the open (Pending/Queried) SOFT request via
cancelApprovalRequest— a no-op for the case that never created one; a FORMAL request is never cancelled (it means a concurrent sweep already re-routed the record), - resolves the blocking issue (
resolveProcessingIssue, resolver = the acting user) — so the audit trail reads naturally: issue resolved alongside the cancellation, before the re-submission, and - re-submits the record via the
resubmitForApprovalprocessor so it re-routes through the proper chain (normal tiers if sensitivity was lifted; the tier chain or cleared soft pool if an approver was granted clearance).
Self-healing is preserved by a re-raise: if the resubmit fails (or no processor is registered for the record type) after the issue was resolved, the SAME issue is re-inserted from the fetched row, returning the record to the reconcile pool for the next trigger event to retry. A resolve failure skips the resubmit entirely, leaving the pending issue to drive the retry.
Sweep 2 — stale clearance-skipped requests. A record can be sensitive under multiple rules (e.g. a project-scope rule on its supplier project_relationship AND an organisation-scope rule on its supplier entity), lifted one rule at a time (the "Mark Not sensitive" button lifts them in one batch call, but Manage Access lifts a single rule). An early lift can resolve the sensitive_no_eligible_approver issue while the record is still (partially) sensitive — because an org-clearance approver can now see it, hasEligibleApproverForRecord returns true — and re-route the record to a formal request whose lower tier is stamped Skipped with a clearance reason (that tier's approver lacks the still-required clearance). The next lift then finds no pending issue, so Sweep 1 is a no-op, and the formal request keeps that lower tier permanently mis-routed — reevaluateUpcomingTiers only re-checks tiers ABOVE an acting approver at decision time, never a tier below the active one. Sweep 2 closes that gap. The two sweeps have independent data sources: a failed Sweep-1 issues fetch does not abort Sweep 2 (Sweep 1 just processes nothing; the returned envelope still carries the fetch error). Sweep 2 calls getOpenClearanceSkippedRequests(projectId) (approval/base.ts) — open (Pending), non-soft approval_requests in the project that carry ≥1 instance matching isClearanceSkippedInstance (utils/approval.ts): status = Skipped with skipped_reason in CLEARANCE_SKIP_REASONS (CLEARANCE_SKIPPED_REASON, CLEARANCE_LOST_SKIPPED_REASON, CLEARANCE_LOST_NO_USER_SKIPPED_REASON, all in constants/approval.ts — note these strings are also user-visible timeline copy; changing them without migrating stamped rows makes old rows invisible to the sweep). For each candidate, guarded:
- Deduped against Sweep 1 — a record already handled above (whatever the outcome; a failed one was re-raised for the next reconcile) is skipped, never double-touched.
- Never-engaged only — skipped if ANY instance is
Approved/Rejected/Queried; someone interacted with the chain and re-routing would destroy in-flight work. This is a deliberate residual: a stale clearance-skipped tier sitting below an engaged tier stays skipped — the request can still complete via its active tier, and unwinding decided/queried work would be worse than the gap. - No-churn change guard —
clearanceRoutingWouldChange(request, instances)(approval/base.ts) returns true only when the routing outcome would actually differ: the record is no longer sensitive at all (empty effective required permissions), OR a clearance-skipped tier would now route — each skipped tier is checked against its CURRENT active config roster (getApprovalTierDetails, falling back to the tier's COMPLETE frozen instance group — not just its clearance-skipped rows — when the tier was deactivated/removed since submission), so both remediations work: lifting a rule that hid the record from an existing approver AND adding a clearance-holding approver to the skipped tier. Non-party (viabuildEvaluationInputs), linked-user approvers only; visibility viarecord_is_visible_to_user. Unchanged → the request is left in place (logged), so repeated reconciles never spam approvers with identical cancel/resubmit cycles. - Resubmit-processor check — the record type's
resubmitForApprovalprocessor is resolved BEFORE cancelling; a type without one is skipped WITHOUT cancelling (cancelling first would orphan the record: request gone, nothing recreated, no issue to re-raise). - Pre-cancel re-check — the request's row + instances are re-fetched fresh immediately before the cancel and both guards re-applied (still
Pending, still never-engaged), shrinking the snapshot→cancel race to a few milliseconds. The cancel itself is unconditional, so that residual TOCTOU window is accepted pending an atomic conditional-cancel RPC.
getOpenClearanceSkippedRequests paginates every fetch to completion and chunks the bulk in filter (CLEARANCE_SWEEP_PAGE_SIZE ≤ PostgREST max_rows, CLEARANCE_SWEEP_ID_CHUNK_SIZE for URL length) — the engaged guard is only safe on a provably complete instance set; a silently truncated response that dropped a decided/queried row would let the sweep cancel an engaged request.
When all guards pass it cancels the mis-routed formal request (reason: "the required sensitive-data clearance changed") and re-submits via the same resubmitForApproval processor. Unlike Sweep 1 there is NO issue row to re-raise on a transient failure — the record would be left In Approval with its old request cancelled and no replacement — so a resubmit failure is logged CRITICAL (the record needs manual re-submission).
Sweep 2 is trigger-agnostic by design: any reconcile trigger (a tier-config save, a permission grant, a sensitivity lift) may re-route stale requests other than the one the acting admin had in mind. That is the invariant — clearance-skipped tiers revive whenever they become routable — not a bug.
The re-submit (both sweeps) runs through resubmitTransactionForApproval (registered on the processor registry so approval/server never imports transaction/server directly). It no-ops on terminal (Cancelled/Rejected) or already-Approved records, and delegates to the idempotent submitTransactionForApproval, so a raced reconcile is harmless — an existing Pending/Queried request short-circuits and issue dedup prevents a duplicate.
Reconcile triggers (all best-effort, non-fatal, logged; a missing acting user is a no-op):
- Permission grants on the project —
createProjectRelationshipWithPermissions,grantAccessToExistingRelationship, andupdateRelationshipPermissionsinproject/server.ts(a grant may newly confer the sensitive-data clearance a record needs). - Approval tier-config saves —
upsertApprovalTiersinapproval/server.ts(a config change may add a cleared tier approver). - Sensitivity lifted at any cascade level —
unmarkRecordSensitiveinsensitive/server.ts, which maps the unmarked record to the affected project(s): a transaction → its project; a project_relationship → itsproject_id; an entity → the projects where the entity participates, pre-filtered to those actually carrying either a pending issue of this key (Sweep 1) or an open clearance-skipped formal request (Sweep 2, viagetOpenClearanceSkippedRequests) — a per-rule lift resolves the issue while the record is still sensitive, so the follow-up lift's project has no pending issue but does have a stale request. Because eligibility is recomputed from the record's effective required permissions (getRecordRequiredPermissions, cascade-aware), lifting sensitivity at any ancestor level counts.
activated_at and timeline dates¶
Because all instances are created at submission, created_at is the submission time for every tier. approval_instances.activated_at records when a tier actually becomes the reached/active tier: set at submission for the first routable tier, and on each approval (as decision_at + 1s, so the activation sorts strictly after the approval that triggered it) as the request advances or a tier is revived. Skipped/not-yet-reached instances stay NULL. v_approval_timeline's assigned event dates from COALESCE(activated_at, created_at), so each tier's assignment shows when it was reached, not the submission moment.
Submitter Rules¶
- For transactions:
created_by_user_idif not internal user - If internal user: use
submission_addressfield - The submitter (who sent the record) CAN be an approver. This is distinct from a party (supplier/customer/reimbursement person) — a party is always excluded (see "Self-Approval Prevention" above).
Query/Discussion¶
- Uses
messagestable withapproval_request_id - Only submitter and assigned approvers can participate
- Query pauses approval at current tier until resolved
- Sends are atomic and stale-guarded. Every query/reply (UI mutation, Sana inbound, email action) goes through the
send_approval_message_atomicRPC (SECURITY INVOKER — seedocs/architecture/database/VIEWS_AND_FUNCTIONS.md): the request row is locked, validated to still be the record's current writable request in the required state (query:Pending→Queried; reply:Queried→Pending), and the message insert + status transition commit together or not at all. A stale id (the request was cancelled and the record re-submitted, e.g. after a document replacement) returns a typed code —APPROVAL_REQUEST_SUPERSEDEDwith the successor's id — as data, never a thrownError(production Flight masks Error instances crossing Server Actions). The client (useSendApprovalQueryMutation/useSendApprovalReplyMutation) runs withretry: 0(sends are non-idempotent), and on a stale code invalidates the record's approval queries and asks the user to act again against the refreshed state — never auto-replays the message. Post-commit side-effects (notifications, record-status sync) are logged on failure but do not fail the send: the message is already durably committed. - One response per query generation. An emailed query reaches several addresses, so both response paths are bound to the query "generation" — the root query message the authorisation was minted against. A reply passes it as
send_approval_message_atomic's trailingp_expected_root_query_message_id; the document-replacement path (which resolves a query by replacing a document, not by sending a message) callsclaim_approval_query_responsewith the same id. Both perform theQueried → Pendingtransition under the same row lock, so a reply and an upload are mutually exclusive — whichever lands first claims the query, and the loser getsAPPROVAL_REQUEST_INVALID_STATE. An authorisation pinned to a superseded generation getsSTALE_QUERY_GENERATIONand writes nothing, so a dormant or in-flight reply link can never answer a LATER query. Passing NULL opts out of the guard, which is why the in-app reply (always acting on the live thread) is unaffected. - Replies from unmatched addresses are attributed, not misfiled. An inbound email reply whose address matches no platform user is recorded under the reserved
external-respondent@farmcove.internalinternal account (never the record submitter), with the real address onmessages.metadata.external_responder_email.v_approval_messagesandv_approval_timelineexpose it as the derivedexternal_responder_emailcolumn, populated only for messages sent by that reserved account.
Service-level query email fan-out¶
sendApprovalQuery (services/approval/server.ts) is the single convergence point — the in-app send and the email approval-review action both reach it — and it emails one notification per address, each with its own authorisation. A shared token would let anyone on the CC line answer, replace documents, and be recorded as the record submitter.
-
Recipient resolution (only when
sendViaEmail). The To address is the UI override, else the submitter's own address; the CC is treated as one address. Each distinct address is resolved byresolveApprovalQueryRecipients(utils/approval.ts) — a pure function fed pre-fetched reads: the submitter's own address binds to the submitter; an address matching exactly one project user binds to that user; anything else (no match, or an address several users share) binds to External Respondent. Addresses dedupe case-insensitively, so a CC repeating the To folds into one send. The reads (submitter row,getProjectUserByEmailper address, the reserved account) run under a narrow service-role elevation — mirroringgetNotificationRecipientReads— because the acting approver can see neither another user'susersrow nor the project's user list under their own RLS. Scope comes from the COMMITTED request'sproject_id, never caller params. A read failure drops that address from the send list and is logged; it is never downgraded to "external". -
Revocation before minting. Once the query message commits and before any token is minted,
revokeQueryResponseTokensForRequestexpires every unused, unexpiredquery_responsetoken whosemetadata.approval_request_idmatches. This is fail-closed: if the revocation cannot be proven to have run, the round sends no email at all (WhatsApp/in-app still go out), because two answerable generations must never coexist. Tokens minted before the top-level key existed are not matched and age out naturally. -
The sends. When the To binds to the submitter (the common case) there is ONE
sendNotificationcarrying every selected channel and the submitter-bound token — byte-identical to the pre-fan-out behaviour. When it binds elsewhere, the submitter's send keeps only WhatsApp (and is skipped entirely for an internal submitter whose external address is an email, whichsendNotificationwould otherwise convert back into a duplicate email), and every email address gets its ownchannels: [EMAIL]send. A known project user is addressed viaemailTowithrecipientUserIdset to that user; an external recipient rides the existing internal-recipient external-address override (recipientUserId= the reserved account,submittedByAddress= the address). Each send is isolated in its own try/catch and the round ends with one aggregate summary log — token URLs and plaintext tokens are never logged. -
Token metadata. Every minted
query_responsetoken carriesroot_query_message_id(the generation it may answer),approval_request_idat the top level (what revocation filters on), and — for reserved-account bindings —recipient_email, the exact address emailed.
Responding¶
handleQueryResponseActionFromToken derives the whole authorisation from the token row: the generation, and whether the token is bound to the reserved account. A reply threads the generation into send_approval_message_atomic and, for a reserved-account binding, stamps external_responder_email onto the committed message — the only attribution such a reply carries. The record-creator notification is suppressed when that address IS the submitter's own email-type submitted_by_address (phone-type addresses are never compared), so a submitter is never notified of their own reply.
The upload path derives the transaction, attachment, project and organisation server-side from the request's record and ignores the browser's copies (mismatches are logged), then routes through the consolidated replacement core below. A token carrying no generation does NOT skip the claim: the core derives the current generation server-side and fails closed if none exists (the pre-generation tokens all expired at the hardening cutover, so the old skip branch was removed as dead).
Queried document replacement (consolidated core)¶
Every flow that replaces a queried record's document — the approval-view re-upload, the issue-resolution and source-document-card re-uploads, the Sana upload reply, the email-token upload, and the duplicate-card "Use as replacement" — drives ONE server function: executeQueriedDocumentReplacement (services/approval/server.ts). Its sequence, and why the order is load-bearing:
- Resolve the processor for the record type from the approval processor registry (
replaceDocument) — before anything is consumed, so an unsupported type refuses without touching the query. - Resolve the generation: the caller-supplied
root_query_message_id, else derived server-side as the latest root query (created_at DESC, id DESC). Unresolvable ⇒ typed refusal, zero writes. - Claim via
claim_approval_query_response(CAS Queried→Pending under the request lock). This is what makes an upload mutually exclusive with a concurrent reply on every route — previously only the email route claimed. - Replace through the record's processor (attachment CAS is the point of no return; full reprocessing enqueued with a bounded in-request retry, a final failure reported as the
REPROCESS_NOT_QUEUEDpartial, never a rollback). - On failure before the attachment CAS, restore the EXACT claimed generation (
restore_claimed_approval_query— ABA-safe). On success, cancel the claimed request server-side (failure =REQUEST_CANCEL_FAILEDpartial, never a rollback). Cancellation is the standard post-replacement state on all routes; the timeline shows it explicitly, and reprocessing re-submits for approval.
Refusals are typed data (ok:false = zero writes); partials ride ok:true and surface as warning toasts. The transaction processor's payload accepts either uploaded bytes or sourceAttachmentId (attachment-row reuse — a new row copying the source's descriptive fields and storage pointer, no storage copy).
Duplicate "Use as replacement" (applyDuplicateDocumentAsQueryReplacement)¶
The duplicate-detection card's action composes the core with a void of the duplicate, staged so every step is either refused cleanly, compensated, or resumable:
- Stage 0 (caller RLS, zero writes): the issue must be current + pending + the potential-duplicate key and belong to the duplicate; the match must carry the
highlabel AND a finite numeric score ≥HIGH_CONFIDENCE_THRESHOLD(a bare< 85fails open on undefined — never reintroduce it); same project, distinct ids; dedicated permissiontransaction:duplicate:replace(seeded to the reject-holding roles + compliance reviewer); the original's current request must be Queried. A duplicate already cancelled WITHOUT this operation's marker refusesALREADY_ACTIONED— a fresh fence would CAS against the observed Cancelled status and overwrite a foreign void's reason. - Stage 1 (fence): one exact-status CAS cancels the duplicate writing
cancel_reasonmarkerUsed as replacement for transaction <code> [dup-replace:<issueId>:<matchedId>]ANDduplicate_of_transaction_idtogether. That PAIR is fence ownership — only this CAS writes both atomically; the plain void action refuses terminal rows precisely so a crafted reason can never be stamped onto a cancelled row. - Stage 2: the core (claim → replace with
sourceAttachmentId→ cancel). A refusal compensates the fence: a marker-matched un-void restoring EVERY captured prior value (status, cancel_reason, duplicate link,updated_by_user_id), re-queuing processing if the duplicate had (or gained) live jobs. A FAILED compensation reportsREPLACEMENT_IN_FLIGHT— the leftover fenced state is exactly what a retry resumes. - Stage 3: the duplicate's void cascades (approval cancel, reconciliations, job settlement, issue resolve + sibling ignore) — idempotent, individually tallied partials, never rolled back.
- Resume: a retry that finds its own marker+link routes here BEFORE any fence call. Request still Queried ⇒ continue from stage 2 (claiming the request open NOW). Swap already committed — detected by storage identity (hash, else bucket-normalised path; NEVER attachment-id equality, the swap copies rows) ⇒ stage 3 only. Request Pending without the swap ⇒
REPLACEMENT_IN_FLIGHT(indistinguishable from a concurrent reply; manual recovery).
Accepted residuals (signed off at review): a crash between claim and compensate leaves the request Pending until manually re-queried (same window as the pre-existing email flow); a reprocess-enqueue failure after retries falls back to the manual reprocess action; a job created AND driven terminal entirely inside the fence window escapes both requeue signals (seconds-wide; manual reprocess recovers).
Rejection¶
- Any approver can reject at any tier
- Rejection ends the approval process
- All remaining instances marked as skipped
reject_reasonstored on bothapproval_requestand the record
Permissions¶
| Permission Key | Description | Scope |
|---|---|---|
| approval_rule:view | View approval configurations | project |
| approval_rule:create | Create approval configurations | project |
| approval_rule:edit | Edit approval configurations | project |
| approval_rule:delete | Delete approval configurations | project |
| approval:view | View approval requests | project |
| approval:approve | Approve/reject/query records | project |
| approval:soft:view | View and action records with processing issues when no formal approval config | project |
Note: The approval:soft:view permission is automatically granted to users with the project:owner role, ensuring at least one approver exists for soft approvals.
Database Schema¶
See database/APPROVAL_SYSTEM.md for complete table definitions.
Tables¶
| Table | Purpose |
|---|---|
| approval_configurations | Enable approvals for type/subtype per project |
| approval_tiers | Define approval levels with conditions |
| approval_tier_approvers | Assign approvers to tiers |
| approval_requests | Track records through approval workflow |
| approval_instances | Track each approver's decision (audit trail) |
Enum¶
- approval_status:
Pending,Approved,Rejected,Skipped,Queried
Changes to Existing Tables¶
- projects: Added
approvals_enabledcolumn - transactions: Added
reject_reasoncolumn - messages: Added
approval_request_idcolumn
Views Reference¶
Configuration Views¶
| View | Purpose | Filter By |
|---|---|---|
| v_project_approval_types | Types enabled per project (toggle switches) | project_id |
| v_project_approval_subtypes | Subtypes with config status | project_id, type_code |
| v_approval_configuration_summary | Config overview with tier/approver counts | project_id |
| v_approval_tier_details | Detailed tier view with approvers | approval_configuration_id |
Request/Instance Views¶
| View | Purpose | Filter By |
|---|---|---|
| v_user_pending_approvals | User's pending approval items | user_id, entity_id |
| v_approval_request_status | Request progress with tier statistics | record_type, record_id |
| v_approval_request_timeline | Full audit trail for a request | approval_request_id |
| v_approval_messages | Discussion thread messages | approval_request_id |
| v_soft_approval_approvers | Users who can approve soft approvals | project_id |
Note: Views now include an is_soft_approval flag to distinguish soft approvals from regular approvals.
v_user_pending_approvals's approver/representative-tier selection is frozen-instance-driven, never live-config-driven. The formal branch's representative row comes from the request's OWN approval_instances (a DISTINCT ON (approval_request_id, user_id) CTE: Pending/Queried instances at or above current_tier_number, current tier preferred → assignment='mine', else lowest future tier → 'lower_tier', deterministic created_at, id tiebreak; max_tier_number reports the instance's frozen tier). This applies to how the user's representative instance/tier is chosen; the total_tiers column is a separate display-only count that still reflects the LIVE configuration (count(*) of active approval_tiers for the request's configuration), not the frozen instances. An earlier definition derived the user's tier from the LIVE config (approval_tier_approvers → max(tier_number)) and joined instances at that live tier — editing a configuration's tier structure while requests were in flight (tier inserted, approvers renumbered) made the join miss, silently hiding those requests from the approver's inbox AND the approve-button gating query (both read this view; one production incident hid 36 of an approver's 40 pending items). Never reintroduce a live-config tier JOIN for instance matching here — the write path (submitApprovalDecision) is frozen-instance-keyed, and the read path must agree with it.
View Usage Examples¶
Get pending approvals for current user:
SELECT * FROM v_user_pending_approvals
WHERE user_id = auth.uid()
AND can_approve = true;
Get approval status for a transaction:
SELECT * FROM v_approval_request_status
WHERE record_type = 'transactions'
AND record_id = 'uuid-of-transaction';
Get full timeline for an approval request:
SELECT * FROM v_approval_request_timeline
WHERE approval_request_id = 'uuid-of-request'
ORDER BY event_at ASC;
Timeline ordering invariant — order tier events by TIER NUMBER, not by timestamp. Two facts make timestamp-based ordering fail: (1) every approval instance is created at submission time, so every
assignedevent for a request shares the samecreated_atdown to the millisecond; and (2) tiers are NOT necessarily decided in tier order — a low tier can be approved AFTER a higher tier was condition-skipped at submission time, so the decision timestamps run backwards relative to the tier numbers. Sorting the timeline byevent_at(even with tier number and phase as tiebreakers) therefore renders out of order: a later-decided low tier drops to the bottom, andSubmittedinterleaves among the tiers that were skipped at submission time.The
ApprovalTimelinecomponent (packages/app/src/components/approvals/ApprovalDetailsContent/components/ApprovalTimeline) owns the deterministic ordering. It groups per tier and computes a single numeric sort key:
- Tier events →
tier_number + phase_offset(assigned0.1before decision0.2). This orders strictly by tier number, independent of the collided / out-of-order timestamps. Every tier's assignment renders immediately before its own decision.Submitted→0, always first.- Query / reply (request-level, mid-flow) → slotted into the band of the tier that was pending at the event's real timestamp: the lowest tier whose decision is missing or happened strictly after the event. The query renders INSIDE that tier's band (
pending_tier + 0.15), i.e. after the tier's "Assigned" row and before its decision — a query almost always arrives after its tier is assigned but before it decides. Comparing only against decision times (assignments all collide at submission and always precede a later query) makes this correct even when tiers are decided out of order: a query at 11:20 with tier 1 approved at 11:23 and tier 2 skipped at 11:18 lands in tier 1's pending band, not misplaced after tier 1.Do NOT re-solve ordering by adding a secondary
ORDER BYon the raw view, by comparing decision event types, or by anchoring the "assigned" event to its tier's decision timestamp (a previous fix did this and broke when tiers were decided out of tier order). Order by tier number.Two supporting facts: (1)
getApprovalTimeline(services/approval/base.ts) orders rows byevent_at ASConly, so rows sharing a timestamp arrive in undefined DB order — never depend on their relative order. (2) A single tier can hold BOTH an approve/reject row AND same-tierskippedrows in the same request: approving/ rejecting auto-skips the tier's other pending approvers (skipped_reason = 'Another approver approved/rejected this tier'). Those same-tier skips are hidden in the timeline (only full-tier skips show); thetiersWithApprovalDecisionset drives that filtering.
Category Seed Data¶
The approval system uses the category system for type/subtype definitions:
Category Group: approval_types
| Code | Name | Parent | Metadata |
|---|---|---|---|
| transactions | Transactions | NULL | {"table_name": "transactions"} |
| Expense | Expense | transactions | is_media=T, is_accounting=T, is_personal_accounting=T |
| Invoice | Invoice | transactions | is_media=T, is_accounting=T, is_personal_accounting=T |
| Payroll Invoice | Payroll Invoice | transactions | is_media=T, is_accounting=F, is_personal_accounting=F |
| Reimbursement | Reimbursement | transactions | is_media=T, is_accounting=F, is_personal_accounting=F |
| Unknown | Unknown | transactions | is_media=T, is_accounting=T, is_personal_accounting=T |
User Interface¶
The approval system provides three main pages for managing approvals, located under Management > Approvals.
Menu Structure¶
| Page | Icon | Purpose |
|---|---|---|
| Pending | CircleDashed | Items awaiting approval |
| Queries | CircleHelp | Discussion threads |
| History | History | Completed approval actions |
Pending Page¶
Displays approval items assigned to the user.
Features:
- Tabs: Grouped by approval type (uses
tab_labelfrom view, pluralized viapluralize_type()) - Badge counts: Number of pending items per tab
- Assignment column: "Mine" or "Lower Tier" badges for filtering
- Default filter: Shows only user's directly assigned items ("Mine")
- Actions: ApprovalButtons component (Approve/Reject/Query)
Data source: v_user_pending_approvals
Queries Page¶
Displays approval queries for discussion.
Features:
- Tabs: Grouped by approval type
- Can Reply column: Indicates if user can respond to the query
- Thread view: Expandable discussion threads
Data source: v_user_approval_queries
History Page¶
Displays user's completed approval decisions.
Features:
- Tabs: Grouped by approval type
- Action column: Badge showing Approved/Rejected/Skipped status
- Decision date and notes: Full audit trail of user's decisions
Data source: v_user_approval_history
ApprovalButtons Component¶
Reusable action buttons component located at src/components/approvals/ApprovalButtons/.
Features:
- Uses ButtonGroup with dropdown for multiple actions
- Priority order for main action: Approve > Query > Reject
- Icons: CircleCheck (Approve), CircleHelp (Query), CircleX (Reject)
- Supports loading states and size variants