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:
work_items.status— a 6-value Postgres enumwork_items.archived_at/work_items.deleted_at— nullable timestamps (lifecycle override)UxState— an 8-value TypeScript union derived client-side inapps/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.
Problem statement
Section titled “Problem statement”What broke
Section titled “What broke”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_nameandsender_emailare denormalized at row creation, but ~94% of rows on staging had both columns null.[InboxItemRow.tsx:184](apps/webapp/src/components/inbox/InboxItemRow.tsx)renderssenderName || senderEmail || 'Unknown', so those rows showed “Unknown”. - Status drift on archive. Archive operations historically set
archived_atonly, neverstatus. 113 rows on staging hadarchived_at IS NOT NULLANDstatus IN ('new','in_progress','awaiting_review'). Downstream UI read the stale status and rendered “Processing…”. - SubmitButton bucketing.
SubmitButton.tsxhad a switch with adefaultthat lumpedarchived/snoozed/processingUxStates 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.
The three layers
Section titled “The three layers”| Layer | Lives in | Values | Set by |
|---|---|---|---|
| Storage status | work_items.status (PG enum work_item_status) | new, in_progress, awaiting_review, submitted, archived, error | Backend pipeline + user actions |
| Lifecycle override | work_items.archived_at, work_items.deleted_at (timestamps, nullable) | timestamp or null | User actions (archive / delete) |
| UI vocabulary | TS union UxState in apps/webapp/src/components/inbox/types.ts:59-67 | needs_fix, failed, awaiting_ref_data, ready, processing, submitted, snoozed, archived | Derived in toUxState() at apps/webapp/src/components/inbox/cockpit/lib.ts:59-76 |
toUxState() resolution order (first match wins):
- server-provided
item.uxState(future — never populated today) snoozedUntil > now→'snoozed'archivedAtset →'archived'status === 'submitted'→'submitted'status === 'error'→'failed'status === 'awaiting_review'→'needs_fix'status in ('new'|'in_progress'): hasextractedOrderId→'ready'else →'processing'- catch-all →
'processing'
Specific smells
Section titled “Specific smells”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_review → needs_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.
Constraints
Section titled “Constraints”- Postgres + Drizzle. Schema is in
packages/db/src/schema/. Migrations are*.sqlunderpackages/db/src/migrations/, generated viadrizzle-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-maintenanceGitHub Actions workflow added in commit08a38bdc. - 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_v2is feature-flagged. Some users still see the legacyInboxEmail-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 targetstaging, notmain.
Options
Section titled “Options”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 readingarchivedAtfirst. - Doesn’t address S4 (brittle cross-casts) —
awaiting_review → needs_fix,awaiting_ref_dataunreachable. - Punts on S6 —
soft_deletewrite 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_review→needs_fixerror→failednew/in_progresswithextracted_order_idnot null →readynew/in_progresswithoutextracted_order_id→processingsubmitted→submittedarchived→archived
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 wasawaiting_reviewbefore snoozing now has no record of that. - Snooze becomes time-dependent storage.
status='snoozed'requires a Temporal cron to flip back whensnoozed_untilexpires. That cron exists today (thework_items_snoozed_idxindex 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. readyrequires backend awareness. Today derived fromextractedOrderId IS NOT NULL. Promoting it to a status means the extraction pipeline must explicitly transition the row when extraction completes — a write-site cost inapps/email-apiand 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.
Option C — UxState canonical, derived server-side (recommended)
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.statusenum reduced to:new,in_progress,awaiting_review,submitted,error. Drop'archived'.work_items.archived_at,work_items.deleted_at,work_items.snoozed_untilremain 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 isarchived_at IS NOT NULL OR deleted_at IS NOT NULLmay be set independently.
Server layer (derives ux_state for clients):
- A SQL view
work_items_inbox(or aux_stateexpression projected into existing inbox queries) that computesUxStatefrom the underlying columns using the same logic as today’stoUxState(). 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.tsso server filters canWHERE ux_state = 'needs_fix'directly. mapUiStatusToWorkItemStatus()(work-item-inbox-query.ts:21-35) is rewritten to filter onux_stateinstead of remapping legacy values onto the underlying enum.
Client layer (consumes ux_state directly):
InboxItem.uxStatebecomes required (not optional). Server populates it.toUxState()becomes a pass-through (or is deleted entirely; clients just useitem.uxState).- The
awaiting_ref_dataUX state activates whenawaiting_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_stateis derived from the underlying columns; there’s no second column to disagree with the timestamps. awaiting_ref_databecomes 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_reviewbefore snoozing keepsstatus='awaiting_review'ANDsnoozed_until— no information loss. - Filter queries simplify.
WHERE ux_state = 'needs_fix'is a single condition. Today’smapUiStatusToWorkItemStatus()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_statecannot be a Postgres GENERATED column because it depends onnow()(snooze TTL). Must be a view or an inline expression. (Same constraint Option B has — and Option B has worse problems.)- New
awaiting_ref_dataflag 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_datahas no owner. Skip ahead to Revised recommendation for the actual go-forward.
Option C (original recommendation).
Rationale:
- 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.
- The codebase already encodes Option C as intent.
InboxItem.uxStateis optional and documented as “backend-provided when available; otherwise derived.”toUxState()already short-circuits whenitem.uxStateis set. We’re finishing a migration that’s been latent in the code. - 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. - 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).
Migration plan
Section titled “Migration plan”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)”-
Add Drizzle migration that:
- Adds nullable
awaiting_ref_data BOOLEAN DEFAULT FALSEcolumn towork_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 NULLif we want delete-implies-archived (open question — see below). - Creates view
work_items_inboxprojecting all columns plus a derivedux_statetext column using the same logic as today’stoUxState().
- Adds nullable
-
Update inbox query module (work-item-inbox-query.ts) to read from
work_items_inbox.mapUiStatusToWorkItemStatus()becomesmapUiStatusToUxState(). Filter conditions migrate fromeq(workItems.status, ...)to filters on the view’sux_state. -
Update server projections that build
InboxItemto populateuxStatefrom the view. -
Update client to prefer
item.uxState(already supported via theif (item.uxState) return item.uxState;short-circuit at lib.ts:60).toUxState()becomes a thin compatibility wrapper for legacy callers. -
Add data-maintenance script
packages/db/scripts/verify-work-items-state-invariants.tsmodeled onbackfill-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_statevalues (sanity baseline).
Wired into the
db-maintenanceworkflow to run on every deploy. - Rows where
-
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.
Phase 2 — Remove 'archived' from enum
Section titled “Phase 2 — Remove 'archived' from enum”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:
-
Migrate write sites to set only
archived_aton archive (drop thestatus: 'archived'SET clause). Touch points:bulk-actions.ts:138-145— archive casebulk-actions.ts:146-154— unarchive case (setstatusback to whatever the pipeline state was before archive — open question, see below)- Any Temporal workflow or Dagster step that sets
status='archived'
-
Drizzle migration to recreate
work_item_statusenum without'archived'. Standard Postgres pattern: create new enum, alter column, drop old enum. -
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.
Rollout / observability
Section titled “Rollout / observability”- 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.tsruns on every deploy via thedb-maintenanceworkflow (added in commit08a38bdc). Logs invariant counts; alerts if any drift. - Distribution sanity baseline. First run of the invariant probe captures the staging
ux_statedistribution. 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’sux_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.
What ships in one PR
Section titled “What ships in one PR”-
CHECK constraint — one-directional, not biconditional:
ALTER TABLE work_itemsADD CONSTRAINT work_items_archived_status_consistentCHECK (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_atwithout settingstatus='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. -
Add
uxStatefield toInboxItemtype atapps/webapp/src/components/inbox/types.ts. It’s optional today (uxState?: UxState); make it required after server projection lands. Add a'deleted'value to theUxStateunion so soft-deleted rows have a derivation target (deleted-view rows currently return'archived'from the existing function — that’s the wrong label). -
Server-side
ux_statederivation inapps/webapp/src/lib/inbox/work-item-inbox-query.ts(or a new helper imported by it). EachInboxItemreturned to the client carries a populateduxState. 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'ifextracted_order_id IS NOT NULL, else'processing'status='archived'(legacy rows) →'archived'(catch-all)
-
Delete
toUxState()atapps/webapp/src/components/inbox/cockpit/lib.ts:59-76outright. Client readsitem.uxStatedirectly. Tests atapps/webapp/src/components/inbox/cockpit/lib.test.tsmove to test the server-side derivation function. -
Drop
awaiting_ref_datafrom theUxStateunion and all visual treatments (per “Lifecycle decisions” below). And add'deleted'. Net change toUxState: dropawaiting_ref_data, adddeleted. Both ripples touch the same files:apps/webapp/src/components/inbox/types.ts— union updateapps/webapp/src/components/inbox/cockpit/lib.ts— dropawaiting_ref_datafromSTATE_PRIORITY+STATE_META; adddeletedentriesapps/webapp/src/components/inbox/cockpit/SubmitButton.tsx— dropawaiting_ref_datacase; adddeletedcase (open question: what’s the CTA for a deleted row in the trash view? “Restore” makes sense given existingRowActionEvent.restore. See open questions below.)apps/webapp/src/components/inbox/cockpit/lib.test.ts— dropawaiting_ref_datatest case; adddeletedtest 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.
-
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):
apps/webapp/src/pages/api/work-items/bulk-actions.ts:124-156—archive,unarchive,soft_delete,restoreapps/webapp/src/pages/api/emails/bulk-actions.ts:372-415— legacy email parallel path that mirrors archive into work_itemsapps/ts-temporal-worker/src/activities/erp-writes.ts:309-316— Temporal approval flow setsstatus='archived', archivedAt=nowapps/ts-temporal-worker/src/activities/work-item-backfill.ts:97-174— INSERT path conditionally chooses initialstatus='archived'apps/webapp/src/pages/api/admin/backfill-work-items.ts:202, 240, 339— admin backfill INSERT paths
Status-mutating (other transitions — no change needed, listed for completeness):
apps/webapp/src/pages/api/work-items/supervisor-actions.ts:111-118— escalationstatus='awaiting_review'(keep)apps/ts-temporal-worker/src/activities/erp-writes.ts:110-127, 320-326— submit/error transitions (keep)
Lifecycle-only (snooze/delete — already correct):
apps/webapp/src/pages/api/work-items/[id]/snooze.ts:45-51— setssnoozedUntilonlyapps/ts-temporal-worker/src/activities/work-item-unsnooze.ts:17-24— clearssnoozedUntil
Combined transitions to AUDIT (status + archive together):
apps/webapp/src/pages/api/extracted-orders/[id]/status.ts:78-85— setsstatus='submitted', archivedAt=nowtogether. Decide: is “submit auto-archives” desired UX? If yes, both are intentional. If no, drop thearchivedAtset and let user file manually.
INSERT-only paths (verify they don’t conditionally set
status='archived'):apps/webapp/src/pages/api/webhook/inbound-email.ts:157— initial insertapps/webapp/src/pages/api/pdf-documents/index.ts:255— manual upload insertapps/webapp/src/pages/api/work-items/[id]/clone.ts:43— clone insertapps/webapp/src/pages/api/work-items/test-order.ts:79— test-order insert
-
Soft-delete TTL implementation (separate but adjacent change — could be same PR or follow-up):
- Lifecycle filter at
work-item-inbox-query.ts:114addsAND (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.
- Lifecycle filter at
-
Invariant probe — runs via
db-maintenanceworkflow on each deploy. Reports counts for the CHECK invariant (should be 0) andux_statedistribution (sanity baseline). -
Read-side audit — the following code reads
statusdirectly and must be reviewed for semantic compatibility once new archives stop settingstatus='archived':apps/webapp/src/pages/api/work-items/dashboard.ts— countsstatus='archived'and groups it withsubmittedfor 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-13—WORK_ITEM_STATUSESset 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 usingstatus='archived'still work but are testing legacy semantics. Update or delete in the same PR for clarity.
Backfills
Section titled “Backfills”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:
-
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.
-
No new backfill needed for legacy archived rows. Rows where
status='archived' AND archived_at IS NOT NULLare valid under the new CHECK and the new derivation: lifecycle precedence puts them inux_state='archived'regardless of status. They lost their pre-archive pipeline state historically; that information is unrecoverable. -
No backfill needed for
deleted_atTTL. Lifecycle filter handles existing rows correctly — anydeleted_atolder than 30 days is hidden from the deleted view. No data migration. -
No backfill needed for
ux_state. Derived per-query in TypeScript; nothing stored. -
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.
Worktree note (file:line accuracy)
Section titled “Worktree note (file:line accuracy)”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.
Scope estimate (revised)
Section titled “Scope estimate (revised)”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):
| Work | Effort |
|---|---|
| Drizzle migration (CHECK NOT VALID + VALIDATE) | 0.25 day |
Server-side ux_state derivation function + tests | 0.5 day |
Add uxState to InboxItem, add 'deleted' to UxState, drop awaiting_ref_data | 0.25 day |
Delete toUxState(), migrate client call sites to item.uxState | 0.5 day |
| Write-site refactor (5 archive sites + audit 4 INSERT paths + 1 combined transition) | 0.75 day |
| 30-day soft-delete TTL filter | 0.25 day |
Read-side audit (dashboard.ts, WORK_ITEM_STATUSES, test fixtures) | 0.25 day |
| Invariant probe script + db-maintenance wiring | 0.25 day |
| Total | ~3 days |
Add buffer if mid-implementation surfaces new write sites or test failures.
What does NOT ship
Section titled “What does NOT ship”- No SQL view (the original Option C centerpiece).
- No new
awaiting_ref_datacolumn (no product owner). - No enum migration (no Phase 2 risk).
- No two-phase deploy choreography.
Why this works
Section titled “Why this works”- Closes the bug class. The CHECK prevents the drift that caused PR #776’s archived-row “Processing” pill.
- Closes S3 (consumers bypass
status). Server-sideux_stateprojection 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.
What stays open
Section titled “What stays open”- Whether to tighten the enum in a follow-up (drop
'archived') once write sites are verified clean. Cosmetic, not load-bearing. - The
unified_inbox_v2flag-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.
Lifecycle decisions (resolved 2026-04-30)
Section titled “Lifecycle decisions (resolved 2026-04-30)”The following were open questions in earlier drafts; product-side answers locked them in.
Archive vs. delete
Section titled “Archive vs. delete”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.
Soft-delete TTL (30 days) — new
Section titled “Soft-delete TTL (30 days) — new”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.
Unarchive state reflection
Section titled “Unarchive state reflection”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.
- Archive →
archived_at = now. Status untouched. - Unarchive →
archived_at = null. Status untouched. - Soft-delete →
deleted_at = now. Status untouched. - Restore →
deleted_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 row | uxState | Claimable? |
|---|---|---|
status='error' | failed | No — server refuses |
status='new'|'in_progress' + failed pdf_documents.status | failed | Yes |
status='new'|'in_progress' + processing_status='failed' | failed | Yes |
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:
- Add
'error'toCLAIMABLE_STATUSESin cockpit/lib.ts. - Add
'error'to theinArray(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.
awaiting_ref_data
Section titled “awaiting_ref_data”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:
- Remove
awaiting_ref_datafrom theUxStateunion at types.ts:59-67. - Remove the priority entry at lib.ts:82 and the META entry at lib.ts:97-103.
- Remove the
'awaiting_ref_data'case from SubmitButton.tsx:48-56. - Remove the test case at lib.test.ts:46-67.
- Do NOT add an
awaiting_ref_datacolumn towork_items.
If a real product need surfaces (e.g., CK-POC ERP-sync-pending bucket), open a fresh RFC with a defined trigger predicate.
Remaining open questions
Section titled “Remaining open questions”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 excludesdeleted_at < now() - 30 daysfrom 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. 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.SubmitButton CTA for deleted rows.
-
Where should
ux_statederivation 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— derivesux_stateon 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().
- View — supports
-
GenFit data on staging — is the current
ux_statedistribution 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. RESOLVED 2026-04-30 (verified via codebase search): No code or DB table stores saved presets with values mapUiStatusToWorkItemStatus() legacy mappingsin_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. RESOLVED 2026-04-30 (verified via codebase search): No unified_inbox_v2 flagunified_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.
Verified during pre-implementation checks
Section titled “Verified during pre-implementation checks”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.
Appendix — file:line index
Section titled “Appendix — file:line index”For reviewers tracing the model:
- Storage schema: packages/db/src/schema/work-items.ts:39-46 (enum),
:91-92(lifecycle timestamps),:133-136(existing CHECK) - Original migration: packages/db/src/migrations/0042_add_work_items.sql:6
- UI vocabulary: apps/webapp/src/components/inbox/types.ts:59-67 (
UxStateunion),:113-114(InboxItem.uxStateoptional field) - Derivation: apps/webapp/src/components/inbox/cockpit/lib.ts:59-76 (
toUxState),:50-57(awaiting_ref_dataknown-gap comment),:79-88(STATE_PRIORITY),:91-109(STATE_META) - Renderer: apps/webapp/src/components/inbox/cockpit/SubmitButton.tsx:27-91 (post-#776 exhaustive switch)
- List row: apps/webapp/src/components/inbox/InboxItemRow.tsx:184 (
senderName || senderEmail || 'Unknown') - Lifecycle filter: apps/webapp/src/lib/inbox/work-item-inbox-query.ts:105-117 (active/archived/deleted),
:21-35(mapUiStatusToWorkItemStatus) - Write paths: apps/webapp/src/pages/api/work-items/bulk-actions.ts:124-154 (soft_delete, archive, unarchive)
- Backfill template: packages/db/scripts/backfill-work-items-display-fields.ts:122-132 (Step 4, status reconciliation)
- Test contract: apps/webapp/src/components/inbox/cockpit/lib.test.ts:45-123 (
toUxStatecases — preserve as a regression suite during migration)
GSTACK REVIEW REPORT
Section titled “GSTACK REVIEW REPORT”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.
CEO Consensus
Section titled “CEO Consensus”| Dimension | Claude subagent | Codex | Consensus |
|---|---|---|---|
| 1. Premises valid? | Mostly; awaiting_ref_data is speculative | Misframes operational/governance failures as data-model failures | DISAGREE on framing — both say revise |
| 2. Right problem to solve? | NO — over-engineers; Option A solves the actual bug | NO — solves real inconsistency at wrong scope; reliability > redesign | CONFIRMED: not the right problem at this scope |
| 3. Scope calibration correct? | Too broad — defer most of Option C | Too broad — narrow to write-path discipline first | CONFIRMED: too broad |
| 4. Alternatives sufficiently explored? | Missing Option A.5, Option D | Missing minimal-fix, orthogonal-columns, real CQRS materialized view | CONFIRMED: under-explored |
| 5. Competitive/market risks covered? | NO — GenFit on staging warrants deferral | NO — customer reliability first | CONFIRMED: market risk under-weighted |
| 6. 6-month trajectory sound? | NO — drift risk, stuck-mid-migration risk | NO — adds concepts rather than removes them | CONFIRMED: trajectory not sound |
Eng Consensus
Section titled “Eng Consensus”| Dimension | Claude subagent | Codex | Consensus |
|---|---|---|---|
| 1. Architecture sound? | NO — view + flag column wrong shape, two derivation copies | NO — view-can-be-indexed claim factually wrong, derivation drift | CONFIRMED: architecture has factual errors |
| 2. Test coverage sufficient? | NO — view parity, CHECK constraint, distribution drift untested | NO — truth tables, migration tests, EXPLAIN snapshots all missing | CONFIRMED: test plan undefined |
| 3. Performance risks addressed? | View at 100k bounded; ux_state filter doesn’t use existing index | View can’t be indexed; performance story incomplete | CONFIRMED: performance story incomplete |
| 4. Security threats covered? | n/a (no security surface change) | n/a | N/A |
| 5. Error paths handled? | Race: archive vs unsnooze cron | archived_at + deleted_at undefined; mixed-version writes fail | CONFIRMED: error paths incomplete |
| 6. Deployment risk manageable? | Phase 2 will break in-flight Temporal txns; need 3-phase | Mixed-version writers fail; need drain-and-cutover | CONFIRMED: 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-51apps/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:105countsstatus='archived'and groups it with submittedWORK_ITEM_STATUSESset 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.
High findings
Section titled “High findings”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 projectsux_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 onlylifecycle_state(enum:active,archived,deleted) OR keep timestamps with explicit precedencedisposition(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.
User Challenge
Section titled “User Challenge”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.
Recommended revisions (if Option C still chosen)
Section titled “Recommended revisions (if Option C still chosen)”- Inventory all 13 write sites in the RFC, not just
bulk-actions.ts. - 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. - Delete
toUxState()in Phase 1 — no compatibility wrapper. - Defer or drop
awaiting_ref_datacolumn. - Decide
archived_at + deleted_atorthogonality before Phase 1 ships. - Add parity test: SQL view vs TS test fixtures, exhaustive truth table.
- Use
ADD CHECK ... NOT VALIDthenVALIDATE CONSTRAINT. - Fix the view-indexability claim in Open Q3 — it’s factually wrong.
- 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:
| Finding | Severity | Resolution |
|---|---|---|
| Biconditional CHECK contradicts timestamp-only design (every new archive would violate the CHECK) | CRITICAL | RESOLVED — replaced with one-directional CHECK (status != 'archived' OR archived_at IS NOT NULL) |
cockpit/ directory absent in current worktree (file:line refs unreachable here) | HIGH | RESOLVED — 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) | HIGH | RESOLVED — step 2 explicitly adds the field as a prerequisite |
'deleted' not in UxState union despite lifecycle decisions saying derivation needs a deleted branch | HIGH | RESOLVED — 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 13 | MEDIUM | RESOLVED — full inventory now in step 6 with category labels (status-mutating, lifecycle-only, INSERT-only, AUDIT) |
| “1 day” scope estimate is unrealistic | MEDIUM | RESOLVED — revised to ~3 days with itemized breakdown |
| Race condition (archive vs unsnooze) marked OPEN with hand-wave | LOW | KEEP 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.
Review-finding resolution table
Section titled “Review-finding resolution table”How the revised RFC addresses each finding flagged by /autoplan dual voices:
| Finding | Status | Where resolved |
|---|---|---|
| C1. View-indexability factual error | RESOLVED | View dropped entirely; Open Q2 notes original claim was wrong |
| C2. 13 write sites not 3 | RESOLVED | All sites enumerated in “Revised recommendation” step 5 |
C3. archived_at + deleted_at undefined | RESOLVED | ”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 drift | RESOLVED | toUxState() deleted outright; one source in work-item-inbox-query.ts |
H1. awaiting_ref_data no product owner | RESOLVED | Dropped from UxState, STATE_PRIORITY, STATE_META, SubmitButton, tests |
| H2. CHECK rollout (NOT VALID then VALIDATE) | RESOLVED | Step 1 specifies NOT VALID then VALIDATE CONSTRAINT |
| H3. Phase 1 entrenches what Phase 2 unwinds | MOOT | A.5 is single-PR; no phase split |
| H4. db-maintenance vs migration channel | RESOLVED | Step 7 — invariant probe runs via db-maintenance; schema migration via Drizzle migration channel |
| H5. Unarchive overwrites pipeline state | RESOLVED | ”Lifecycle decisions” — archive/unarchive timestamp-only |
| Backfill safety | RESOLVED | ”Backfills” section — re-run PR #776 backfill pre-CHECK; no new backfills required |
| Race: archive vs unsnooze cron | OPEN | Optimistic-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) | OPEN | Open Q1 — recommendation is (b) soft-hide + 1-year hard-purge; awaiting confirmation |
unified_inbox_v2 flag-off path | OPEN | Open Q5 — verify before merge |
Legacy mapUiStatusToWorkItemStatus mappings | OPEN | Open Q4 — verify saved-preset usage before pruning |
Decision audit trail (autonomous decisions)
Section titled “Decision audit trail (autonomous decisions)”| # | Phase | Decision | Class | Principle | Rationale |
|---|---|---|---|---|---|
| 1 | Phase 0 | Skip Design phase | mechanical | P3 pragmatic | RFC has no UI redesign; matches were file references |
| 2 | Phase 0 | Skip DX phase | mechanical | P3 pragmatic | Product is SaaS, not developer tooling; matches were internal API references |
| 3 | Phase 1 | Auto-confirm CEO premises with caveats | taste | P6 bias toward action | Premises mostly valid; awaiting_ref_data flagged as speculative; user notified at final gate |
| 4 | Phase 1 | Surface “redirect” verdict to user | user challenge | n/a | Both models agree user’s direction should change — escalated to user |
| 5 | Phase 3 | Run Eng review despite CEO redirect | mechanical | P1 completeness | Full picture for user decision |
| 6 | Phase 4 | Skip auto-approval; surface for user decision | user challenge | n/a | Per skill: User Challenges never auto-decided |
A.6 — Pipeline writeback (May 2026)
Section titled “A.6 — Pipeline writeback (May 2026)”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.
State-transition rules (canonical)
Section titled “State-transition rules (canonical)” ┌─────────────┐ │ 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_atPlus the extraction-failed branch:
'new'/'in_progress' + mark_pdf_document_failed → 'error'Mapping: agentic final_status → work_items.status
Section titled “Mapping: agentic final_status → work_items.status”final_status (workflow decision) | extracted_orders.auto_validation_status | work_items.status | Notes |
|---|---|---|---|
auto_submitted | auto_submitted | submitted + archived_at | Falls out of active inbox. Matches manual-submit path. |
dry_run | requires_review | awaiting_review | We would have submitted, but flag is off. User still acts. |
escalated | requires_review | awaiting_review | Confidence below threshold or issue found. |
failed (ERP error) | failed | error | Submit attempt rejected by ERP. |
disabled (flag off) | disabled | awaiting_review | Validation 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)”| Transition | Code site |
|---|---|
new → awaiting_review | _link_work_item_inline() in apps/temporal-worker/activities/database.py |
new/in_progress → error | mark_pdf_document_failed() in apps/temporal-worker/activities/database.py |
| validation-driven advancement | update_work_item_from_validation() in apps/temporal-worker/activities/database.py |
| user manual submit | apps/webapp/src/pages/api/extracted-orders/[id]/status.ts (unchanged) |
| archive / unarchive / soft-delete | apps/webapp/src/pages/api/work-items/bulk-actions.ts (unchanged, lifecycle-only) |
Idempotency
Section titled “Idempotency”All Python writeback paths are forward-only and idempotent under Temporal retry:
_link_work_item_inline—WHERE status IN ('new','in_progress'). A replay after the first link succeeded is a no-op (zero rows updated).update_work_item_from_validation—WHERE status NOT IN ('submitted','error'). Terminal states never regress.mark_pdf_document_failedwork_item branch —WHERE status IN ('new','in_progress'). A row that already advanced toawaiting_reviewis left alone.