Recourseservicing exception queue

Design notes

Four decisions in this codebase are load-bearing. Each one is a position I would defend, and each one is enforced by a test rather than by a convention someone has to remember.

1. The case has no status column

A servicing case's state is a fold over its append-only event log, computed on read. There is no status field to update.

The alternative — a status column kept in sync with an audit table — has one failure mode, and it is permanent. Any path that writes the status and skips the audit row, or writes the audit row and fails before the status update, leaves the two disagreeing. Nothing detects it. It is found months later by a regulator, or by a borrower who has a screenshot, and by then there is no way to reconstruct which one was lying.

Deriving the state costs a query and removes the possibility. The tests assert that a backfilled older event does not overwrite a newer fact, and that an event kind with no state mapping is recorded without changing anything.

2. Events are append-only, and the database enforces the rest

Event raises on update and on destroy. A fact that happened does not get edited.

Idempotency is a unique index on (lender_id, idempotency_key), not a read-then-write check. Two workers handed the same retried job both pass a "does this exist yet" check and both insert; only one survives a unique index, and the loser is handed the winner's row. The suite replays the same outreach fifty times and asserts one event, and asserts that the same key under a different lender is a different event, because tenancy is part of the key.

3. Imports are idempotent at the file and at the row

Servicing shops re-send tapes constantly: a nightly job fires twice, an analyst re-uploads "the good one". The same bytes for the same lender produce one Import row and write nothing, by unique index on the content digest. Within a run, each row upserts on (lender_id, external_id), and a row whose digest is unchanged is counted and not rewritten — so updated_at stays honest and no spurious "loan changed" event fires downstream.

The three counts the importer returns — created, updated, unchanged — are the reconciliation an operator reads before trusting the run. A run that reports twelve updates on a tape nobody changed is a bug you can see. The imports page lets you paste a tape and watch a replay do nothing.

4. A model reading is evidence; only a person makes it a fact

The classifier writes a Reply row with a typed intent, a confidence, and the verbatim span it relied on. It does not write an event, schedule a draft, or close a case. A servicer promotes it, and the promotion is the event — carrying the confidence and the reply id, so the audit trail records that a model was involved and what it said.

This is the decision the evaluation exists to justify. On the labelled set the model reads "I can do 300 a month, that is genuinely all I have. Take it or take the house" as a promise to pay, at 80% confidence. It is a hardship. Acting on it schedules a draft that returns and charges an NSF fee to the borrower least able to absorb one, which is the harm the product is supposed to reduce. Because the confidence is high, a threshold rule would not catch it. Only the boundary does.

Two guards on the structured output

The model is constrained by a response schema, so the caller gets a typed object or an error — never a paragraph to regex. An intent outside the enum is a failure and is recorded as one, not coerced to the nearest match.

Then both actionable fields are re-derived from the borrower's own text. An amount survives only if its digits appear in the email; a date survives only if the email contains a date-shaped token. A model that confidently supplies a figure the borrower never wrote loses it here. Both guards are tested directly.

When the upstream fails — and it did during this build, on a model deprecation — the classifier degrades to the deterministic baseline, records the error string on the reply, and shows it in the UI. Nothing is silently dropped and nothing is silently wrong.

What is deliberately not here

Stack

Rails 8.1, PostgreSQL, Hotwire, no build step. 40 labelled replies; 37 tests, 251 assertions covering the import idempotency, the append-only guarantees, the derived state, the grounding guards, and the eval harness itself. Source at github.com/tachyurgy/recourse.