Kevin McArthur (CSO) at our 2026-05-18 working session: *"there needs to be some kind of staging area where they can see those documents... I want to see a room where I can say, 'Okay, contract. Yeah, got it right. Cool. Go ahead and go home.'"* The /triage queue is that room.
**What lands here.** Every document where `finalized_at` is NULL — newly uploaded docs default to NULL, so every fresh ingest goes through the queue. Pre-D344 documents were backfilled to `finalized_at = created_at` so the existing library doesn't suddenly flood the queue on the day this ships.
**What you see per row.** Each card shows the doc's display name, its docType label (if classified), the content type it maps to (if any), and the list of unsatisfied required fields. Sort is oldest-first — the docs that have been waiting longest float to the top so a paralegal walking through the morning's batch sees a stable order.
**The Ready / Needs split.** The header counters break the queue into two buckets:
- **Ready to finalize** — all content-type required fields are satisfied. One click on the doc page and it's gone from the queue. - **Needs required fields** — at least one required field is empty. Open the doc, fill them via the Metadata panel (the red Required panel shows you exactly which keys are missing), then Finalize.
**Finalize action.** On the doc page, an unfinalized doc shows a "Finalize document" button. Click it and Kodori verifies every required field on the bound content type is satisfied. If anything is missing, the action refuses with a precise error: *"Cannot finalize — 'Fingerprint card' content type requires: Date received, County."* Fix what's missing, click again, done.
**Documents without a content type.** A doc whose `metadata.docType` doesn't map to any live content type CAN be finalized — there's no required-field schema to enforce against. Operators who want every doc to flow through a content type should define one for every common docType they ingest. Operators who just need bulk approve-everything can finalize ad-hoc.
**Where finalize goes in the audit chain.** Each finalize emits a `document.finalized` event with the operator's actor id, optional reason, and the matching docType label. The audit log at /audit?actor=<user> shows every finalize the operator has done — useful for paralegals reviewing each other's work or for compliance audits ("which docs did Bob approve on 2026-05-15?").
**Bulk finalize (D346).** Each Ready-to-finalize row carries a checkbox; the toolbar above the list has a "Select all ready-to-finalize (N)" toggle and a "Finalize N selected" button that approves the batch in one click. Rows with unsatisfied required fields show a dashed-square placeholder instead of a checkbox — they aren't selectable because finalize would refuse them anyway. Click through to the doc page, fill the missing fields, come back to bulk-approve.
Batches cap at 100 docs per call (Vercel function-timeout headroom + a sanity check that an operator who needs more than 100 at once is probably bulk-uploading without enough content-type discipline upstream). After submit, you get an aggregate banner: "23 finalized, 2 already finalized, 1 failed — first error: 'Cannot finalize — Contract content type requires: matterNumber'". Fix the common issue, re-run, and the residual cases work themselves out.
Sequential under the hood — the event store's hash chain needs the head pointer to be current, so parallel writes would either fight or need synchronization that's no cheaper than just sequencing in the tool. Each per-doc finalize still emits its own `document.finalized` event with the operator's actor id, so /audit?actor=<user> still shows exactly which docs they approved on which date even when the actions were submitted as one bulk batch.
**Exceptions area (D349).** When you can't approve a doc right now — the AI got the classification wrong, the doc needs rework, you want to hand it off to someone else — click "Reject → exceptions" on the row. Expand the disclosure, type a reason (required, lands on the audit chain), click Move to Exceptions. The doc leaves the main queue and shows up in the Exceptions tab (`?tab=exceptions`).
Each exception row shows the rejection reason inline alongside who rejected + when, so the next operator opening the doc knows what was wrong without diving into the audit log. The Reopen button on each row sends the doc back to the main queue for re-review — clears the exception state, emits a `document.reopened-from-exceptions` audit event, and the doc shows up at the top of the main queue.
Exceptions stay `status='live'` — the doc isn't deleted, just sidelined. Search still finds it; permissions still apply; the agent can still read it. Reject is "set aside for later," not "throw away." Docs that have been in exceptions for > 90 days are a future signal (cron-driven escalation flag, deferred to a follow-up).
**Bulk reject (D352).** Same multi-select pattern as bulk-finalize, but on the Queue tab and aimed at the opposite direction. Each queue row carries a checkbox; the toolbar at the top of the list has a shared reason field + "Reject N selected → exceptions" button. Type the batch reason once, select the rows that share it, click. Each doc emits its own `document.rejected-to-exceptions` audit event with that shared reason as the rejection text, so per-doc audit trails stay intact even though the operator's intent was batch-level. Cap 100 docs per batch. Useful when the AI mis-classified an entire upload batch ("the OCR was bad on these 30 invoices, we need to rework them before triage").
**Bulk reopen (D359).** The Exceptions tab now mirrors the same multi-select pattern — checkboxes per row + a "Reopen N selected → queue" toolbar button. Most common use case is operator-error recovery: someone batched-rejected the wrong set and wants to undo with one click. The reason field on bulk-reopen is OPTIONAL — the original rejection reason already lives on each doc's audit chain, so the recovery direction doesn't need to re-justify. After bulk-reopen, the banner deep-links back to the main queue since the reopened docs are now there. Cap 100 per batch matches the other two bulk ops.
**Where the queue counts surface (D354).** The /triage badge appears in three places so operators see what's waiting without navigating: (1) the sidebar Triage entry in the Governance nav group carries a tenant-wide unfinalized-count badge (suppressed when 0); (2) the dashboard's TriageBanner surfaces BOTH queue + exceptions counts in one container — main link to /triage for queue items, secondary section with a red "Exceptions" label + deep-link to /triage?tab=exceptions when exceptionCount > 0; (3) the /admin/content-types page surfaces "→ N docs awaiting triage" near the header so an admin tuning content types can jump straight to the queue without going back through the nav. Counts are non-permission-trimmed for navigation speed — the badge tells the operator that work exists; the page itself enforces the read filter.
**What changes when finalize fires.** Only one column flips: `document_objects.finalized_at` goes from NULL to the current timestamp + `finalized_by` gets the actor id. The doc's status stays `live`; permissions stay unchanged; metadata stays as you set it. The doc disappears from /triage at the next render but every other view (Browse, search, the agent, /audit) shows it the same way as before — finalize is an operator-attestation, not a data mutation.