Document Output Dispatcher Design

Context

AP approvals currently execute DATEV export from the app service after the final approval. The export itself is implemented in com.getorcha.integrations.ap.maesn and records DATEV-specific progress in ap_datev_export_audit.

We want the app to own user-facing workflow state and output intent, while a worker owns external output execution. This first implementation is intentionally narrow: AP final approval should enqueue a document output job, and an SQS worker should execute the existing DATEV export path. The design uses generic names so AR outputs can use the same mechanism later, but AR policy resolution and multi-target output auditing are out of scope.

Goals

Non-Goals

Data Model

Add document_output_job as the generic record of output intent and worker execution state.

Columns:

Enums:

The job table must not include a target. A job means "dispatch this document's outputs." Which systems receive output is a worker/code decision in this phase and later a tenant policy decision. Auditing individual output targets is useful, but out of scope for this implementation.

Useful indexes:

SQS Configuration

Add a fifth queue to resources/com/getorcha/config.edn under :com.getorcha/aws :queues:

Queue attributes should match the current local queue helper posture unless production infrastructure requires the same setting elsewhere:

Worker Integrant config should mirror the existing SQS consumers:

Message body for V1 is the job id as a plain UUID string. This matches the ingestion queue's simple id message style and avoids introducing a message schema before the worker has multiple message kinds. Future message versions can switch to JSON if needed.

The stale-running threshold is intentionally longer than the visibility extension window and several heartbeat periods. It lets a replacement worker recover from a dead worker, while avoiding duplicate export initiation during normal heartbeat jitter.

Flow

Final AP Approval

The approval handler continues to lock approval rows and update the final row in the existing transaction. When the approval transition makes the document fully approved, the same transaction inserts a document_output_job with:

After the transaction commits, the handler sends an SQS message to the document output queue with the job-id.

If the transaction fails, no approval or job is committed. If the transaction commits but SQS send fails, update the job to failed with last_error and surface that dispatch failure through the document detail UI. Stale pending jobs should therefore mean the app committed the job and attempted dispatch, but the process stopped before it could mark send failure; operators can find these rows directly, and a future sweeper can enqueue stale pending jobs if this proves common.

The failure update after an SQS send error must be guarded: UPDATE document_output_job SET status = 'failed', last_error = ?, updated_at = now(), completed_at = now() WHERE id = ? AND status = 'pending'. If the update affects zero rows, the app must not overwrite the current state; it should re-read the job and render the current dispatch state. This prevents an ambiguous send error from marking a job failed after a worker has already claimed it.

Manual DATEV Re-Export

The document detail re-export action should use the same dispatcher path. The handler validates user access and basic document eligibility, inserts a document_output_job with:

After creating the job, the handler sends the SQS message with job-id. If SQS send fails, it marks the job failed with last_error and returns a UI state that reflects the dispatch failure. It should not call maesn/create-booking-proposal! directly.

Manual re-export must reject or surface "already in flight" when the partial unique index prevents creating a second pending job for the same document. It must not enqueue another SQS message for a document with an active pending or running dispatch job.

Document Output Worker

Add an SQS worker namespace following existing worker patterns:

DATEV completion remains asynchronous and continues to be represented by ap_datev_export_audit. The output job records that the output dispatcher accepted and dispatched the configured output work.

The worker should not invent a new concurrency convention in this plan. It should reuse the existing SQS worker pattern: virtual-thread-per-task executor plus SQS visibility extension during long-running work.

Add a nullable dispatch_job_id UUID REFERENCES document_output_job(id) column to ap_datev_export_audit.

When the output worker calls the DATEV connector, the connector should record the job id on the audit row it creates. This gives operators a direct link from the generic output dispatch attempt to the DATEV-specific async task state. Existing manual exports or historical rows can have NULL.

UI and Events

Dispatch-level failures must be visible even when no DATEV audit row exists. Examples include SQS send failure after job creation, missing DATEV integration, or worker-side eligibility failure before maesn/create-booking-proposal! creates an audit row.

Add a Postgres NOTIFY trigger for document_output_job changes on status insert/update. The payload should include:

The document detail SSE handler should react to :output-dispatch by re-rendering the DATEV export section using both:

The DATEV export section should surface dispatch state when it is relevant:

Manual re-export should also use this path. If the app creates a job but fails to send SQS, it should mark the job failed, render the DATEV export section with that dispatch failure, and not pretend that DATEV export has started.

Idempotency

The worker must tolerate duplicate SQS delivery.

Rules:

Before initiating DATEV export, the worker should preserve existing DATEV eligibility behavior and avoid creating a new export when the document already has a non-retryable successful export state.

If a stale running job already has a linked DATEV audit with a task id or a terminal status, the worker should not create another DATEV export. It should complete the dispatch job according to the linked audit state, or leave it for manual repair if the audit state is ambiguous.

Batch Export Removal

Remove AP batch DATEV export entirely:

Manual single-document re-export remains available in the document detail UI, but its execution moves to the document output worker. The required behavior changes for this plan are final-approval export via worker, manual re-export via worker, and removal of batch export.

Schema Redundancy Review

Before adding the migration, review current export-related schema and code. Initial expectations:

Only remove schema that is proven unused and unrelated to DATEV task auditing or DATEV connection state.

Testing

Add or update focused tests:

Open Future Work