Skip to content

Issue Resolution System

This document describes the issue resolution system architecture for FarmCove Delta, which enables tracking and resolving validation issues detected during processing workflows.

Status: Implemented. The database schema, type definitions, service layer (processing/base.ts + processing/server.ts + processing/actions.ts), and UI (issue registry + per-key resolution cards) are all live. Some example payloads below are illustrative; the authoritative field list is the processing_issues table in the AI Processing database doc.

Table of Contents

  1. Overview
  2. System Architecture
  3. Issue Lifecycle
  4. Issue Types and Categories
  5. Creating Issues
  6. Resolving Issues
  7. Service Layer
  8. Integration with Processing System
  9. Implemented Components
  10. UI Patterns
  11. Best Practices
  12. Future Enhancements

Overview

The Issue Resolution System provides a structured way to track, manage, and resolve validation issues that arise during entity processing. Issues can be created by both AI processing steps (when confidence is low) and manual validation steps (when data doesn't meet requirements).

Key Features

  • Flexible Issue Tracking: Track issues for any entity type (transactions, budgets, schedules)
  • Rich Context: Store detailed issue context in JSONB format
  • Suggested Resolutions: System/AI can provide resolution suggestions
  • Resolution Tracking: Complete audit trail of who resolved issues, when, and how
  • Severity Levels: Prioritize issues by severity (critical, high, medium, low)
  • Processing Integration: Seamlessly integrates with the processing system workflow
  • Real-time Updates: Realtime subscriptions for issue status changes

System Architecture

Components

Processing Step (Manual Validation)
    ↓
Issue Detection
    ↓
Create Processing Issue
    ↓
Set Transaction Status to "In Approval"
    ↓
User Reviews Issue in UI
    ↓
User Resolves Issue
    ↓
Update Resolution Data
    ↓
Check All Issues Resolved
    ↓
Continue Approval Process

Data Flow

  1. Issue Creation: Processing steps create issues when validation fails
  2. Status Update: Transaction moves to "In Approval" status
  3. User Notification: User sees issues on "In Approval" transactions with pending issues indicator
  4. Resolution: User provides resolution data
  5. Verification: System verifies all critical issues are resolved
  6. Approval Flow: Transaction continues through the approval process

Issue Lifecycle

Issue States

The live status enum (PROCESSING_ISSUE_STATUS in constants/processing.ts) has exactly three values:

pending → resolved
        → ignored

State Descriptions

  • pending: Issue detected and awaiting resolution
  • resolved: Issue has been fixed/addressed by a user
  • ignored: User chose to dismiss the issue without acting on it. Some issue kinds are non-ignorable (can_ignore = false on their processing_issue_templates row) — those cannot be moved to ignored and block approval until genuinely resolved.

Only pending and resolved/ignored exist — there is no skipped or rejected issue status (those belong to the approval-instance lifecycle, not issues).

State Transitions

Transitions are driven by resolveProcessingIssue(input, userId) (processing/base.ts). Each transition appends a step to the resolution_steps JSONB array (an append-only action-history audit trail) rather than overwriting a single resolution blob, and stamps resolved_by_user_id / resolved_at. The call is a no-op (returns null) if the issue is no longer pending — a concurrency guard, so a second resolver doesn't clobber the first.

// Pending → Resolved (ignoreProcessingIssue is the same call with status: 'ignored')
resolveProcessingIssue(
  {
    id: issueId,
    status: 'resolved',
    resolution_step: {
      /* optional structured detail merged into the appended step's `data` */
    },
    resolution_notes: 'Optional user notes',
  },
  userId
);
// Effect: appends { action: 'resolved', timestamp, user_id, data? } to
// resolution_steps; sets resolved_by_user_id + resolved_at.

Issue Types and Categories

How to read the payloads below. They show each kind's EFFECTIVE shape as a reader sees it, not the stored row. issue_key, issue_title, issue_description, severity and submitter_resolvable are no longer columns on processing_issues — they are resolved from the processing_issue_templates registry row via COALESCE(per-raise override, template default) and exposed under those names (plus effective_*) by v_pending_processing_issues. Only context_data and the four *_override columns are stored per row.

Common Issue Types

1. Data Quality Issues

total_mismatch

{
  "issue_key": "total_mismatch",
  "issue_title": "Total Mismatch",
  "issue_description": "Transaction total does not match sum of line items",
  "severity": "high",
  "context_data": {
    "transaction_total": 875.06,
    "line_items_sum": 850.0,
    "difference": 25.06
  },
  "suggested_resolution": {
    "type": "adjust_total",
    "suggested_value": 850.0
  }
}

description_unclear

{
  "issue_key": "description_unclear",
  "issue_title": "Description unclear",
  "issue_description": "AI couldn't identify specific items - needs clarification",
  "severity": "medium",
  "context_data": {
    "field": "description",
    "current_value": "Equipment",
    "line_item_id": "uuid"
  },
  "suggested_resolution": {
    "type": "manual_entry",
    "placeholder": "e.g., Camera Gimbal Rental"
  }
}

2. Format Issues

format_invalid

{
  "issue_key": "gst_format_invalid",
  "issue_title": "GST Number: Format Issue",
  "issue_description": "Missing country prefix for Indian GST",
  "severity": "medium",
  "context_data": {
    "field": "tax_number",
    "current_value": "92345679",
    "expected_format": "IN followed by 8 digits",
    "country_code": "IN"
  },
  "suggested_resolution": {
    "type": "use_value",
    "value": "IN92345679"
  }
}

3. Matching Issues

vendor_no_match

{
  "issue_key": "vendor_no_match",
  "issue_title": "Vendor: No exact match found",
  "issue_description": "Similar vendors exist in the system",
  "severity": "high",
  "context_data": {
    "field": "billing_entity_name",
    "current_value": "Desert Storm",
    "similar_vendors": [
      {
        "id": "uuid1",
        "name": "Desert Storm Equipment Rentals",
        "confidence": 0.85
      },
      { "id": "uuid2", "name": "Desert Rentals LLC", "confidence": 0.72 },
      { "id": "uuid3", "name": "Storm Props & Equipment", "confidence": 0.65 }
    ]
  },
  "suggested_resolution": {
    "type": "select_from_options",
    "options": ["uuid1", "uuid2", "uuid3"],
    "allow_create_new": true
  }
}

4. Duplicate Detection

duplicate_found

{
  "issue_key": "duplicate_found",
  "issue_title": "Possible Duplicate",
  "issue_description": "Similar transaction found from 3 days ago",
  "severity": "critical",
  "context_data": {
    "similar_transactions": [
      {
        "id": "uuid",
        "transaction_code": "TRX-2024-0122",
        "date": "2024-01-12",
        "amount": 2145.75,
        "vendor": "Desert Storm",
        "similarity_score": 0.95
      }
    ]
  },
  "suggested_resolution": {
    "type": "confirm_or_link",
    "actions": ["not_duplicate", "link_to_existing"]
  }
}

5. Budget Category Issues

budget_category_needed

{
  "issue_key": "budget_category_needed",
  "issue_title": "Line Item 2: Budget category needed",
  "issue_description": "Equipment Transport - $20.69",
  "severity": "medium",
  "context_data": {
    "line_item_id": "uuid",
    "description": "Equipment Transport",
    "amount": 20.69,
    "line_number": 2
  },
  "suggested_resolution": {
    "type": "select_category",
    "ai_suggested": [
      { "category_id": "uuid1", "name": "Transport", "confidence": 0.88 },
      { "category_id": "uuid2", "name": "Equipment Rental", "confidence": 0.65 }
    ]
  }
}

6. Per-Type Fallback Applied (legend on unmatched-linking issues)

When transaction processing can't match a line item to a chart of account or budget item, and the project has a per-type default fallback configured (a project_transaction_fallbacks row, see the PROJECTS database doc), the matching step applies the fallback to the unmatched items and still raises the existing unmatched issue (transaction_unmatched_chart_of_accounts / transaction_unmatched_budget_items). No new issue type is introduced. The issue's context_data carries two extra fields so the issue card can show a "Default fallback applied" legend:

{
  "issue_key": "transaction_unmatched_chart_of_accounts",
  "context_data": {
    "unmatchedLineItems": [{ "lineItemId": "uuid", "description": "…" }],
    "unmatchedCount": 1,
    "totalCount": 3,
    "fallbackApplied": true,
    "fallbackName": "6000 — General Expenses",
    "fallbackId": "uuid-of-coa-or-budget-item"
  }
}

The legend is rendered by FallbackAppliedLegend (under issues/common/), included by both UnmatchedChartOfAccountsContextData and UnmatchedBudgetItemsContextData when fallbackApplied is present. The fallback is only applied when its target still exists in live data (synced COA reference data / the active budget's items); a dangling id is treated as "no fallback configured" and the plain unmatched issue is raised without the legend.

7. System-Raised Issues (outside processing steps)

Not every issue comes from a processing-job step. Some are raised as a side-effect of an application event and carry no processing_job_step_id.

sensitive_no_eligible_approver

Raised when a sensitive transaction's proper approval channel cannot clear it: its config's tiers were emptied by the clearance filter (raised even when cleared soft reviewers exist — the soft pool is a stop-gap, and the issue is the signal to fix the chain or lift the flag), or there is no config and not even the soft pool holds a cleared reviewer. Routing soft for non-sensitivity reasons (no config with pending issues, conditions unmet, self-approval-emptied tiers) never raises it. The record still enters approval where possible so the routing failure is visible, but this issue blocks approval.

  • Raised by submitTransactionForApproval (transaction/server.ts) under a service-role context — processing_issues has no INSERT grant for authenticated, and the record's owner may not hold the permission to write the issue.
  • Its registry row carries submitter_resolvable: false, severity: critical, and can_ignore: false — because it is non-ignorable it is included in getTransactionNonIgnorableIssueKeys and blocks the approve decision until resolved (see APPROVAL_SYSTEM.md "Unroutable sensitive records" for the two-layer gate).
  • The first-class project_id column scopes the reconcile sweep to a single project.
  • Auto-resolved by reconcileUnroutableSensitiveApprovals (approval/server.ts) — never resolved by hand from the UI. This key is a member of APPROVAL_RECONCILE_ISSUE_KEYS (constants/approval.ts), the set of keys the sweep auto-resolves once an eligible approver exists; the sweep is record-type-agnostic (it derives the record type per issue row). It fires on a permission grant, an approval tier-config save, or sensitivity being lifted at any cascade level. Resolution requires the record's OWN channel to become clearable — a cleared TIER approver when a config applies (a soft-pool grant does not resolve it), the soft pool only when no config exists, or the record no longer being sensitive. When it finds a now-routable record it cancels the open soft request, resolves this issue, and re-submits so the record re-routes through the proper chain (a failed resubmit RE-RAISES the issue from the row in hand, returning the record to the reconcile pool — self-healing).
  • Second sweep — stale clearance-skipped requests. reconcileUnroutableSensitiveApprovals runs a follow-up sweep that is NOT issue-keyed, precisely because this issue can be resolved by an EARLIER lift while the record is still sensitive (a multi-rule record lifted one rule at a time). That early resolution re-routes the record to a formal request whose lower tier stays Skipped-for-clearance; the follow-up lift then has no pending issue to key off. The second sweep finds those open, never-engaged formal requests (getOpenClearanceSkippedRequests) and re-routes them only when the clearance picture actually changed (clearanceRoutingWouldChange). See APPROVAL_SYSTEM.md "Unroutable sensitive records" → "Sweep 2" for the full guards.
  • Visibility: the processing_issues RLS policies gate on the row's project_id (membership via v_user_accessible_projects), so a job-less row is visible to project members exactly like any job-chain issue — no processing_job_step_id needed.
{
  "issue_key": "sensitive_no_eligible_approver",
  "issue_title": "No approver with Sensitive Data permission",
  "issue_description": "No approvers with Sensitive Data permission are configured. Grant the permission to an approver, or remove the sensitive flag to proceed.",
  "severity": "critical",
  "submitter_resolvable": false,
  "context_data": {}
}

8. Source Document Totals (three-way decision in OCR extraction)

transaction_source_totals_mismatch ("Document Totals Inconsistent") is raised by processOcrExtraction (transaction/base.ts) — but only as the last of three outcomes. The step first decides which figures the line items should be reconciled against, because a header that fails the balance identity is not by itself evidence of a corrupt document.

The identity checked is |subtotal + tax_total − total| <= TRANSACTION_TOTALS_TOLERANCE (0.03). An omitted tax_total is coalesced to zero for this check: a document printing a subtotal and a total but no tax figure is asserting zero tax. (Reading it as "unknown" instead would fail the check open and pin the line items to a subtotal the document itself contradicts.) A header missing its subtotal or total states too little to test — see the backfill case below.

Fallback-rate isolation. The identity decision reads only header fields, so it is made before the line items are normalized. The per-line fallback tax rate (inferEffectiveTaxRateId) is withheld from exactly one shape: a header that states a full set of figures and contradicts itself. Inferring a rate there would let the suspect figures back-solve the line tax, so the "independent" line evidence weighed below would be derived from the very header it is meant to corroborate. An incomplete header is not suspect — it has no identity to contradict, and a document printing subtotal and tax but no total still names its rate truthfully, so its lines must be allowed to split their tax by it (the inference self-limits, returning null without both a non-zero subtotal and a tax). Normalization still runs exactly once either way.

The line evidence is eligible to describe the document when all of these hold: the extracted lines are non-empty and their sums finite; every line balances internally (|subtotal + tax − total| <= 0.01); the aggregate identity |Σ subtotal + Σ tax − Σ total| holds within tolerance (per-line 1p residuals can accumulate past 3p across four or more lines); the line totals sum to the printed total within tolerance; and the line tax agrees with any printed tax (a printed tax is never erased on line evidence alone).

  1. Header self-consistent → the line items are reconciled against the printed {subtotal, tax_total, total}, exactly as before. No issue.
  2. Header inconsistent, but the line evidence is eligible → the stored header is corrected from the line items and no issue is raised. This is the fee/discount-receipt case: a receipt's printed "Item subtotal" often covers only the priced items and excludes the delivery-fee, service-fee and discount lines that the OCR extracts as line items, so the header identity legitimately fails on a document that is not corrupt. The correction sets tax_total = round2(Σ line tax) and subtotal = round2(total − tax), making the identity exact by construction, and the items are then reconciled against the corrected figures.
  3. Implied-tax-rate refusal. A correction that would store zero tax (the rounded Σ line tax is 0 — a 2p sum is a real tax and is not refused) is rejected when the gap between the printed subtotal and total is explainable as a known project tax rate applied to that subtotal (inferEffectiveTaxRateId({subtotal, tax_total: gap, total}, taxRates) matching a rate with percentage > 0). A receipt whose fee lines were extracted and an invoice whose printed tax was never extracted produce identical evidence in that case, and stamping zero tax on the second would write confidently wrong accounting data. Such documents fall through to outcome 3, which uses the ambiguousDescription wording (the lines DID reconcile, so the default copy would be false).
  4. Incomplete-header backfill. A header with a total but no subtotal is neither trustworthy nor rejectable — the identity has nothing to test. Applying the same eligibility, the same corrected figures are written, backfilling the missing side instead of leaving subtotal at its stored default of 0 (which the later totals check would then report as a mismatch — the same false positive through a different door). The refusal guard structurally cannot apply here: with no printed subtotal there is no gap to test, so a gross-priced document whose tax was never extracted is indistinguishable from a genuinely untaxed one, and total − Σ line tax is the best available evidence. When the evidence is not eligible, the incomplete header falls open exactly as before — no issue is raised and the partial header is passed to reconciliation.
  5. Neither reconcilestransaction_source_totals_mismatch is raised and the line items are persisted unreconciled (not pinned to any header figure) for review. This is also what an inconsistent header with no extracted line items produces. Note this outcome is reachable only from an inconsistent complete header — incomplete headers never raise from this check.

The issue's context_data carries the document figures alongside the line-item evidence that was weighed, so the card can show the two side by side:

{
  "issue_key": "transaction_source_totals_mismatch",
  "issue_title": "Document Totals Inconsistent",
  "severity": "critical",
  "submitter_resolvable": true,
  "context_data": {
    "Subtotal": 100,
    "Tax Total": 10,
    "Total": 130,
    "Subtotal + Tax Total": 110,
    "Difference": -20,
    "Line Items Subtotal": 65,
    "Line Items Tax Total": 0,
    "Line Items Total": 65,
    "Number of Line Items": 2
  }
}

The four Line Items * keys are optional — issues raised before line evidence was weighed carry only the document figures, and SourceTotalsMismatchContextData omits the comparison section (and its line-item claim in the intro copy) for those rather than rendering it empty. The arithmetic rows (Subtotal + Tax Total, Difference) mirror the identity that was evaluated, so an omitted tax contributes zero there too.

This kind has two wordings under one key: the registry row's default description, and an alternative used when the implied-tax-rate refusal is what blocked the correction, which the raise site writes into description_override. (transaction_missing_or_wrong_tax_number uses the same shape for its name-mismatch wording.) An alternative wording for a materially different failure cause is exactly what an override is for — everything else reads the registry default.

Resolution. The issue is not ignorable (can_ignore = false on its registry row) and offers two paths: correcting the totals or line items in place via TotalsMismatchForm (the UI registry entry renders the same form as transaction_total_mismatch, which gates resolution on the totals actually matching), or replacing the document with a clearer copy (can_replace_document = true). Earlier versions presented reupload as the only remedy.

Creating Issues

Issues are created through createProcessingIssues(data) (processing/base.ts), which takes an array of CreateProcessingIssueInput. It is the single chokepoint every raise passes through, and owns three responsibilities no call site may duplicate:

  1. Key resolution. Callers speak registry keys; the issue_template_id FK is stamped here from getIssueTemplateByKey. An unregistered key — or one whose template is registered for a different processable_typeTHROWS. A new issue kind requires a registry seed row in a migration, and raising against the wrong record type is a coding error, not something to persist.
  2. Applicability. For transactions the registered filterIssuesByApplicability processor decides which candidates survive (see DB-driven applicability below), so applicability lives in exactly one place instead of being re-derived by each validator.
  3. Dedup against existing PENDING issues for the same (processable_type, processable_id, issue_template_id) triple (dedupePendingIssues), so a reprocessing flow that re-runs its validators does not stack duplicate pending rows the user must dismiss one by one. resolved/ignored issues are intentionally NOT treated as duplicates — a recurring real issue should re-fire so the user sees it again.

The examples below show the two common sources; the field list matches the live processing_issues schema (there is no processing_job_id column — only the optional processing_job_step_id).

DB-driven issue applicability

The registry table processing_issue_templates replaced the old hardcoded TypeScript issue registry: copy, severity, resolvability, capability flags, applicability and configurability are all DATA now, so changing them is a migration rather than a deploy.

Resolution order (isIssueTemplateApplicable in utils/processing.ts — the ONLY applicability authority):

  1. Two absolute gates first: an inactive template never applies, and a non-configurable template ignores project overrides entirely (they cannot be created for it, but a template demoted from configurable could leave stale rows behind).
  2. For a configurable template, the project's sparse project_issue_settings override for the exact (transaction_type, direction) pair wins if one exists — direction carried only for Invoice, matching the table's cross-column invariant.
  3. Otherwise the template's own applies_to_transaction_types decides: NULL means every type; a non-null list restricts to the listed ones.

The transaction-side evaluator is filterTransactionIssuesByApplicability (transaction/server.ts), registered as the transaction filterIssuesByApplicability processor so the processing service never imports the owning service's base. It resolves the type as follows: a processor that has just written the authoritative type passes it in and that wins; otherwise the transaction is fresh-read — never taken from a processing context cache, which can predate the type write the decision depends on. Direction always comes from the fresh read. Suppressed raises are logged (key, type, direction, reason), never silently dropped: an issue that stops appearing must be explainable from the logs. A processable type with no registered filter (every non-transaction type today) raises everything, which is the pre-registry behaviour.

The same authority drives reconciliation after a type change: getInapplicablePendingIssueTemplateIds tests every PENDING issue against the TARGET type/direction rather than diffing old against new, so legacy rows and race artifacts are reconciled too.

Overrides are for genuine per-raise variation only

The registry owns the default copy, severity and resolvability. A raise site writes title_override / description_override / severity_override / submitter_resolvable_override only when it has a real per-raise variant — a {0}-substituted party label (e.g. "Customer" vs "Vendor" in the data-mismatch title), or an alternative wording for a materially different failure cause. Everything else leaves the override NULL and reads the registry default through COALESCE(override, template default).

The one deliberate exception to "prefer the default": resolved wording is written as an override. Once an issue has been raised with particular wording, a later registry copy edit must not retroactively rewrite what the user was told, so the substituted text is frozen on the row.

processing_issues itself stores no copy of its own beyond those four nullable overrides — issue_key, issue_title, issue_description, severity and submitter_resolvable were dropped from the table as duplicates of the registry.

From Manual Validation Steps

// In a validation processor (planned implementation)
export async function checkTransactionTotals(
  step: ProcessingJobStepWithRelation
): Promise<boolean> {
  const { processing_job_id } = step;
  const transaction = await getTransactionByJobId(processing_job_id);

  // Calculate line items total
  const lineItemsTotal = transaction.items.reduce(
    (sum, item) => sum + item.total,
    0
  );

  // Check if matches transaction total
  const difference = Math.abs(transaction.total - lineItemsTotal);

  if (difference > 0.01) {
    // Create issue
    await createProcessingIssues([
      {
        processable_type: DatabaseTable.TRANSACTIONS,
        processable_id: transaction.id,
        processing_job_step_id: step.id,
        // Only the key is supplied — title, description and severity come
        // from the registry row. Overrides are omitted because this raise has
        // no per-raise variant to express.
        issue_key: TRANSACTION_PROCESSING_ISSUE_KEYS.TOTAL_MISMATCH,
        context_data: {
          transaction_total: transaction.total,
          line_items_sum: lineItemsTotal,
          difference: transaction.total - lineItemsTotal,
        },
      },
    ]);

    // Transaction will be submitted for approval with pending issues
    // The status change to 'In Approval' happens in finishTransactionProcessing

    return false; // Step completed with issues
  }

  return true; // Step completed successfully
}

Note: context_data is the only free-form JSON on an issue. There is no suggested_resolution column — resolution suggestions, when a key has them, are derived by the issue's UI card from context_data.

From AI Processing Steps

export async function processVendorMatching(
  step: ProcessingJobStepWithRelation
): Promise<boolean> {
  const { result_data, processing_job_id } = step;

  if (!result_data) return true;

  const aiResponse = JSON.parse(result_data.message);

  // Check if AI has low confidence
  if (aiResponse.confidence < 0.7) {
    await createProcessingIssues([{
      processable_type: DatabaseTable.TRANSACTIONS,
      processable_id: getTransactionId(processing_job_id),
      processing_job_step_id: step.id,
      issue_key: 'vendor_no_match',
      // Title and description come from the registry. Only severity is
      // overridden, because this raise genuinely computes one that differs
      // from the template default.
      severity_override:
        aiResponse.confidence < 0.5
          ? PROCESSING_ISSUE_SEVERITY.CRITICAL
          : undefined,
      context_data: {
        field: 'billing_entity_name',
        current_value: aiResponse.vendor_name,
        similar_vendors: aiResponse.similar_matches,
        ai_confidence: aiResponse.confidence,
      },
      suggested_resolution: {
        type: 'select_from_options',
        options: aiResponse.similar_matches.map((v: any) => v.id),
        allow_create_new: true,
      },
    });

    return false;
  }

  return true;
}

Resolving Issues

Resolution Data Structures

Each resolution passes an optional resolution_step to resolveProcessingIssue, which is appended (as the step's data) to the issue's resolution_steps array alongside { action, timestamp, user_id }. The shapes below are per-key conventions for that data payload — they are stored inside resolution_steps, not as dedicated columns.

Accept Suggestion

{
  "action": "accepted_suggestion",
  "value": "IN92345679",
  "field": "tax_number"
}

Manual Edit

{
  "action": "manual_edit",
  "field": "description",
  "old_value": "Equipment",
  "new_value": "Camera Gimbal Rental",
  "line_item_id": "uuid"
}

Split Line Item

{
  "action": "split_item",
  "original_amount": 875.06,
  "split_into": [
    {
      "description": "Camera Equipment Rental",
      "amount": 500.0,
      "budget_category_id": "uuid1"
    },
    {
      "description": "Lighting Equipment",
      "amount": 375.06,
      "budget_category_id": "uuid2"
    }
  ]
}
{
  "action": "link_existing",
  "entity_type": "vendor",
  "entity_id": "uuid",
  "field": "billing_entity_id"
}

Create New Entity

{
  "action": "create_new",
  "entity_type": "vendor",
  "entity_data": {
    "name": "Desert Storm Rentals",
    "tax_number": "IN92345679",
    "contact_email": "info@desertstorm.com"
  }
}

Mark as Not Duplicate

{
  "action": "not_duplicate",
  "verified_unique": true,
  "reason": "Different project, different vendor"
}

Service Layer

Processing issue management is implemented as part of the main processing service.

Processing Issue Service (packages/app/src/services/processing/base.ts)

The base functions run against the ambient request-context client (RLS-bound, or a service-role client when the caller wraps them in runWithContext, e.g. for system-raised issues) — they take no explicit client argument. processableType is a DatabaseTable value.

// The single raise chokepoint: stamps the template FK from the caller's key
// (throwing on an unregistered key or a processable-type mismatch), applies
// applicability, then dedupes against existing PENDING
// (processable_type, processable_id, issue_template_id) triples.
export async function createProcessingIssues(
  data: CreateProcessingIssueInput[],
  options?: { transactionType?: TransactionType }
): Promise<ProcessingIssue[] | null>;

// The issue registry. Cached with a bounded TTL (clearIssueTemplateCache()
// drops it); returns INACTIVE templates too, because the evaluator needs to
// see one to refuse it and historical rows still reference them.
export async function getIssueTemplates(): Promise<ProcessingIssueTemplate[]>;
export async function getIssueTemplateByKey(
  key: string
): Promise<ProcessingIssueTemplate | null>;

// A project's sparse applicability overrides. Deliberately NEVER cached:
// they are user-editable, and a stale read would keep raising an issue the
// user has just switched off.
export async function getProjectIssueOverrides(
  projectId: string
): Promise<ProjectIssueSetting[]>;

// Get CURRENT issues for an entity with user + template relations.
// `includeSuperseded: true` drops the is_current filter for history surfaces.
export async function getProcessingIssuesByEntity(
  processableType: DatabaseTable,
  processableId: string,
  options?: { includeSuperseded?: boolean }
): Promise<ProcessingIssueWithRelations[] | null>;

// Count pending, current issues
export async function getPendingProcessingIssuesCount(
  processableType: DatabaseTable,
  processableId: string
): Promise<number>;

// Pending, current issues matching any of the given keys across a project,
// scoped by the first-class project_id column in one query. Record-type-agnostic
// (issue keys are domain-namespaced). Used by the sensitive-approval reconcile
// sweep with APPROVAL_RECONCILE_ISSUE_KEYS.
export async function getPendingIssuesByKeysForProject(
  issueKeys: readonly string[],
  projectId: string
): Promise<ProcessingIssue[]>;

// Resolve or ignore an issue. Appends to resolution_steps; returns null if the
// issue is no longer pending (someone else already resolved it).
export async function resolveProcessingIssue(
  input: ResolveProcessingIssueInput,
  userId: string
): Promise<ProcessingIssue | null>;

// Convenience wrapper: resolveProcessingIssue with status 'ignored'.
export async function ignoreProcessingIssue(
  id: string,
  notes: string | undefined,
  userId: string
): Promise<ProcessingIssue | null>;

There is no requestJustificationForIssue — justification is now handled through the approval query system (see the note under the AI Processing database doc), not a dedicated issue status.

Server & Action Layers (processing/server.ts, processing/actions.ts)

processing/server.ts re-exports the base functions via wrapService (adding logging + the ServiceResponse envelope); processing/actions.ts exposes the write paths (resolve/ignore/create) as Server Actions. There is no separate processing/client.ts. UI reads go through the generic reads route / React Query hooks, and issue changes stream in via a realtime subscription on processing_issues.

Type Definitions (Implemented)

Both input types are inferred from the Zod schemas in packages/app/src/schemas/processingIssue.schema.ts (createProcessingIssueInputSchema / resolveProcessingIssueInputSchema). The resolve input carries a single resolution_step (appended to the issue's resolution_steps array), not a resolution_data blob, and its status is the three-value PROCESSING_ISSUE_STATUS enum:

// From packages/app/src/types/processing.ts
export type CreateProcessingIssueInput = z.infer<
  typeof createProcessingIssueInputSchema
>; // processingIssueSchema minus id/created_at/updated_at/resolved_at/resolved_by_user_id,
// with status, resolution_steps, submitter_resolvable, is_current all optional

export type ResolveProcessingIssueInput = z.infer<
  typeof resolveProcessingIssueInputSchema
>;
// {
//   id: string;
//   status: PROCESSING_ISSUE_STATUS; // 'resolved' | 'ignored' (pending is the start state)
//   resolution_step?: Record<string, unknown>; // appended to resolution_steps
//   resolution_notes?: string;
// }

Transaction Service Integration (illustrative)

The transaction service moves a record to "In Approval" when submission finds pending issues (via the soft-approval path) and re-checks pending-issue counts as issues are resolved. The snippets below are illustrative of the pattern, not verbatim signatures.

// Get transactions with unresolved issues (In Approval status with pending issues)
export async function getTransactionsWithPendingIssues(
  projectId: string
): Promise<ServiceResponse<Transaction[]>> {
  // Transactions in 'In Approval' status may have pending issues
  // Use getPendingIssuesCount to check for issues on each transaction
  return findMany(DatabaseTable.TRANSACTIONS, {
    project_id: projectId,
    status: 'In Approval',
  });
}

// Update status after all issues resolved
export async function updateTransactionStatusAfterIssueResolution(
  transactionId: string
): Promise<ServiceResponse<void>> {
  const { data: pendingCount } = await getPendingIssuesCount(
    DatabaseTable.TRANSACTIONS,
    transactionId
  );

  if (pendingCount === 0) {
    await updateOne(DatabaseTable.TRANSACTIONS, transactionId, {
      status: 'Processed',
    });
  }
}

Integration with Processing System

Processing Flow with Issues

1. Start Processing Job
   ↓
2. Run AI Steps (OCR, Classification, etc.)
   ↓
3. Run Manual Validation Steps
   ↓
4. Manual Step Detects Issue
   ↓
5. Create Processing Issue
   ↓
6. Continue Other Non-Dependent Steps
   ↓
7. Submit for Approval (with pending issues)
   ↓
8. Pause Processing (wait for resolution)
   ↓
[User resolves issues]
   ↓
9. All Issues Resolved
   ↓
10. Entity Status → "Processed"
   ↓
11. Continue Processing or Complete

Template Configuration

-- Example: Add validation step to transaction template
INSERT INTO process_template_steps (
  process_template_id,
  step_key,
  display_name,
  description,
  type,                          -- 'manual' for validation
  execution_group,
  depends_on_steps,
  processor_method,              -- Method that creates issues
  on_failure_action
) VALUES (
  template_id,
  'check_transaction_totals',
  'Validate Totals',
  'Verify transaction total matches line items sum',
  'manual',
  4,
  ARRAY['transaction_ocr_extraction'],
  'checkTransactionTotals',
  'continue'                     -- Continue even if issues created
);

Per-template issue actions at dispatch

A process template may declare, as DATA in process_template_issue_actions, what it does to a record's PENDING issues of a given kind when it is dispatched. applyTemplateIssueActions (processing/base.ts) runs this before the first execution group: the steps the template is about to re-run invalidate the verdicts they previously produced, so leaving those rows pending would let createProcessingIssues' dedup collapse the fresh raise into the stale row.

  • ignoreignorePendingIssuesByTemplateIds: each row goes through resolveProcessingIssue with status ignored, so the dismissal appends to that issue's resolution_steps audit trail exactly like a manual resolution, and a row someone resolved concurrently is neither overwritten nor reported.
  • supersedesupersedePendingIssuesByTemplateIds: the same status transition plus is_current = false, dropping the row from the default list surfaces.

Both write the note Superseded by <template display_name>. This replaced a hardcoded regeneration key list in TypeScript; Transaction Regenerate Line Items is seeded with ignore actions for the five issue kinds derived from the previous line set.

It is hygiene, never a gate. Per-issue failures are isolated into failed_issue_ids rather than thrown, and a failure of the whole hook is logged while the dispatch proceeds.

Job-metadata audit. When any action applied, recordJobIssueActionOutcomes merges the outcome list into processing_jobs.metadata under the applied_issue_actions key (JOB_ISSUE_ACTIONS_METADATA_KEY), preserving every other key the job was created with. Each entry is { issue_template_id, issue_key, action, ignored_issue_ids, failed_issue_ids }. getJobIssueActionOutcomes(jobId) reads it back — an empty array means the template configured no actions (or the job predates the hook), never that the read failed, which throws. summariseTemplateIssueActions (utils/processing.ts) collapses the list into the flat audit shape a step records (keys, ignored_issue_ids, ignored_count, failed_issue_ids, error), keeping the pre-registry payload contract so an audit reader need not know whether the list came from a hardcoded array or a template's configured actions.

Per-project issue settings

Projects override applicability for configurable templates through project_issue_settings, edited on the project's transaction-type settings screen. The table is sparse: a row exists only where the project DIFFERS from the template default, keyed by (project, template, transaction_type, direction) with direction carried only for Invoice.

The screen saves all three surfaces — projects.metadata.transaction, project_transaction_fallbacks and project_issue_settings — through the atomic save_project_transaction_settings RPC, whose signature, validation and prune semantics are documented in VIEWS_AND_FUNCTIONS.md. PostgREST auto-commits per request, so without it a mid-flight failure left the metadata updated and the child rows stale.

Implemented Components

Database Schema

The processing_issues table has the following structure (authoritative column list in AI_PROCESSING.md):

  • processable_type: Type of entity the issue belongs to (a DatabaseTable value, e.g. transactions)
  • processable_id: UUID of the entity with issue
  • processing_job_step_id: Optional link to the step that detected the issue (system-raised issues have none)
  • issue_template_id: NOT NULL FK to processing_issue_templates — the sole carrier of the issue key, and the source of the default title, description, severity, resolvability and capability flags
  • title_override / description_override / severity_override / submitter_resolvable_override: nullable per-raise values. NULL means the registry default applies; effective value = COALESCE(override, template default)
  • context_data: JSON flexible issue context
  • status: pending, resolved, ignored (enum)
  • resolution_steps: JSONB append-only array of resolution actions (audit trail)
  • resolution_notes: Optional notes from the resolver
  • is_current: False when the issue has been superseded — by a document replacement, or by a process template's supersede action (default true)
  • resolved_by_user_id: User who resolved or ignored the issue
  • resolved_at: Timestamp of resolution/ignore

Type Definitions

Located in packages/app/src/types/processing.ts:

  • ProcessingIssue: Base issue type
  • ProcessingIssueWithRelations: Issue with job and step relations
  • CreateProcessingIssueInput: Input for creating issues
  • ResolveProcessingIssueInput: Input for resolving issues

Constants

Located in packages/app/src/constants/processing.ts:

  • PROCESSING_ISSUE_STATUS: pending, resolved, ignored
  • PROCESSING_ISSUE_SEVERITY: critical, high, medium, low

The processable_type values are DatabaseTable enum members (@/constants/database), not a dedicated PROCESSABLE_TYPE constant.

TRANSACTION_PROCESSING_ISSUE_KEYS in constants/transaction.ts is a flat key map only — the keys the app raises, with no copy, severity or behaviour fields. All of that lives in the processing_issue_templates registry. getTransactionNonIgnorableIssueKeys (transaction/server.ts) derives the approval decision gate's blocking set from the registry at read time (can_ignore = false on an active transaction template), so a registry edit takes effect without a deploy.

Schema Validation

Located in packages/app/src/schemas/processingIssue.schema.ts:

  • processingIssueSchema: Zod schema for validation
  • processingIssueWithRelationsSchema: Schema with relations

UI Patterns

Issue resolution UI is implemented. The transaction detail view renders each pending issue through a registry (IssueRegistry) that maps an issue_key to a per-key resolution card; keys without a bespoke card fall back to a generic card that shows the title/description. For example, sensitive_no_eligible_approver renders a SensitiveNoEligibleApproverContent card that adds a permission-gated "Go to approval settings" link (shown only to users holding approval_configuration:view). The layouts below are indicative of the pattern.

In Approval Tab with Pending Issues

Display Structure:

Transaction List (status = "In Approval")
  └─ Transaction Item
      ├─ Basic Info
      ├─ Pending Issues Badge (if has pending issues)
      └─ Click → Opens Transaction Detail with Issues View

Issue Resolution View

Layout:

┌─────────────────────────────────────────────────────────┐
│ Issues to Resolve                     2 of 8 resolved   │
├─────────────────────────────────────────────────────────┤
│                                                         │
│ ┌─────────────────────────────────────┐                │
│ │ Line Item 3: Description unclear    │ [Skip for now] │
│ │ AI couldn't identify specific items │                │
│ │                                     │                │
│ │ [Input: e.g., Camera Gimbal Rental] │                │
│ │                                     │                │
│ │ Amount: $875.06            [Split Item]              │
│ └─────────────────────────────────────┘                │
│                                                         │
│ ┌─────────────────────────────────────┐                │
│ │ GST Number: Format Issue            │                │
│ │ Missing country prefix for Indian GST                │
│ │                                     │                │
│ │ [89234567]  [Use suggestion: IN89234567] ✓          │
│ └─────────────────────────────────────┘                │
│                                                         │
│ ┌─────────────────────────────────────┐                │
│ │ Vendor: No exact match found        │                │
│ │ Similar vendors exist in the system │                │
│ │                                     │                │
│ │ ○ Desert Storm Equipment Rentals  ●                  │
│ │ ○ Desert Rentals LLC                                 │
│ │ ○ Storm Props & Equipment                            │
│ │                                     │                │
│ │ [+ Create "Desert Storm Rentals" as new vendor]     │
│ └─────────────────────────────────────┘                │
│                                                         │
└─────────────────────────────────────────────────────────┘

Issues tab: what shows by default, and what is history

The transaction Issues tab fetches with includeSuperseded: true (useIssueResolution), so it holds a record's FULL issue history, and then IssueResolutionContainer decides what to render. Instances are grouped by issue_template_id (the canonical key; the registry key, then the row id, are fallbacks so an unidentifiable row becomes its own group rather than colliding with every other one) and ordered newest-first by created_at DESC, id DESC — a total order, which both the newest-row fallback and the history list depend on.

Within a group, the cards rendered in the list are:

  • every row still pending — a pending row is never hidden, because a race can create two live instances of one issue and both must stay visible and blocking until each is closed out; plus
  • for a group with NO pending row, its single newest row, so a fully-closed issue still shows its latest state.

Everything else in the group is history, reachable through a History (n) control hung on the newest primary (so n is never duplicated). Primary status is read from status explicitly, not inferred from is_current: superseding writers today also close the row out, but the primary set must hold even if a pending row ever became non-current.

IssueHistoryDialog is strictly read-only — every instance it shows has already been closed out or superseded, so it carries no resolution affordances in any state (no ignore, resolve or query controls exist inside it), and it stays available even while a running processing job holds the live resolution controls closed.

Elsewhere, getProcessingIssuesByEntity defaults to is_current = true only — that is what every gating and counting caller needs, so a superseded row never contributes to a pending count or blocks an approval.

Real-time Updates

// Subscribe to issue updates
const subscription = supabase
  .channel('processing_issues')
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'processing_issues',
      filter: `processable_id=eq.${transactionId}`,
    },
    (payload) => {
      // Update UI with new issue or resolution
      handleIssueChange(payload);
    }
  )
  .subscribe();

Best Practices

Issue Creation

  1. Register the kind first: a new issue key needs a processing_issue_templates seed row in a migration, carrying the title, description, severity, resolvability and capability flags. A raise with an unregistered key throws
  2. Raise with the key alone: let the registry supply copy and severity; write an override only for a genuine per-raise variant
  3. Provide Context: include all relevant data in context_data — it is the only free-form JSON on an issue, and the per-key resolution card derives its suggestions from it
  4. Set applicability in the registry, not in the validator: applies_to_transaction_types (and, for configurable kinds, the project's override) is the single authority, applied at the createProcessingIssues chokepoint
  5. Link to Source: always link to processing_job_step_id when available

Resolution Tracking

  1. Complete Resolution Data: Store full details of how issue was resolved
  2. Add Notes: Encourage users to add resolution notes for audit trail
  3. Verify Changes: After resolution, verify the fix was applied correctly
  4. Update Entity Status: Always update entity status after resolution

Performance

  1. Index Usage: Ensure queries use appropriate indexes
  2. Batch Operations: Resolve multiple issues in a transaction when possible
  3. Real-time Efficiency: Use selective subscriptions with filters
  4. Cleanup: Archive or delete old resolved issues periodically

Security

  1. RLS Policies: Ensure users can only see/resolve issues in their projects
  2. Audit Trail: Never rewrite resolution_steps — it is append-only, preserving the full action history
  3. Validation: Validate a resolution_step payload before applying changes
  4. Authorization: Check user permissions before allowing issue resolution. Some system-raised issues (e.g. sensitive_no_eligible_approver) are non-ignorable and resolved only by an automated reconcile flow, never by hand.

Future Enhancements

  1. Issue Templates: Predefined issue templates for common scenarios
  2. Bulk Resolution: Resolve multiple similar issues at once
  3. AI-Assisted Resolution: AI suggests resolutions based on past patterns
  4. Issue Analytics: Track issue trends and patterns
  5. Workflow Automation: Auto-resolve certain issue types based on rules
  6. Issue Escalation: Escalate unresolved issues after certain time
  7. Mobile Support: Issue resolution on mobile devices
  8. Collaborative Resolution: Multiple users can work on same issue