Skip to content

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.

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), and work_items.status describe 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 / deletedAt are overlays; unarchive must reveal the prior state (work-items/bulk-actions.ts:235 already preserves status across archive). A single flattened enum would need previous_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/deleted have 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.

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_items is 1:1 with email_events (unique constraint on email_event_id), and mailbox labels only apply to email-sourced items, so work_items.status is 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, whose updated_at changed within LIFECYCLE_LABEL_SYNC_WINDOW_MINUTES (default 120). Status writers bump updated_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 Info collapse into Received under the milestone model.
  • Gated per-org on email_processed_writeback_v1 (cached, fail-closed) and on the mailbox holding gmail.modify / Mail.ReadWrite (a read-only grant 403s → permanent no-op skip). The per-submit writeback_processed_email still tags Submitted inline for instant feedback; PROCESSED_CATEGORY was retargeted to Ordermatic/Submitted so 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 Needed lingering after a later Submitted proves 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:102 picks among an org’s mailboxes; email_events already carries the owning email_pull_connection directly (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:199 does not persist the returned identity, so later transitions target a stale id. Persist it, or keep move out of the per-transition path.
  1. Phase 0 (now): writeback at the manual-submit success point. Ships independently.
  2. Decouple archive from submit + the time-based sweep (Layer 2). Small, high value.
  3. customerLifecycle() view function + the label projection aggregated by source email, with outbox + reconciler (Layer 3). Depends on the milestones-vs-mirroring decision below.
  4. Fix the two Layer-4 bugs (mailbox resolution, folder-move identity).
  1. 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.
  2. Bidirectional (Gmail → Ordermatic) — out of scope, mirror-only.
  • 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).
  • 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 failed before the sweep can reach it.
  • 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 (after persistOrderResultActivity)
    • Verify: submit a test order with email_processed_writeback_v1 on; 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 — submitted never 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 submitted in the tab; a >N-day submitted row archives on the sweep
  • T3 (P2, human: ~1h / CC: ~20min) — webapp — resolve mailbox from email_events, stop guessing
    • Surfaced by: Codex #11 — move-claimed-email.ts:102 guesses among org mailboxes; unsafe multi-mailbox
    • Files: lib/inbox/move-claimed-email.ts, read email_events → email_pull_connection (schema/email-events.ts:115)
    • Verify: multi-mailbox org routes the writeback to the mailbox the email was pulled from
  • 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_v1 on and a gmail.modify grant, an order email gets Ordermatic/Received within a tick, then Ordermatic/Submitted on submit.
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.