Skip to content

RFC — `work_items` State Model

Status: Draft Date: 2026-04-30 Owner: @dboone31 Related PRs: #775 (schema-health drift check), #776 (sender + status backfill, SubmitButton case split) Related design: docs/designs/inbox-is-the-product.md


The unified-inbox work_items table currently has three independent encodings of “what state is this row in” that don’t fully agree:

  1. work_items.status — a 6-value Postgres enum
  2. work_items.archived_at / work_items.deleted_at — nullable timestamps (lifecycle override)
  3. UxState — an 8-value TypeScript union derived client-side in apps/webapp/src/components/inbox/cockpit/lib.ts

Two production bugs in late April 2026 (#775, #776) traced back to those three layers drifting apart. The recent PRs paint over symptoms — the underlying state model needs design.

Original draft proposed Option C (SQL view + new column + two-phase enum drop). After /autoplan dual-voice review and product-side answers to open questions, the recommendation revised to Option A.5 (single-PR fix: CHECK constraint + server-side TS derivation, no view, no column add, but lifecycle write-site cleanup). See “Revised recommendation” section below the original analysis. The Option-A/B/C comparison is preserved as the design trail; Option A.5 is the actual go-forward.


Bug 1 (PR #775). The legacy /orders/pdf/[id]/review page started 500ing on staging because extracted_orders.custom_line_items was in the Drizzle schema but missing in the live DB. CI was migrating a different DB than the webapp container connected to. Fixed by adding a startup schema-health drift check.

Bug 2 (PR #776). After the schema-health fix landed and a stale-row backfill ran, archived inbox rows still rendered “Unknown” senders and a “Processing…” pill. Three distinct root causes piled up:

  • Display fields not backfilled. work_items.sender_name and sender_email are denormalized at row creation, but ~94% of rows on staging had both columns null. [InboxItemRow.tsx:184](apps/webapp/src/components/inbox/InboxItemRow.tsx) renders senderName || senderEmail || 'Unknown', so those rows showed “Unknown”.
  • Status drift on archive. Archive operations historically set archived_at only, never status. 113 rows on staging had archived_at IS NOT NULL AND status IN ('new','in_progress','awaiting_review'). Downstream UI read the stale status and rendered “Processing…”.
  • SubmitButton bucketing. SubmitButton.tsx had a switch with a default that lumped archived/snoozed/processing UxStates into the same animated spinner.

PR #776 patched all three: backfill the display fields, reconcile status for archived rows, and split SubmitButton cases with an exhaustiveness check. None of these fix the model itself.

LayerLives inValuesSet by
Storage statuswork_items.status (PG enum work_item_status)new, in_progress, awaiting_review, submitted, archived, errorBackend pipeline + user actions
Lifecycle overridework_items.archived_at, work_items.deleted_at (timestamps, nullable)timestamp or nullUser actions (archive / delete)
UI vocabularyTS union UxState in apps/webapp/src/components/inbox/types.ts:59-67needs_fix, failed, awaiting_ref_data, ready, processing, submitted, snoozed, archivedDerived in toUxState() at apps/webapp/src/components/inbox/cockpit/lib.ts:59-76

toUxState() resolution order (first match wins):

  1. server-provided item.uxState (future — never populated today)
  2. snoozedUntil > now'snoozed'
  3. archivedAt set → 'archived'
  4. status === 'submitted''submitted'
  5. status === 'error''failed'
  6. status === 'awaiting_review''needs_fix'
  7. status in ('new'|'in_progress'): has extractedOrderId'ready' else → 'processing'
  8. catch-all → 'processing'

S1. Vocabulary mismatch. Storage enum has 6 values, UI has 8. awaiting_ref_data is in the UI union with full visual treatment (lib.ts:97-103) but unreachable from any storage state today — the toUxState() source comment explicitly acknowledges this at lib.ts:50-57.

S2. Redundant lifecycle encoding. 'archived' is both a status enum value AND an archived_at timestamp. The PR #776 backfill (backfill-work-items-display-fields.ts:122-132) had to manually reconcile 113 rows where they disagreed. 'deleted' only lives in deleted_at — never in the enum. Inconsistent.

S3. Consumers don’t actually read status directly. toUxState() reads archivedAt and snoozedUntil BEFORE consulting status. So a status backfill doesn’t surface in the UI for archived/snoozed rows; the backfill only matters for non-cockpit surfaces (InboxItemRow status badge, server-side filter queries).

S4. Brittle cross-casts. awaiting_reviewneeds_fix is a renaming-as-translation. (new|in_progress, has_extracted_order)ready is computed. awaiting_ref_data is unreachable. The mapping has accumulated comments admitting it’s a stopgap.

S5. No CHECK constraint keeping the columns honest. Future code can re-introduce the inconsistency the recent backfill cleaned up. The original migration (0042_add_work_items.sql:41) has a CHECK for num_nonnulls(email_event_id, pdf_document_id, cloned_from_work_item_id) = 1 — a precedent for this kind of invariant — but nothing equivalent for the status/lifecycle columns.

S6. Write-site asymmetry. bulk-actions.ts is now careful — archive sets both archived_at AND status='archived' (bulk-actions.ts:138-145). unarchive sets archived_at=null AND status='new' (bulk-actions.ts:146-154). But soft_delete only sets deleted_at (bulk-actions.ts:124-130). Asymmetric handling — the model doesn’t tell you which fields to update for which transition.


  • Postgres + Drizzle. Schema is in packages/db/src/schema/. Migrations are *.sql under packages/db/src/migrations/, generated via drizzle-kit.
  • Live data on staging. ~400 active rows, including GenFit production data piped through staging for the customer demo. Cannot tolerate a migration that loses or scrambles state.
  • Existing CI/CD. Schema-health drift check (PR #775) runs at webapp startup. Data-maintenance scripts run via the db-maintenance GitHub Actions workflow added in commit 08a38bdc.
  • Backend writes happen from multiple worker contexts. Temporal workers, Astro API routes, Dagster orchestrators. Any write-site changes need to land everywhere status is set.
  • unified_inbox_v2 is feature-flagged. Some users still see the legacy InboxEmail-backed inbox. The work-items table is the eventual single source of truth, but legacy paths still read the same columns.
  • PR base is staging. Per feedback_pr_base_staging.md, this RFC and any follow-up implementation PRs target staging, not main.

Option A — Status quo + CHECK constraint

Section titled “Option A — Status quo + CHECK constraint”

Add a single CHECK enforcing (status = 'archived') = (archived_at IS NOT NULL). Keep everything else unchanged. Optionally add a parallel CHECK to make deleted_at and status consistent (would require adding 'deleted' to the enum or accepting the asymmetry).

Pros

  • Cheapest. One migration, no data backfill (the PR #776 backfill already cleaned the existing rows).
  • No app code changes.
  • Future drift is impossible at the DB layer.

Cons

  • Doesn’t address S1 (vocabulary mismatch) — UI still has 8 values, storage still has 6.
  • Doesn’t address S3 (consumers bypassing status). toUxState() keeps reading archivedAt first.
  • Doesn’t address S4 (brittle cross-casts) — awaiting_review → needs_fix, awaiting_ref_data unreachable.
  • Punts on S6 — soft_delete write asymmetry remains.
  • Adds friction every time we want a new UX state: the CHECK becomes a roadblock for evolving the model.

Verdict. Plug the immediate drift hole, leaves five of six smells. Cheap defensive move, not a design.


Option B — Widen storage enum to match UxState

Section titled “Option B — Widen storage enum to match UxState”

Migrate work_item_status to the full 8-value vocabulary: needs_fix, failed, awaiting_ref_data, ready, processing, submitted, snoozed, archived. Map existing rows during migration:

  • awaiting_reviewneeds_fix
  • errorfailed
  • new / in_progress with extracted_order_id not null → ready
  • new / in_progress without extracted_order_idprocessing
  • submittedsubmitted
  • archivedarchived

toUxState() becomes a no-op (or a thin shim that respects snoozed_until expiry).

Pros

  • Single vocabulary across UI and DB.
  • Easy filter queries (e.g. “show me all needs_fix”).
  • Removes the renaming-as-translation in toUxState().

Cons

  • Conflates two orthogonal axes. “Where did the pipeline get to” (processing, awaiting_review) and “what’s the user’s UX disposition” (snoozed, archived) are different concerns. The current derivation prioritizes snooze over status precisely because they’re orthogonal. Collapsing both into one column loses information — e.g., a snoozed row that was awaiting_review before snoozing now has no record of that.
  • Snooze becomes time-dependent storage. status='snoozed' requires a Temporal cron to flip back when snoozed_until expires. That cron exists today (the work_items_snoozed_idx index suggests it), but it now becomes load-bearing for correctness, not just a polling optimization.
  • Massive migration. Every write site, every status filter, every existing row. Backend pipeline writes today with 'in_progress'/'awaiting_review' need remapping to UX vocabulary that doesn’t fit pipeline semantics.
  • ready requires backend awareness. Today derived from extractedOrderId IS NOT NULL. Promoting it to a status means the extraction pipeline must explicitly transition the row when extraction completes — a write-site cost in apps/email-api and the Temporal extraction workflow.
  • UX vocabulary churns more than storage vocabulary. Coupling them tightly creates pressure to rename DB enum values when the design changes its mind.

Verdict. Solves the vocabulary mismatch but loses the orthogonality that makes the current derivation work. Bigger change for less clarity.


Section titled “Option C — UxState canonical, derived server-side (recommended)”

Treat the three concerns as three different things. Make UxState the canonical user-facing field, but derive it from underlying storage signals so writes still happen against the right column.

Storage layer (kept narrow — pipeline progress only):

  • work_items.status enum reduced to: new, in_progress, awaiting_review, submitted, error. Drop 'archived'.
  • work_items.archived_at, work_items.deleted_at, work_items.snoozed_until remain as today (timestamp lifecycle/snooze).
  • New nullable column work_items.awaiting_ref_data BOOLEAN DEFAULT FALSE — opt-in flag for the existing UX state that’s currently unreachable.
  • New CHECK constraint: archived_at IS NULL OR status != 'archived' becomes vacuously true after the enum change. The new invariant is archived_at IS NOT NULL OR deleted_at IS NOT NULL may be set independently.

Server layer (derives ux_state for clients):

  • A SQL view work_items_inbox (or a ux_state expression projected into existing inbox queries) that computes UxState from the underlying columns using the same logic as today’s toUxState(). Snooze TTL stays time-dependent (now() is fine in a view, just not in a generated column).
  • The view replaces the inline projection in work-item-inbox-query.ts so server filters can WHERE ux_state = 'needs_fix' directly.
  • mapUiStatusToWorkItemStatus() (work-item-inbox-query.ts:21-35) is rewritten to filter on ux_state instead of remapping legacy values onto the underlying enum.

Client layer (consumes ux_state directly):

  • InboxItem.uxState becomes required (not optional). Server populates it.
  • toUxState() becomes a pass-through (or is deleted entirely; clients just use item.uxState).
  • The awaiting_ref_data UX state activates when awaiting_ref_data = TRUE, set by the extraction pipeline when an order needs ERP reference data.

Pros

  • Clear axes. Status = pipeline progress. Timestamp columns = lifecycle. Boolean flags = exceptional pipeline conditions. ux_state = the user-facing read model that combines all three.
  • No drift possible. ux_state is derived from the underlying columns; there’s no second column to disagree with the timestamps.
  • awaiting_ref_data becomes reachable without destabilizing the rest of the model.
  • Storage stays stable even when UX vocabulary evolves. Designers can rename 'needs_fix''fix_required' by changing the view + UI; no DB migration.
  • Snooze stays orthogonal. A snoozed row that was awaiting_review before snoozing keeps status='awaiting_review' AND snoozed_until — no information loss.
  • Filter queries simplify. WHERE ux_state = 'needs_fix' is a single condition. Today’s mapUiStatusToWorkItemStatus() table goes away.

Cons

  • Two-phase migration (drop enum value, add view, update read paths). Bigger than Option A but smaller than Option B.
  • Adds a SQL view to the architecture. Team needs to understand the convention.
  • ux_state cannot be a Postgres GENERATED column because it depends on now() (snooze TTL). Must be a view or an inline expression. (Same constraint Option B has — and Option B has worse problems.)
  • New awaiting_ref_data flag requires extraction pipeline awareness to populate. If we ship without that, the column stays unused but the UX state still exists for future use.

Verdict. Aligns with the stated future direction (uxState was always intended as a backend-provided field — see lib.ts:38, lib.ts:50-57, types.ts:113-114). Cleanest separation of concerns. Migration is incremental and reversible.


Recommendation (original draft — SUPERSEDED)

Section titled “Recommendation (original draft — SUPERSEDED)”

⚠️ Superseded by “Revised recommendation” further down. Kept here as the design trail. /autoplan dual-voice review flagged factual errors (view-can-be-indexed claim) and migration risk (mixed-version Temporal workers); product confirmed awaiting_ref_data has no owner. Skip ahead to Revised recommendation for the actual go-forward.

Option C (original recommendation).

Rationale:

  1. The three layers are three different things and should stay that way. Pipeline progress, lifecycle disposition, and UX disposition are independent axes. Conflating them (Option B) loses information; ignoring the conflation (Option A) leaves the smells.
  2. The codebase already encodes Option C as intent. InboxItem.uxState is optional and documented as “backend-provided when available; otherwise derived.” toUxState() already short-circuits when item.uxState is set. We’re finishing a migration that’s been latent in the code.
  3. Future-proofs awaiting_ref_data. This UX state has design treatment but no path to the user. The flag column is a small addition that unlocks the existing UI work.
  4. Smallest blast radius for the schema change. Removing one enum value is a known Drizzle pattern; the rest of the change is read-side (view + client refactor).

Two phases, both safely landable on staging without coordinated app/DB cuts.

Phase 1 — Add CHECK + view (paint the new model on top, no destructive changes)

Section titled “Phase 1 — Add CHECK + view (paint the new model on top, no destructive changes)”
  1. Add Drizzle migration that:

    • Adds nullable awaiting_ref_data BOOLEAN DEFAULT FALSE column to work_items.
    • Adds CHECK constraint (status = 'archived') = (archived_at IS NOT NULL). (Captures the invariant that PR #776 backfilled.)
    • Adds CHECK constraint deleted_at IS NULL OR archived_at IS NOT NULL if we want delete-implies-archived (open question — see below).
    • Creates view work_items_inbox projecting all columns plus a derived ux_state text column using the same logic as today’s toUxState().
  2. Update inbox query module (work-item-inbox-query.ts) to read from work_items_inbox. mapUiStatusToWorkItemStatus() becomes mapUiStatusToUxState(). Filter conditions migrate from eq(workItems.status, ...) to filters on the view’s ux_state.

  3. Update server projections that build InboxItem to populate uxState from the view.

  4. Update client to prefer item.uxState (already supported via the if (item.uxState) return item.uxState; short-circuit at lib.ts:60). toUxState() becomes a thin compatibility wrapper for legacy callers.

  5. Add data-maintenance script packages/db/scripts/verify-work-items-state-invariants.ts modeled on backfill-work-items-display-fields.ts. It reports counts for:

    • Rows where status='archived' AND archived_at IS NULL (should be 0 after CHECK).
    • Rows where archived_at IS NOT NULL AND status != 'archived' (should be 0 after CHECK).
    • Distribution of ux_state values (sanity baseline).

    Wired into the db-maintenance workflow to run on every deploy.

  6. Update schema-health drift check (PR #775) to verify the view exists with the expected columns.

Rollout: PR against staging. Migration is additive; rollback = drop the view + column. Check constraint is the only tricky part; if any rows still violate it (despite PR #776 backfill), Phase 1 rolls back cleanly and we re-run the backfill.

After Phase 1 has soaked on staging for a deploy cycle (~1 week, GenFit demo cadence), and the schema-health probe confirms 0 invariant violations:

  1. Migrate write sites to set only archived_at on archive (drop the status: 'archived' SET clause). Touch points:

    • bulk-actions.ts:138-145 — archive case
    • bulk-actions.ts:146-154 — unarchive case (set status back to whatever the pipeline state was before archive — open question, see below)
    • Any Temporal workflow or Dagster step that sets status='archived'
  2. Drizzle migration to recreate work_item_status enum without 'archived'. Standard Postgres pattern: create new enum, alter column, drop old enum.

  3. Drop the now-redundant CHECK constraint added in Phase 1.

Rollout: Separate PR against staging. Reversible — re-add the enum value if needed. Soak before promoting to main.

Phase 3 — Activate awaiting_ref_data (optional, separate RFC if scoped)

Section titled “Phase 3 — Activate awaiting_ref_data (optional, separate RFC if scoped)”

When the extraction pipeline knows how to detect “needs ERP reference data sync”, it sets awaiting_ref_data = TRUE on the work item. The view auto-projects ux_state = 'awaiting_ref_data'. UI already has the visual treatment. No further changes needed.

This is intentionally scoped out of the current RFC — the trigger condition is a product question, not a model question.


  • Schema-health drift check. Extend the startup probe (PR #775) to verify the view exists and has the expected columns. Fails fast if a deploy lands without the view.
  • Invariant probe. New script verify-work-items-state-invariants.ts runs on every deploy via the db-maintenance workflow (added in commit 08a38bdc). Logs invariant counts; alerts if any drift.
  • Distribution sanity baseline. First run of the invariant probe captures the staging ux_state distribution. Subsequent runs flag drift greater than ±10% in any bucket.
  • Backfill PR #776 data backfill stays in place. After Phase 1, it’s idempotent — the CHECK prevents new drift, but the backfill is a safety net for any pre-existing rows.
  • Soak window. ~1 week on staging before promoting Phase 1 to main. Phase 2 follows ~1 week after Phase 1 has been clean on staging.
  • Manual smoke test. Each phase: archive a work item via the inbox UI, confirm it’s ux_state='archived', unarchive, confirm it’s ux_state='ready' (or whatever the pipeline state implies). Repeat for snooze + soft_delete.

Revised recommendation (post-review, 2026-04-30)

Section titled “Revised recommendation (post-review, 2026-04-30)”

Adopt Option A.5 — single-PR change that closes the bug class and the architectural complaint without enum-drop risk on live data.

  1. CHECK constraint — one-directional, not biconditional:

    ALTER TABLE work_items
    ADD CONSTRAINT work_items_archived_status_consistent
    CHECK (status != 'archived' OR archived_at IS NOT NULL)
    NOT VALID;
    ALTER TABLE work_items VALIDATE CONSTRAINT work_items_archived_status_consistent;

    Why one-directional: the timestamp-only archive design (step 5 below) sets archived_at without setting status='archived'. A biconditional CHECK (status='archived') = (archived_at IS NOT NULL) would reject every new archive. The one-directional CHECK says “if status is the legacy ‘archived’ value, archived_at must agree” — it permits new archives where archived_at is set but status is its real pipeline value (e.g. awaiting_review). Legacy rows (status=‘archived’ AND archived_at IS NOT NULL) satisfy it.

  2. Add uxState field to InboxItem type at apps/webapp/src/components/inbox/types.ts. It’s optional today (uxState?: UxState); make it required after server projection lands. Add a 'deleted' value to the UxState union so soft-deleted rows have a derivation target (deleted-view rows currently return 'archived' from the existing function — that’s the wrong label).

  3. Server-side ux_state derivation in apps/webapp/src/lib/inbox/work-item-inbox-query.ts (or a new helper imported by it). Each InboxItem returned to the client carries a populated uxState. Resolution order (deleted first):

    • deleted_at IS NOT NULL'deleted'
    • snoozed_until > now()'snoozed'
    • archived_at IS NOT NULL'archived'
    • status='submitted''submitted'
    • status='error''failed'
    • status='awaiting_review''needs_fix'
    • status IN ('new','in_progress')'ready' if extracted_order_id IS NOT NULL, else 'processing'
    • status='archived' (legacy rows) → 'archived' (catch-all)
  4. Delete toUxState() at apps/webapp/src/components/inbox/cockpit/lib.ts:59-76 outright. Client reads item.uxState directly. Tests at apps/webapp/src/components/inbox/cockpit/lib.test.ts move to test the server-side derivation function.

  5. Drop awaiting_ref_data from the UxState union and all visual treatments (per “Lifecycle decisions” below). And add 'deleted'. Net change to UxState: drop awaiting_ref_data, add deleted. Both ripples touch the same files:

    • apps/webapp/src/components/inbox/types.ts — union update
    • apps/webapp/src/components/inbox/cockpit/lib.ts — drop awaiting_ref_data from STATE_PRIORITY + STATE_META; add deleted entries
    • apps/webapp/src/components/inbox/cockpit/SubmitButton.tsx — drop awaiting_ref_data case; add deleted case (open question: what’s the CTA for a deleted row in the trash view? “Restore” makes sense given existing RowActionEvent.restore. See open questions below.)
    • apps/webapp/src/components/inbox/cockpit/lib.test.ts — drop awaiting_ref_data test case; add deleted test cases
    • The TS exhaustiveness check (const _exhaustive: never = uxState) at SubmitButton.tsx will fail at compile time if a case is missed — guarantees the implementer can’t ship the change incomplete.
  6. Lifecycle write-site cleanup: archive and unarchive become timestamp-only operations across all write sites. No site sets status='archived'. After this lands, 'archived' becomes unreachable as a status value for new rows; the enum can optionally be tightened in a follow-up PR.

    Sites to touch (full inventory — 13 sites from /autoplan + INSERT paths):

    Status-mutating (archive/unarchive paths to make timestamp-only):

    Status-mutating (other transitions — no change needed, listed for completeness):

    Lifecycle-only (snooze/delete — already correct):

    Combined transitions to AUDIT (status + archive together):

    INSERT-only paths (verify they don’t conditionally set status='archived'):

  7. Soft-delete TTL implementation (separate but adjacent change — could be same PR or follow-up):

    • Lifecycle filter at work-item-inbox-query.ts:114 adds AND (deleted_at IS NULL OR deleted_at > now() - INTERVAL '30 days').
    • Optional: Temporal cron at e.g. 1 year that hard-purges old soft-deleted rows.
  8. Invariant probe — runs via db-maintenance workflow on each deploy. Reports counts for the CHECK invariant (should be 0) and ux_state distribution (sanity baseline).

  9. Read-side audit — the following code reads status directly and must be reviewed for semantic compatibility once new archives stop setting status='archived':

    • apps/webapp/src/pages/api/work-items/dashboard.ts — counts status='archived' and groups it with submitted for completion metrics. After A.5 lands, this count drops monotonically (only legacy rows remain). Decide: is “archived” a completion signal or a lifecycle signal? Update the metric accordingly.
    • apps/webapp/src/lib/inbox/work-item-inbox-query.ts:6-13WORK_ITEM_STATUSES set still includes 'archived'. Stays for the legacy preset path; remove only when verified unused.
    • Test fixtures (SupervisorKanban.test.tsx, ConservativeInbox.test.tsx, supervisor/lib.test.ts) — fixtures using status='archived' still work but are testing legacy semantics. Update or delete in the same PR for clarity.

The one-directional CHECK requires status != 'archived' OR archived_at IS NOT NULL (legacy rows where status=‘archived’ must have archived_at set). PR #776’s backfill-work-items-display-fields.ts Step 4 already satisfied this invariant. Plan:

  1. Re-run PR #776 backfill in the same migration deploy, BEFORE the CHECK is added. Idempotent — only updates rows that drifted since the last run. Catches anything that landed between PR #776 and this PR.

  2. No new backfill needed for legacy archived rows. Rows where status='archived' AND archived_at IS NOT NULL are valid under the new CHECK and the new derivation: lifecycle precedence puts them in ux_state='archived' regardless of status. They lost their pre-archive pipeline state historically; that information is unrecoverable.

  3. No backfill needed for deleted_at TTL. Lifecycle filter handles existing rows correctly — any deleted_at older than 30 days is hidden from the deleted view. No data migration.

  4. No backfill needed for ux_state. Derived per-query in TypeScript; nothing stored.

  5. Optional one-time normalization (not recommended): a script to update legacy status='archived' rows to a sentinel pipeline state (e.g. in_progress) so the legacy enum value becomes truly unreachable. Lossy — drops the historical fact that the row was archived in some prior pipeline state. Defer until enum-tightening follow-up PR if/when that ships.

If the team later wants to “reclaim” pipeline state for legacy archived rows, that’s a separate one-time analysis using email_events/extracted_orders history — out of scope here.

All apps/webapp/src/components/inbox/cockpit/* and packages/db/scripts/backfill-work-items-display-fields.ts references in this RFC reflect the post-PR-#776 state on the dazzling-heisenberg-2e506a worktree (or its eventual merge into staging). The current keen-curran-425b90 worktree where this RFC was drafted predates those PRs and does not contain the cockpit subdirectory or the backfill script. Implementers should base the implementation PR on origin/staging after PR #775 + PR #776 have landed there.

Single PR, 2-3 days CC effort (revised up from “1 day” — the cockpit refactor + write-site audit + INSERT path verification + read-side audit add up):

WorkEffort
Drizzle migration (CHECK NOT VALID + VALIDATE)0.25 day
Server-side ux_state derivation function + tests0.5 day
Add uxState to InboxItem, add 'deleted' to UxState, drop awaiting_ref_data0.25 day
Delete toUxState(), migrate client call sites to item.uxState0.5 day
Write-site refactor (5 archive sites + audit 4 INSERT paths + 1 combined transition)0.75 day
30-day soft-delete TTL filter0.25 day
Read-side audit (dashboard.ts, WORK_ITEM_STATUSES, test fixtures)0.25 day
Invariant probe script + db-maintenance wiring0.25 day
Total~3 days

Add buffer if mid-implementation surfaces new write sites or test failures.

  • No SQL view (the original Option C centerpiece).
  • No new awaiting_ref_data column (no product owner).
  • No enum migration (no Phase 2 risk).
  • No two-phase deploy choreography.
  • Closes the bug class. The CHECK prevents the drift that caused PR #776’s archived-row “Processing” pill.
  • Closes S3 (consumers bypass status). Server-side ux_state projection means clients consume the canonical field; the lifecycle/snooze precedence happens once on the server.
  • Eliminates derivation drift. One source: the TS function in work-item-inbox-query.ts. The client has nothing to derive.
  • Eliminates Phase 2. No mixed-version worker write failures because the enum doesn’t change. Tightening the enum to remove 'archived' becomes optional cleanup — not a load-bearing migration.
  • Preserves orthogonality. Archive, snooze, soft-delete, pipeline status all move independently. Unarchive doesn’t clobber pipeline state.
  • Whether to tighten the enum in a follow-up (drop 'archived') once write sites are verified clean. Cosmetic, not load-bearing.
  • The unified_inbox_v2 flag-off path — confirm no leakage between legacy and v2 reads.
  • The legacy mapUiStatusToWorkItemStatus() translation table — verify saved-preset usage before pruning.

The Option-A/B/C analysis above (sections “Options” through “Recommendation”) is preserved as the design trail. Option A.5 is the actual go-forward.


The following were open questions in earlier drafts; product-side answers locked them in.

archived_at and deleted_at are orthogonal lifecycle states, not nested:

  • Archived → row stays visible forever, just out of the active queue. The user filed it for reference. User can browse archived items indefinitely.
  • Deleted → soft-delete with 30-day visibility window. Within 30 days, row appears in the “deleted” view (trash). After 30 days, the user can no longer see it.

A row can be both archived AND deleted (user archived first, then deleted later). The deleted_at lifecycle takes display precedence — once deleted, the row appears in the deleted view, not archived.

Implication for ux_state: the view’s CASE expression must check deleted_at BEFORE archived_at. Today’s toUxState() at lib.ts:59-76 checks neither directly (it relies on the lifecycle filter to scope first); the new derivation must add an explicit deleted branch.

After deleted_at + 30 days, the row should disappear from every user-facing query. Two implementations to choose between:

  • (a) Hard delete after 30 days. A daily Temporal cron purges rows where deleted_at < now() - INTERVAL '30 days'. Storage stays bounded; row is irrecoverable.
  • (b) Soft hide after 30 days. Lifecycle filter adds AND (deleted_at IS NULL OR deleted_at > now() - INTERVAL '30 days') to the deleted view. Row stays in DB but no UI surfaces it. Recoverable via DB query if support needs it.

Recommendation: (b) soft-hide, with a separate hard-purge cron at e.g. 1 year. Lets support recover for “I deleted by accident a month ago” cases without re-architecting. Add a single index on deleted_at for the hide predicate.

This is independent of the enum/view decision below — it’s a real product requirement either way.

Today, archive at bulk-actions.ts:138-145 sets status='archived' AND archived_at=now. Unarchive at bulk-actions.ts:146-154 clears archived_at and sets status='new'. The unarchive → 'new' clobbers real pipeline state — a row that was awaiting_review before archive comes back as new, losing the fact that the clerk had already flagged it for review.

New design: archive and unarchive must be lifecycle-only. They never touch status.

  • Archivearchived_at = now. Status untouched.
  • Unarchivearchived_at = null. Status untouched.
  • Soft-deletedeleted_at = now. Status untouched.
  • Restoredeleted_at = null. Status untouched.

This makes status purely the pipeline progress field: new → in_progress → awaiting_review → submitted | error. Lifecycle is independently tracked via timestamps. Unarchive surfaces the row in whatever pipeline state it actually was when archived.

Migration impact: all 13 write sites identified in the GSTACK REVIEW REPORT must be audited. The four sites that explicitly set status='archived' (bulk-actions, emails/bulk-actions, erp-writes, work-item-backfill) need to drop that SET clause. Then 'archived' becomes unreachable as a status value, and Phase 2’s enum drop is safe.

Claimability of failed items (resolved 2026-08-13)

Section titled “Claimability of failed items (resolved 2026-08-13)”

Decision: terminal status='error' items are NOT claimable, for now.

The server’s claim route only updates rows whose status is in ('new', 'in_progress', 'awaiting_review')'error' is deliberately excluded so a claim can’t corrupt processed-credit attribution or resurrect terminal work. The client, however, gated its “Claim” CTA on UxState and treated only submitted/archived/deleted as non-claimable. failed fell through, so failed rows advertised a Claim button whose POST always came back 410 NOT_CLAIMABLE — and because the client only special-cased 409, the failure surfaced as a row that flickered out and back with no state change. To the operator this read as “clicking Claim does nothing”.

Resolution: the client now gates on the raw status via isClaimable() in cockpit/lib.ts, mirroring the server predicate exactly, so the CTA is withheld rather than offered-and-rejected.

Why status, not uxState. deriveUxState produces failed from two structurally different rows, and only one is terminal:

Underlying rowuxStateClaimable?
status='error'failedNo — server refuses
status='new'|'in_progress' + failed pdf_documents.statusfailedYes
status='new'|'in_progress' + processing_status='failed'failedYes

A UxState-level failed check would also hide the CTA on the latter two — exactly the extraction failures an operator needs to pick up. This is why the bug read as intermittent rather than “failed items are always broken”.

If we later want failed items claimable. The likely product argument is that an ERP-rejected order needs an owner to fix and resubmit it, and there is already a remediation flow for those (isErpRejection in cockpit/CockpitDetail.tsx). The change is two-sided:

  1. Add 'error' to CLAIMABLE_STATUSES in cockpit/lib.ts.
  2. Add 'error' to the inArray(workItems.status, …) predicate in api/work-items/[id]/claim.ts.

Changing only (1) reinstates the silent no-op described above. Doing this also requires deciding what claiming an errored row means for processed-credit attribution — the original reason for the exclusion.

Decision: drop from the design. No product owner can define what triggers it; the visual treatment in STATE_META at lib.ts:97-103 is design speculation that hasn’t been wired to a backend signal.

Action:

If a real product need surfaces (e.g., CK-POC ERP-sync-pending bucket), open a fresh RFC with a defined trigger predicate.

  1. Soft-delete TTL implementation — (a) daily hard-purge cron at 30 days, or (b) soft-hide at 30 days + separate hard-purge at e.g. 1 year? RESOLVED 2026-04-30: (b). Lifecycle filter excludes deleted_at < now() - 30 days from the trash view; row is invisible to users after 30 days but stays in DB. Separate Temporal cron at 1 year hard-purges. Lets support recover for “I deleted by accident a month ago” cases.

1a. SubmitButton CTA for deleted rows. RESOLVED 2026-04-30: passive “Deleted” pill (matches archived/snoozed pattern). Renders grey pill with trash icon, no click action. Restore stays as the row-level hover button at InboxItemRow.tsx:476-487.

  1. Where should ux_state derivation live? Three real options now that the design is simpler:

    • View — supports now() for snooze TTL. Cannot be indexed (the earlier draft was factually wrong about this). Adds a SQL surface.
    • TypeScript projection in work-item-inbox-query.ts — derives ux_state on each row in the result set. No SQL surface, single source of truth, no view-vs-TS drift. Recommended. This is the missing “Option A.5” the dual-voice review surfaced.
    • Generated column — rejected; can’t reference now().
  2. GenFit data on staging — is the current ux_state distribution sane? Before any migration lands, run an invariant probe against staging to establish a baseline. If unexpected concentrations show up (e.g. > 50% processing), investigate before migrating. (Run as part of implementation, not blocking.)

4. mapUiStatusToWorkItemStatus() legacy mappings RESOLVED 2026-04-30 (verified via codebase search): No code or DB table stores saved presets with values in_review, needs_attention, completed, on_hold, rejected. The translation table at work-item-inbox-query.ts:21-35 is purely defensive code for URL query params. Keep the function as a thin shim during migration; consider removing in a follow-up cleanup PR after telemetry shows no inbound traffic uses these values.

5. unified_inbox_v2 flag RESOLVED 2026-04-30 (verified via codebase search): No unified_inbox_v2 or useWorkItems flag found in the staging codebase. The flag has already been removed/deprecated. work_items is the canonical inbox path; no legacy InboxEmail-backed parallel path to worry about.

The combined-transition AUDIT at extracted-orders/[id]/status.ts:78-85 — the site sets status='submitted', archivedAt=now together when an extracted order’s status changes to submitted/completed/approved. The inline comment marks this as intentional Phase 4 UX (“Auto-archive workItem when extracted order is submitted/completed”). Under the new design this remains correct — pipeline transition (status=‘submitted’) and lifecycle archive (archivedAt set) are independent and orthogonal. No change needed at this site.


For reviewers tracing the model:


Run: /autoplan — 2026-04-30 Reviewers: Codex CLI (CEO + Eng) + Claude subagents (CEO + Eng), independent Verdict:REDIRECT — do not ship as drafted. Both phases, both reviewers, unanimous.

DimensionClaude subagentCodexConsensus
1. Premises valid?Mostly; awaiting_ref_data is speculativeMisframes operational/governance failures as data-model failuresDISAGREE on framing — both say revise
2. Right problem to solve?NO — over-engineers; Option A solves the actual bugNO — solves real inconsistency at wrong scope; reliability > redesignCONFIRMED: not the right problem at this scope
3. Scope calibration correct?Too broad — defer most of Option CToo broad — narrow to write-path discipline firstCONFIRMED: too broad
4. Alternatives sufficiently explored?Missing Option A.5, Option DMissing minimal-fix, orthogonal-columns, real CQRS materialized viewCONFIRMED: under-explored
5. Competitive/market risks covered?NO — GenFit on staging warrants deferralNO — customer reliability firstCONFIRMED: market risk under-weighted
6. 6-month trajectory sound?NO — drift risk, stuck-mid-migration riskNO — adds concepts rather than removes themCONFIRMED: trajectory not sound
DimensionClaude subagentCodexConsensus
1. Architecture sound?NO — view + flag column wrong shape, two derivation copiesNO — view-can-be-indexed claim factually wrong, derivation driftCONFIRMED: architecture has factual errors
2. Test coverage sufficient?NO — view parity, CHECK constraint, distribution drift untestedNO — truth tables, migration tests, EXPLAIN snapshots all missingCONFIRMED: test plan undefined
3. Performance risks addressed?View at 100k bounded; ux_state filter doesn’t use existing indexView can’t be indexed; performance story incompleteCONFIRMED: performance story incomplete
4. Security threats covered?n/a (no security surface change)n/aN/A
5. Error paths handled?Race: archive vs unsnooze cronarchived_at + deleted_at undefined; mixed-version writes failCONFIRMED: error paths incomplete
6. Deployment risk manageable?Phase 2 will break in-flight Temporal txns; need 3-phaseMixed-version writers fail; need drain-and-cutoverCONFIRMED: deployment risk understated

Critical findings (must fix before any version of this ships)

Section titled “Critical findings (must fix before any version of this ships)”

C1. Factual error: view indexability claim. RFC §Open Questions Q3 says the view “supports indexes via CREATE INDEX ON work_items_inbox”. Postgres does not support indexes on plain views. Only materialized views can be indexed, and they go stale (snooze TTL is time-dependent). docs/work-items-state-model.md:256

C2. Phase 2 enum drop is unsafe with live workers. RFC names ~3 write sites. Actual count: 13 write sites including 4 that write status='archived' directly. Mixed-version code (worker on old code, DB on new enum) → write fails → row stuck. The RFC’s “reversible — re-add the value if needed” is not a runbook.

Write sites the RFC missed (verified by both reviewers):

  • apps/webapp/src/pages/api/emails/bulk-actions.ts:374-388 (legacy email parallel path)
  • apps/webapp/src/pages/api/extracted-orders/[id]/status.ts:78-85 (auto-archive on submit)
  • apps/ts-temporal-worker/src/activities/erp-writes.ts:309-316 (Temporal approval flow — THIS IS THE HIGH-RISK ONE)
  • apps/ts-temporal-worker/src/activities/work-item-backfill.ts:97-174 (insert path picks 'archived')
  • apps/webapp/src/pages/api/admin/backfill-work-items.ts:202, 240, 339 (admin backfill)
  • apps/webapp/src/pages/api/work-items/supervisor-actions.ts:111-118 (escalation)
  • apps/webapp/src/pages/api/work-items/[id]/snooze.ts:45-51
  • apps/ts-temporal-worker/src/activities/work-item-unsnooze.ts:17-24
  • Various INSERT paths: webhook/inbound-email.ts, pdf-documents/index.ts, work-items/[id]/clone.ts, work-items/test-order.ts

C3. archived_at IS NOT NULL AND deleted_at IS NOT NULL is undefined. Today’s lifecycle filter at work-item-inbox-query.ts:105-117 treats them as orthogonal — 'deleted' view ignores archivedAt. The view’s ux_state would resolve to 'archived' for the same row. The RFC defers this to Open Question 2 but it’s load-bearing for the view’s correctness.

C4. Read-side blast radius understated. Beyond the inbox query, status-based consumers will silently change semantics when 'archived' disappears. Examples:

  • apps/webapp/src/pages/api/work-items/dashboard.ts:105 counts status='archived' and groups it with submitted
  • WORK_ITEM_STATUSES set at work-item-inbox-query.ts:6-13 includes 'archived'
  • Test fixtures across SupervisorKanban.test.tsx, ConservativeInbox.test.tsx, supervisor/lib.test.ts

C5. Two derivation copies will drift. RFC says toUxState() becomes a “thin compatibility wrapper” with the test suite preserved as regression. SQL view + TS function = two sources, both with tests, no enforcement of equivalence. Either delete toUxState() outright in Phase 1, or generate one from the other.

H1. awaiting_ref_data column = migration cost without owner. RFC explicitly defers trigger logic to “a separate RFC.” Adding a column with no writer and no defined predicate is dead weight. Either give it a writer in this PR or drop it.

H2. CHECK rollout strategy. RFC says “rollback cleanly” if rows violate. Reality: ALTER TABLE ... ADD CHECK (without NOT VALID) fails the entire migration on any violating row. Use ADD CHECK ... NOT VALID then VALIDATE CONSTRAINT separately to avoid blocking the deploy.

H3. Phase 1 entrenches what Phase 2 unwinds. Phase 1 adds CHECK (status='archived') = (archived_at IS NOT NULL). Phase 2 drops 'archived' from the enum, making the CHECK vacuously true. This is awkward. Option A.5 (below) avoids the round-trip.

H4. Migration mechanism mismatch. The RFC routes the new invariant probe through the db-maintenance workflow (commit 08a38bdc) — that workflow is for one-shot data scripts, not schema migrations. The actual schema migration must go through normal Drizzle migration channels (CI + app startup).

H5. unarchive overwrites pipeline state. bulk-actions.ts:148-154 and emails/bulk-actions.ts:383-388 hardcode status='new' on unarchive. After Phase 2 (no 'archived' in enum), unarchive becomes actively wrong — it overwrites e.g. 'awaiting_review' that the row was in before being archived. RFC flags this in Open Q1 but defers; it’s load-bearing for Phase 2.

Missing alternatives — both reviewers raised these

Section titled “Missing alternatives — both reviewers raised these”

Option A.5 — Option A + server-side derivation in TypeScript (no view, no enum change).

The RFC’s strongest move (server-derive ux_state) does NOT require a SQL view. Compute uxState in apps/webapp/src/lib/inbox/work-item-inbox-query.ts as a TS expression on the result rows. Add the CHECK constraint. Delete toUxState() outright. Keep storage unchanged.

  • Closes the bug class that actually fired (the CHECK)
  • Fixes S3 (consumers bypass status) — server projects ux_state, client uses it
  • Eliminates derivation drift (one source: TS query module)
  • No enum migration → no Phase 2 risk → no worker drain choreography
  • Single PR, ~1 day CC effort

Option D — Orthogonal columns (proper domain redesign).

Three real axes, three real columns:

  • processing_state (enum: new, in_progress, awaiting_review, submitted, error) — pipeline progress only
  • lifecycle_state (enum: active, archived, deleted) OR keep timestamps with explicit precedence
  • disposition (nullable enum: snoozed, needs_ref_data, null) — exceptional pipeline conditions

Then ux_state is a pure function of (processing_state, lifecycle_state, disposition, snoozed_until > now()) — viable as a Postgres GENERATED ... STORED column for everything except snooze TTL. Cleaner domain, longer migration.

Both reviewers (4 voices, 2 phases) recommend changing the user’s stated direction.

You said: Lock in Option C as the target, two-phase migration to staging.

Both models recommend: Adopt Option A.5 as the immediate move. Option D as the long-term redesign if needed. Defer or drop awaiting_ref_data until product owns it.

Why: Option C optimizes internal model coherence at a cost (live-data migration risk, derivation drift, factual SQL error) that doesn’t pay for itself. Option A.5 closes the actual bug class and gets the architectural cleanup (server-side derivation) without touching the enum or live data. Option D is a real redesign worth doing later if/when domain pressure demands it.

What we might be missing: team’s strategic priorities (WSO2 launch, Smith/Kroy POC, GenFit demo cadence), whether the cockpit vocabulary is actually stable or will churn, whether there’s product pressure to ship awaiting_ref_data soon.

If we’re wrong, the cost is: Option A.5 leaves the storage enum slightly weird ('archived' redundant with archived_at) for longer. That’s the actual downside.

Section titled “Recommended revisions (if Option C still chosen)”
  1. Inventory all 13 write sites in the RFC, not just bulk-actions.ts.
  2. Three-phase migration, not two: (a) add CHECK NOT VALID + view + invariant probe, (b) ship code that stops emitting 'archived' everywhere, soak ≥1 week with probe at 0, (c) drop enum value.
  3. Delete toUxState() in Phase 1 — no compatibility wrapper.
  4. Defer or drop awaiting_ref_data column.
  5. Decide archived_at + deleted_at orthogonality before Phase 1 ships.
  6. Add parity test: SQL view vs TS test fixtures, exhaustive truth table.
  7. Use ADD CHECK ... NOT VALID then VALIDATE CONSTRAINT.
  8. Fix the view-indexability claim in Open Q3 — it’s factually wrong.
  9. Audit dashboard.ts and other status consumers for silent semantic change post-enum-drop.

Second-pass review (Letta code-leader-sandbox, 2026-04-30)

Section titled “Second-pass review (Letta code-leader-sandbox, 2026-04-30)”

After the first revision, ran the RFC through Letta’s code-leader-sandbox agent. It caught a showstopper the dual-voice review missed:

FindingSeverityResolution
Biconditional CHECK contradicts timestamp-only design (every new archive would violate the CHECK)CRITICALRESOLVED — replaced with one-directional CHECK (status != 'archived' OR archived_at IS NOT NULL)
cockpit/ directory absent in current worktree (file:line refs unreachable here)HIGHRESOLVED — added “Worktree note” clarifying RFC tracks post-PR-#776 state; implementer rebases on origin/staging
InboxItem.uxState field missing in current types.ts (line 113-114 ref doesn’t match)HIGHRESOLVED — step 2 explicitly adds the field as a prerequisite
'deleted' not in UxState union despite lifecycle decisions saying derivation needs a deleted branchHIGHRESOLVED — step 2 adds 'deleted' to the union; step 3 specifies the deleted-first resolution order
Step 5 listed only 6 write sites; GSTACK report had 13MEDIUMRESOLVED — full inventory now in step 6 with category labels (status-mutating, lifecycle-only, INSERT-only, AUDIT)
“1 day” scope estimate is unrealisticMEDIUMRESOLVED — revised to ~3 days with itemized breakdown
Race condition (archive vs unsnooze) marked OPEN with hand-waveLOWKEEP OPEN — A.5 reduces blast radius (archive is timestamp-only now); document as accepted trade-off pending observability

The startup-tech-lead and startup-backend Letta agents errored (“Unexpected stop reason”) on this review. Did not retry; code-leader-sandbox findings were comprehensive enough to act on.

How the revised RFC addresses each finding flagged by /autoplan dual voices:

FindingStatusWhere resolved
C1. View-indexability factual errorRESOLVEDView dropped entirely; Open Q2 notes original claim was wrong
C2. 13 write sites not 3RESOLVEDAll sites enumerated in “Revised recommendation” step 5
C3. archived_at + deleted_at undefinedRESOLVED”Lifecycle decisions” — orthogonal, deleted takes display precedence
C4. Read-side blast radius (dashboard.ts, WORK_ITEM_STATUSES)RESOLVED”Revised recommendation” step 8 — explicit audit list
C5. Two derivation copies will driftRESOLVEDtoUxState() deleted outright; one source in work-item-inbox-query.ts
H1. awaiting_ref_data no product ownerRESOLVEDDropped from UxState, STATE_PRIORITY, STATE_META, SubmitButton, tests
H2. CHECK rollout (NOT VALID then VALIDATE)RESOLVEDStep 1 specifies NOT VALID then VALIDATE CONSTRAINT
H3. Phase 1 entrenches what Phase 2 unwindsMOOTA.5 is single-PR; no phase split
H4. db-maintenance vs migration channelRESOLVEDStep 7 — invariant probe runs via db-maintenance; schema migration via Drizzle migration channel
H5. Unarchive overwrites pipeline stateRESOLVED”Lifecycle decisions” — archive/unarchive timestamp-only
Backfill safetyRESOLVED”Backfills” section — re-run PR #776 backfill pre-CHECK; no new backfills required
Race: archive vs unsnooze cronOPENOptimistic-locking version column protects against last-writer-wins; document as accepted trade-off or add to follow-up
Soft-delete TTL implementation (a vs b)OPENOpen Q1 — recommendation is (b) soft-hide + 1-year hard-purge; awaiting confirmation
unified_inbox_v2 flag-off pathOPENOpen Q5 — verify before merge
Legacy mapUiStatusToWorkItemStatus mappingsOPENOpen Q4 — verify saved-preset usage before pruning

Decision audit trail (autonomous decisions)

Section titled “Decision audit trail (autonomous decisions)”
#PhaseDecisionClassPrincipleRationale
1Phase 0Skip Design phasemechanicalP3 pragmaticRFC has no UI redesign; matches were file references
2Phase 0Skip DX phasemechanicalP3 pragmaticProduct is SaaS, not developer tooling; matches were internal API references
3Phase 1Auto-confirm CEO premises with caveatstasteP6 bias toward actionPremises mostly valid; awaiting_ref_data flagged as speculative; user notified at final gate
4Phase 1Surface “redirect” verdict to useruser challengen/aBoth models agree user’s direction should change — escalated to user
5Phase 3Run Eng review despite CEO redirectmechanicalP1 completenessFull picture for user decision
6Phase 4Skip auto-approval; surface for user decisionuser challengen/aPer skill: User Challenges never auto-decided

A.5 made deriveUxState() the single source of truth for the inbox’s state buckets, but left a hole: for PDF uploads, work_items.extracted_order_id is set to NULL at insert and no code path ever links it back after extraction completes. That made 'new' + extractedOrderId IS NULL → 'processing' a permanent label for any uploaded PDF, even after the AgenticValidationWorkflow had set every validation flag green on extracted_orders. Users saw “Processing” in the inbox cockpit and “Order Validated” on the review page for the same row.

┌─────────────┐
│ work_item │
│ created │
└──────┬──────┘
│ insert (pdf upload | inbound email | clone)
'new'
│ save_extracted_order / save_email_order
│ (links extracted_order_id)
'awaiting_review'
┌────────────────┼────────────────┬─────────────────┐
│ user submits │ agentic │ agentic │ user archives
│ via /api/... │ auto_submitted │ failed │ (lifecycle only:
│ status=submitted│ │ │ archived_at set,
▼ ▼ ▼ │ status untouched)
'submitted' 'submitted' 'error' ─┘
+ archived_at + archived_at

Plus the extraction-failed branch:

'new'/'in_progress' + mark_pdf_document_failed → 'error'

Mapping: agentic final_statuswork_items.status

Section titled “Mapping: agentic final_status → work_items.status”
final_status (workflow decision)extracted_orders.auto_validation_statuswork_items.statusNotes
auto_submittedauto_submittedsubmitted + archived_atFalls out of active inbox. Matches manual-submit path.
dry_runrequires_reviewawaiting_reviewWe would have submitted, but flag is off. User still acts.
escalatedrequires_reviewawaiting_reviewConfidence below threshold or issue found.
failed (ERP error)failederrorSubmit attempt rejected by ERP.
disabled (flag off)disabledawaiting_reviewValidation never ran. Don’t leave row stuck on ‘Processing’.

Write sites (where to look when adding a new transition)

Section titled “Write sites (where to look when adding a new transition)”
TransitionCode site
newawaiting_review_link_work_item_inline() in apps/temporal-worker/activities/database.py
new/in_progresserrormark_pdf_document_failed() in apps/temporal-worker/activities/database.py
validation-driven advancementupdate_work_item_from_validation() in apps/temporal-worker/activities/database.py
user manual submitapps/webapp/src/pages/api/extracted-orders/[id]/status.ts (unchanged)
archive / unarchive / soft-deleteapps/webapp/src/pages/api/work-items/bulk-actions.ts (unchanged, lifecycle-only)

All Python writeback paths are forward-only and idempotent under Temporal retry:

  • _link_work_item_inlineWHERE status IN ('new','in_progress'). A replay after the first link succeeded is a no-op (zero rows updated).
  • update_work_item_from_validationWHERE status NOT IN ('submitted','error'). Terminal states never regress.
  • mark_pdf_document_failed work_item branch — WHERE status IN ('new','in_progress'). A row that already advanced to awaiting_review is left alone.