Work-item lifecycle states and customer email labels
Status: DRAFT — reshaped 2026-09-03 after /plan-eng-review + Codex outside voice Author: David Boone Scope: the order/work-item state model, and how it should manifest as labels in a customer’s email client (Gmail labels / M365 categories).
Phase 0 — ship now, unblocks go-live (decided)
Section titled “Phase 0 — ship now, unblocks go-live (decided)”The concrete bug: the “Processed by Ordermatic” writeback fires from mark-approved
(the Finished button) and from auto-submit, but NOT from manual Submit. At go-live
Submit replaces Finished, so the label silently stops.
Fix: trigger the existing writeback_processed_email at the manual-submit success
point — ERPOrderSubmissionWorkflow after persistOrderResultActivity
(apps/ts-temporal-worker/src/activities/erp-writes.ts), the one place a manual
submit is known to have succeeded. No redesign required. This is a ~2-line change and
does not depend on anything below.
Everything else in this doc is the larger, separately-validated redesign.
Problem (one sentence)
Section titled “Problem (one sentence)”An order’s journey is spread across several status fields that describe different
domains, the customer-facing email labels are bolted onto three specific trigger
sites (one of which does not even reach the mailbox), and archive is fused to submit
so the submitted state is never observable — so the labels can neither be complete
nor stay consistent with reality.
What the review corrected (do not repeat the first draft’s mistakes)
Section titled “What the review corrected (do not repeat the first draft’s mistakes)”The first draft proposed “one canonical lifecycle_state + a 1:1 label projection.”
Codex + eng review found that framing wrong on several counts. The corrected model:
- The statuses are orthogonal domains, not redundant fields.
pdf_documents.status(extraction),autoValidationStatus(validation),extracted_orders.status(ERP submission), andwork_items.statusdescribe different things. Do NOT merge them into one enum. Keep them; derive a customer-facing view on top. - Inbox lifecycle overlays pipeline state.
archivedAt/snoozedUntil/deletedAtare overlays; unarchive must reveal the prior state (work-items/bulk-actions.ts:235already preservesstatusacross archive). A single flattened enum would needprevious_state— reintroducing the complexity it claimed to remove. Keep archive/snooze/delete as overlays. - Neither mapping is 1:1. State→label is many-to-few (
ready/snoozed/deletedhave no customer label). Work-item→email is many-to-one: multi-order workbooks and multi-attachment emails create sibling work items for one message (temporal-worker/activities/database.py:1251,1494). The label must aggregate by source email, not per work item, or partially-complete emails race and mislabel.
Proposed design (corrected)
Section titled “Proposed design (corrected)”Layer 1 — Customer-facing lifecycle is a DERIVED view, not a new source of truth
Section titled “Layer 1 — Customer-facing lifecycle is a DERIVED view, not a new source of truth”Define one function, customerLifecycle(order), that maps the domain statuses +
overlays to a small customer-facing set: received, processing, needs_review,
submitted, failed. Archive/snooze/delete stay as overlays on top. This is a read
model. If it is persisted for cross-consumer use, it is an explicitly denormalized
cache with a reconciler (Layer 3), never a hand-maintained column that every writer
must remember to update — writers live in the webapp, the TS worker, and the Python
worker (including raw SQL), so “one reducer everyone calls” is infeasible as a single
function.
Layer 2 — Decouple archive from submit (decided)
Section titled “Layer 2 — Decouple archive from submit (decided)”Today submit writes status='submitted' AND archivedAt=now together
(orders/[id]/mark-approved.ts:177, ts-temporal-worker/.../erp-writes.ts:537), and
archivedAt wins in the UX derivation (lib/inbox/work-item-ux-state.ts:107), so a
submitted order always renders archived. Fix: submit sets submitted only.
Submitted orders rest in the (currently always-empty) Submitted tab. A time-based
sweep archives submitted orders older than N days (decided; N to tune, start 7).
Upgrade path: archive on ERP confirmation once read-back exists per connector.
Layer 3 — Email label is a projection of the customer lifecycle, delivered reliably — SHIPPED (milestones)
Section titled “Layer 3 — Email label is a projection of the customer lifecycle, delivered reliably — SHIPPED (milestones)”Decision (2026-09-04): milestones, not current-state mirroring (Open decision #1).
Shipped as a reconciler-as-delivery sweep, which sidesteps the outbox concern
entirely: there is no DB-commit-then-Temporal-start dual-write to lose, because the
sweep just reads the current work_items.status and converges the mailbox to match.
- Aggregated by source email for free.
work_itemsis 1:1 withemail_events(unique constraint onemail_event_id), and mailbox labels only apply to email-sourced items, sowork_items.statusis the per-email aggregate — no GROUP BY, no sibling race. - Reconciler is the primary path, run on a 5-min schedule
(
ScheduledLifecycleLabelSyncWorkflow→sync_lifecycle_labels_sweep). Scope: email work_items, non-archived/non-deleted, whoseupdated_atchanged withinLIFECYCLE_LABEL_SYNC_WINDOW_MINUTES(default 120). Status writers bumpupdated_at, so the window catches every transition, then the row ages out and stops re-syncing — the convergence path the review required, with no new column. - Additive milestones, so every apply is an idempotent add (no removal, no ordering,
self-healing). Label set:
Ordermatic/Received(a work_item exists),Ordermatic/Submitted(status submitted),Ordermatic/Action Needed(status error).Processing/Needs Infocollapse intoReceivedunder the milestone model. - Gated per-org on
email_processed_writeback_v1(cached, fail-closed) and on the mailbox holdinggmail.modify/Mail.ReadWrite(a read-only grant 403s → permanent no-op skip). The per-submitwriteback_processed_emailstill tagsSubmittedinline for instant feedback;PROCESSED_CATEGORYwas retargeted toOrdermatic/Submittedso inline + sweep converge on one label. Folder move (Archived) stays a SEPARATE concern. - ponytail: window sweep, no synced-marker column — at the two orgs the flag is on for,
re-applying an unchanged label a few times an hour costs nothing. The upgrade (drop
the window, add a per-row synced-state marker) is documented at the activity if
volume/quota ever bite. Removal (mirroring) is the other upgrade if
Action Neededlingering after a laterSubmittedproves confusing.
Layer 4 — Flags stay policy; capability is OAuth scope (corrected)
Section titled “Layer 4 — Flags stay policy; capability is OAuth scope (corrected)”Do NOT collapse the flags into “capability gates.” OAuth gmail.modify /
Mail.ReadWrite IS the capability. The flags are product policy + rollout, and the
three surfaces (label / categorize / physically move) are independent customer
choices — keep them separable. Two real bugs to fix here, not paper over:
- Mailbox resolution guesses.
lib/inbox/move-claimed-email.ts:102picks among an org’s mailboxes;email_eventsalready carries the owningemail_pull_connectiondirectly (schema/email-events.ts:115). Use it. The guess is unsafe for multi-mailbox orgs. - Folder move loses message identity. Moving an Outlook message can change its
usable id;
email_folder.py:199does not persist the returned identity, so later transitions target a stale id. Persist it, or keep move out of the per-transition path.
Migration path
Section titled “Migration path”- Phase 0 (now): writeback at the manual-submit success point. Ships independently.
- Decouple archive from submit + the time-based sweep (Layer 2). Small, high value.
customerLifecycle()view function + the label projection aggregated by source email, with outbox + reconciler (Layer 3). Depends on the milestones-vs-mirroring decision below.- Fix the two Layer-4 bugs (mailbox resolution, folder-move identity).
Open decisions
Section titled “Open decisions”- Milestones vs current-state mirroring — DECIDED (2026-09-04): milestones. received / submitted / action-needed, additive. Safe default; lower churn and write volume than mirroring under provider rate limits. Shipped in Layer 3 above.
- Bidirectional (Gmail → Ordermatic) — out of scope, mirror-only.
NOT in scope
Section titled “NOT in scope”- Merging the domain status fields into one enum (rejected — they are orthogonal).
- Bidirectional sync.
- Per-connector ERP read-back for confirmation-based archive (upgrade path, later).
- Reworking the inbox UI tabs (they keep deriving from the same view).
Failure modes
Section titled “Failure modes”- Dual-write projection (commit + Temporal start) loses labels on crash → outbox required (Layer 3).
- No reconciler → later-enabled connections / transient 403s never converge.
- Multi-order/multi-attachment email → sibling work items race → mislabel unless aggregated by source email.
- Time sweep archives an ERP-rejected order silently → the error path must move it to
failedbefore the sweep can reach it.
Implementation Tasks
Section titled “Implementation Tasks”- T1 (P1, human: ~1h / CC: ~15min) — ts-temporal-worker — fire processed-writeback at the manual-submit success point
- Surfaced by: Phase 0 — label only fires on Finished/auto-submit, not manual Submit
- Files:
apps/ts-temporal-worker/src/activities/erp-writes.ts(afterpersistOrderResultActivity) - Verify: submit a test order with
email_processed_writeback_v1on; assert the Gmail message gets the label
- T2 (P1, human: ~2h / CC: ~30min) — webapp/db — decouple archive from submit + time-based sweep
- Surfaced by: Layer 2 —
submittednever renders (archivedAt precedence); Submitted tab always 0 - Files:
orders/[id]/mark-approved.ts:177,ts-temporal-worker/.../erp-writes.ts:537, a new archive-sweep cron - Verify: submit → work-item shows
submittedin the tab; a >N-day submitted row archives on the sweep
- Surfaced by: Layer 2 —
- T3 (P2, human: ~1h / CC: ~20min) — webapp — resolve mailbox from email_events, stop guessing
- Surfaced by: Codex #11 —
move-claimed-email.ts:102guesses among org mailboxes; unsafe multi-mailbox - Files:
lib/inbox/move-claimed-email.ts, reademail_events→email_pull_connection(schema/email-events.ts:115) - Verify: multi-mailbox org routes the writeback to the mailbox the email was pulled from
- Surfaced by: Codex #11 —
- T4 (P3) — design — milestones-vs-mirroring decided (milestones); Layer 3
shipped as the reconciler sweep (
sync_lifecycle_labels_sweep+ScheduledLifecycleLabelSyncWorkflow). Labels:Ordermatic/Received→Ordermatic/Submitted/Ordermatic/Action Needed.- Verify: with
email_processed_writeback_v1on and agmail.modifygrant, an order email getsOrdermatic/Receivedwithin a tick, thenOrdermatic/Submittedon submit.
- Verify: with
GSTACK REVIEW REPORT
Section titled “GSTACK REVIEW REPORT”| Review | Trigger | Why | Runs | Status | Findings |
|---|---|---|---|---|---|
| CEO Review | /plan-ceo-review |
Scope & strategy | 0 | — | — |
| Codex Review | /codex review |
Independent 2nd opinion | 1 | issues_found | 12 findings, core model miscast |
| Eng Review | /plan-eng-review |
Architecture & tests (required) | 1 | issues_open | model reshaped; Phase 0 fix carved out |
| Design Review | /plan-design-review |
UI/UX gaps | 0 | — | — |
| DX Review | /plan-devex-review |
Developer experience gaps | 0 | — | — |
- CODEX: outside voice ran (134k tokens, high effort). Core call: the “one canonical state + 1:1 projection” framing is miscast (orthogonal domains, overlays, not-1:1) and oversized for the bug — 2-line fix at the submit success point. Design reshaped to absorb it.
- CROSS-MODEL: Claude + Codex agree — model miscast, redesign oversized for the immediate bug, outbox/enforcement/convergence all under-specified. No unresolved tension; the eng review under-weighted “orthogonal domains” and deferred to Codex on it.
- VERDICT: ENG reviewed — direction A (user-approved): ship the Phase 0 writeback-at-submit fix now; reshape the redesign on the corrected model as a separate, validated effort. Not “CLEARED to build the full redesign” — Phase 0 is build-ready; Layers 1/3 need a second pass.
UNRESOLVED DECISIONS:
- Milestones vs current-state mirroring (Open decision #1) — must be decided before Phase 2 (Layer 3); it changes retention, removal, ordering, and recovery.