Skip to content

Unified Revision Workbench: customer order changes and vendor PO acknowledgments on one review surface

Status: IMPLEMENTATION-READY DRAFT 2026-09-04. Product/design direction was approved 2026-09-03 via /autoplan (final gate D5; taste D6 = Approach A, D7 = accept-by-default rows, D8 = E1 + E8 gated on URL verification). Sections 0–10 are authoritative; the review transcript after them is supporting history only. Β§11 and Β§12 are also authoritative (dated addenda, most recent: Β§12, 2026-09-08 β€” binding resequencing decision, supersedes Β§5’s phase order and Β§3/Β§0’s sell-side-first rationale where they conflict). Date: 2026-09-04 (expanded from the approved 2026-09-03 design) Branch: claude/po-order-revision-design-8ac3c8 Owner: David Boone Supersedes (partially): the UX half of debt/backlog/order-lifecycle-expansion-plan.md (2026-06-12, approved, never built). Its order_events data model is NOT superseded; see Β§9.


Two kinds of email arrive that do not create a new order. They change an existing ERP record:

Direction Who sends What they send What must change in the ERP Today
Sell-side (customer revision) A customer (Harris Teeter, SpartanNash β†’ Cleveland Kitchen) A revised / confirming PO for a PO already entered (usually via EDI 850) The existing sales order: line qty, price, added/removed lines, ship-to, requested date Partially built for WhereFour only, as three strips stacked above the review header. Apply path is dead-wired (Β§2.3).
Buy-side (vendor acknowledgment) A vendor / supplier An order acknowledgment, PO confirmation, price/date change, or backorder notice for a PO the distributor placed The distributor’s existing purchase order: confirmed qty, confirmed price, promised date, vendor item #, backorder split Adjacent only: the short-supply pipeline (ShortSupplyEmailWorkflow + 3 ops-mcp tools) handles vendor backorder emails with a read-only P21 inventory lookup. No triage category for acknowledgments, no vendor document role, no PO read or write on any connector.

Both are the same operator job: a document arrived β†’ find the ERP record it refers to β†’ show what changed β†’ let a human decide β†’ write the accepted changes back, safely, with an audit trail.

The pure diff engine (apps/webapp/src/lib/revision/engine.ts) was designed source-agnostic for exactly this (β€œTomorrow: an EDI 850 β€” same target shape, same engine”). The UI and the ERP plumbing were not.

  • P1. A CSR wants one place to resolve β€œthis email changes something that already exists,” regardless of whether the counterparty is a customer or a vendor. The mental model is the same (target β†’ diff β†’ decide β†’ apply).
  • P2. The review page (/orders/pdf/[documentId]/review) is the operator’s home. A revision surface that lives elsewhere would be a second tool to learn; a revision mode of the review page is not.
  • P3. The buy-side has real demand. Gate outcome 2026-09-03: user keeps buy-side in scope on their own knowledge of demand; the reviewer’s challenge (F3/F5/F9) is recorded in Β§8 as accepted risk. Search record (D4, 2026-09-03): repo docs + deleted history, ~/.gstack artifacts, memory, Coda (8 hits, all sell-side POCs), gbrain keyword + semantic (hits: GenFitβ†’Odoo purchase-order migration plan, P21 access requirements, AP & Vendor Analytics warehouse plan, Action Doors call noting AP automation) β€” no vendor-acknowledgment automation design exists in any reachable system. Unverified in this repo: no prior design, issue, or customer ask for vendor PO acknowledgment automation was found in the repo, ~/.gstack artifacts, gbrain, or Coda. The user reports it β€œhas been designed a couple times” β€” those designs need to be located and linked here, or the buy-side scope stands on the user’s word alone. Addendum 2026-09-07 β€” partially resolved. Two named P21 buyers surfaced in Granola after this gate closed: All State Fasteners (Pat, Director of Supply Chain, call of 2026-09-03 β€” β€œthe PO confirmation is a big, big challenge for us, and it has been in every business I’ve been involved with”; asks in order: confirmation matching, auto price-update on a supplier-quoted delta, silent-vendor follow-up, on-time-delivery analytics, one cross-ERP (P21 + InSQL) needs-action view, vendor notes, part/vendor/PO history search; the board wants one vendor for both order entry and supply chain; a prototype demo was committed for the week of 2026-09-07) and P.F. Markey / AFC Tooling (Brandi Goodman, call of 2026-08-11 β€” roughly 300 vendor acknowledgments a day through a separate Zendesk inbox, one person hand-matching each to its P21 PO; a scratch matching POC was promised). Neither is a signed customer and neither has a working P21 API credential, so Β§8 risk 6 / reviewer finding F3 (no live tenant) is unchanged β€” but β€œthe user’s word alone” no longer is. Full analysis and a proposed resequencing: ~/.gstack/projects/ERP-Unlocked-ordermatic/mini-boone-claude-youthful-margulis-38b1fe-design-20260907-125718.md.
  • P4. Cleveland Kitchen’s live pain (duplicate WhereFour orders from confirming POs; the 2026-08-10 Forest City Collective incident) makes the sell-side the first thing to finish, and finishing it is prerequisite to the buy-side sharing the surface.
  • P5. Ponytail posture: reuse the existing review page, engine, order_revisions table, and WhereFour IERPService.updateOrder contract. Build new only where nothing exists (buy-side ERP read/write, header diff, explicit added-line item identity).

Goals

  1. One Revision tab on the review page that is the complete revision experience: target candidates, header + line change set, per-line accept/skip, decision, apply, and history. Replaces the three stacked strips.
  2. Fix the dead wiring so the existing WhereFour sell-side apply path works end to end from the UI and survives a page refresh.
  3. Generalize the revision target behind a small per-ERP interface so the tab does not know it is talking to WhereFour, and so a purchase order can be a target.
  4. Buy-side intake: a vendor acknowledgment email is triaged, extracted into the same extracted-order shape, and lands on the same Revision tab with the distributor’s purchase order as the target. First connector: Prophet21 (its PO views are already ingested to bronze).
  5. Every ERP write stays behind a default-off Flagsmith flag per feature-flag-policy-erp-writes.md.

Non-goals (this plan)

  • Auto-apply without a human decision (the order_events Phase-3 seam stays a seam).
  • A new inbox queue pill (β€œChanges (N)”) β€” surfaced as an expansion candidate, not baseline.
  • Buy-side for connectors other than P21.
  • Rewriting the diff engine’s matching heuristics.

Sub-problem Existing code Reuse
Score candidate ERP orders, bind lines, classify changes lib/revision/engine.ts (scoreCandidates, matchLines, diffLines), lib/revision/types.ts Preserve algorithms; extend output with stable line keys, explicit item id, and header diff (Β§4.3).
Map a conf order into engine input lib/revision/adapters.ts confToRevisionOrder Reuse and extend with explicit item identity; buy-side vendor ack maps through the same canonical shape.
Persist the operator decision, evidence, diff snapshot, patch lifecycle packages/db/src/schema/order-revisions.ts (recorded β†’ approved β†’ patch_started β†’ patch_succeeded / patch_failed / verification_failed) Extend additively per M1 (Β§4.1). approved exists but is currently dead; this plan uses it.
Endpoints: lookup, decision, apply api/erp/orders/lookup-by-po.ts, revision-decision.ts, revision-apply.ts Keep route locations; replace connector checks and client-authored evidence with Β§4.2/Β§4.7 contracts.
Block submit while a revision is active lib/orders/revision-guard.ts + submit.ts:1006-1046 Reuse the gate location, but change it to newest-row semantics and keep revise blocked after apply (Β§4.7).
Lost-update guard, status allowlist, shipment guard, id-keyed PATCH WherefourService.updateOrder (wherefour-service.ts:1433-1552) Preserve guards; extend the result with sanitized prior/current state for audit.
Sell-side update contract IERPService.updateOrder + ERPOperationOptions.{enableWrite, expectedUpdatedAt} + ErpOrderItemInput.{erpLineId, destroy, overrideUnitPrice} Reuse for WhereFour only; B0 decides the separate purchase-order contract.
Existing revision visual vocabulary RevisionPanel.tsx DeltaRow/Section/CandidateCard/Chip Reuse copy/fixtures where useful; the approved rail/focused-card layout replaces these component shapes.
ERP catalog picker ERPSearchModal.tsx (Cmd+K, a11y complete) Reuse to bind an added line to an ERP sku (Β§4.3).
Tab routing on the review page stores/pdfOrder/review-v2.ts ReviewTab, applyInitialReviewTab, REVIEW_TAB_ORDER Add 'revision' member + tab order. applyInitialReviewTab does not read ?tab= today (review-v2.ts:393-398) β€” SSR decides the initial tab (eng A1) and passes it as a hydrator prop; the β€œrevision page” URL is review?tab=revision.
Staleness guard on async lookups revision.ts revisionLookupVersion pattern Keep.
Cross-doc/org reset stores/pdfOrder/reset-on-swap.ts Extend for transient revision state and persisted-row reseeding.
Vendor PO data (read) P21 PO DLT resources, transformers, and bronze/silver models (Aug 2026) Reuse upstream data; B1 adds the missing Typesense serving indexes (Β§4.5).
Reason β†’ copy lib/erp/po-check-reasons.ts As-is.
  • RevisionStrip + PoHistoryStrip + RevisionPanel are three independent client:only islands stacked above the header (review.astro:1065-1069). They do not share state; they can show two amber bars for the same fact, or one bar and silence. Replaced by one RevisionSignalBar + the Revision tab.
  • RevisionCandidate.changeSet is lines-only. No header deltas.
  • RevisionLine.partNumbers blends customer part #, vendor part #, and ERP item id into one array, so apply.ts cannot recover an ERP sku for an added line and unconditionally blocks it.
  • ReviewSubmitFlow.tsx:701-795 carries a manual search-by-po escape hatch inside the reject step. It belongs in the Revision tab’s target picker.
  1. Apply cannot succeed from the UI. recordDecision never sends revisesErpOrderNumber; revision-decision.ts:80 only reads that field; revision-apply.ts:127-133 500s MISSING_TARGET_ORDER. The test fixture hardcodes the value and hides it.
  2. Saved decision vanishes on refresh. $savedDecision/$decisionStatus are never rehydrated from order_revisions; the Apply button disappears after reload.
  3. verification_failed is a dead end. Once WhereFour updated_at moves, every retry fails the lost-update guard; the documented recovery (re-record the decision) has no UI.
  4. before_image is never written although declared required for rollback.

APPROACH A: Extend the review page (Revision tab + signal bar) ← RECOMMENDED
Summary: Add 'revision' to ReviewTab; render RevisionRail and RevisionCenter in
the existing page regions; one RevisionSignalBar replaces the three
strips; generalize the target behind RevisionTargetAdapter; buy-side =
new documentRole + P21 adapter.
Effort: M (sell-side complete) + L (buy-side P21) human ~3 wk / CC ~2 days
Risk: Med β€” review-v2 store is dense; buy-side P21 PO write is an unknown API
Pros: Zero new pages; reuses hydrator, stores, ERPSearchModal, doc viewer, claim,
heartbeat, submit gates; `?tab=revision` is the deep link the inbox needs.
Cons: review.astro grows again (already 1194 lines, 14 flag reads);
the center pane must host a wide side-by-side diff at 320px min width.
Reuses: everything in Β§2.1
APPROACH B: Dedicated /orders/pdf/[documentId]/revise page
Summary: New Astro page that renders doc viewer + a full-width revision workbench;
review page keeps a one-line "This looks like a revision β†’ Open" bar.
Effort: L human ~4 wk / CC ~3 days
Risk: Med-High β€” duplicates SSR access control, flag reads, hydration, heartbeat,
claim, and set-aside from review.astro (β‰ˆ600 lines to copy or extract first)
Pros: Full width for a side-by-side diff; clean URL; review.astro stops growing.
Cons: Two surfaces to keep in sync; operator learns a second screen; the extract-
shared-SSR refactor is a prerequisite nobody has scoped.
Reuses: engine, table, endpoints; not the page shell
APPROACH C: MCP-first (order-lifecycle plan D1), webapp later
Summary: Ship order_events + five ops-mcp tools; CSRs resolve changes via Claude/
Cowork; webapp tab is Phase 2.
Effort: M human ~2 wk / CC ~1.5 days
Risk: High for adoption β€” CK's CSRs work the inbox and review page all day, not MCP
Pros: Already approved (2026-06-12); org-scoped from token; no UI.
Cons: Does not touch the user's stated goal (a revision *page*); leaves the dead
WhereFour apply wiring dead; buy-side still has no ERP path.
Reuses: order-lifecycle-expansion-plan.md design
APPROACH D: Intake + dedupe only (no tab)
Summary: Fix triage so order_change emails with an attachment and a PO on file are processed
as revisions; fix the dead apply wiring; keep today's strips.
Effort: S human ~4 d / CC ~4 h
Risk: Low
Pros: Closes the CK duplicate incident class and the intake drop with the fewest moving parts.
Cons: Leaves added lines unappliable, header changes invisible, decision lost on refresh; no buy-side.
Reuses: everything; adds only a triage rule + flag.
β†’ Absorbed into A as phases A0 + A-in. Not a competitor, a prerequisite.
APPROACH E: Buy-side via the short-supply pipeline, not the review page
Summary: Vendor acks enter through ShortSupplyEmailWorkflow's triage/extraction; resolution is an
ops-mcp tool + a small buyer-facing list, not the CSR review page.
Effort: M human ~2 wk / CC ~1.5 d
Risk: Med β€” no PO write exists on any connector either way
Pros: Keeps vendor traffic out of the CSR inbox; extends a shipped P21 vendor-email path.
Cons: Two resolution surfaces (one per side); no shared diff UI; ops-mcp adoption by buyers unproven.
β†’ Kept as the fallback shape for B1 if vendor acks prove to pollute the CSR queue (Β§8 risk 7).
APPROACH F: Two plans (sell-side now, buy-side later)
Summary: Split this document; ship A0–A3 as its own plan; write the buy-side plan when a live tenant exists.
β†’ Rejected by the user at the premise gate (D1: keep P21 buy-side as planned). Recorded.
On C's reversal: the June 2026 D1 ("MCP-first") predates review-v2 becoming the surface CK's CSRs live in all day
(RevisionStrip/Panel shipped 2026-07-01, review-v2 responsive work Aug 2026). The adoption constraint changed; the
data-model half of that decision (order_events) is untouched (Β§9).
RECOMMENDATION: A. It is the ponytail path (no new page, no new store, no new access-control
surface) and it is also the complete path for the operator: everything they need is where they
already are. B is the right call only if the side-by-side diff proves unusable inside the
center pane; A's tab can be promoted to B later without touching the engine, table, or endpoints.

4.1 Data model (Drizzle schema edits β†’ generated migration; no hand-authored SQL)

Section titled β€œ4.1 Data model (Drizzle schema edits β†’ generated migration; no hand-authored SQL)”

order_revisions migration M1 is an additive expansion. Edit packages/db/src/schema/order-revisions.ts, then run pnpm --filter @repo/db db:generate; never create the SQL file by hand.

Column / index Drizzle shape Null/default/backfill Authoritative meaning
target_kind new order_revision_target_kind enum: sales_order, purchase_order NOT NULL DEFAULT 'sales_order'; no backfill job Chooses the target adapter. The default makes every pre-migration row a sell-side revision.
target_version_token text nullable Opaque concurrency token captured from the authoritative target. New WhereFour rows write its updated_at; P21 hashes the header/line modification values established in B0. During the compatibility window WhereFour also writes the legacy wherefour_updated_at. Apply reads target_version_token ?? wherefour_updated_at.
line_resolutions jsonb typed as Record<lineKey, 'revision' | 'current' | 'dispute'> nullable; old rows use the snapshot defaults The operator’s choices for the server-stamped snapshot. There is no bind action: item matching is persisted on extracted_order_items before confirmation.
patch_started_at timestamp with time zone nullable Time the atomic apply claim succeeded.
patch_completed_at timestamp with time zone nullable Terminal write/verification time, success or failure.
failure_code text nullable Stable machine code such as STALE_TARGET, WRITE_FAILED, or VERIFY_UNKNOWN. Cleared by a new decision row, never by mutating history.
failure_detail jsonb nullable Sanitized diagnostic detail; never credentials or raw request headers.
order_revisions_extracted_created_idx index on (extracted_order_id, created_at DESC) new index Supports newest-row submit guard, SSR hydration, and history without scanning every revision row.

Existing columns remain the compatibility contract:

  • selected_erp_order_id is the connector’s immutable target id.
  • revises_erp_order_number remains the human-readable target number for both sales and purchase orders despite its sell-side name. Do not add a duplicate display-number column merely to rename it.
  • diff_snapshot contains the complete typed ChangeSet, including display-only header deltas. A separate header_diff column would duplicate it and is not added. matched_signals, candidate_count, confidence_tier, and dollars_changed are likewise computed and written by the server, never trusted from the browser.
  • Confirming revise inserts patch_status='approved' when the mapping produced at least one writable line, otherwise recorded (an all-date-only WhereFour revision, or any Eclipse E4 revision). Confirming start_clean inserts patch_status='recorded'. The lane’s ERP-write flag never decides the persisted status: the route returns it as applyEnabled and the workbench withholds Apply while it is off, so an approved row becomes appliable on the next load after the flag is turned on.
  • before_image is the sanitized state read in the same ERP update operation immediately before the write; after_image is the verified post-write state.

M1 generation and deploy sequence: schema edit β†’ Drizzle generation β†’ inspect the generated DDL β†’ run packages/db/schema.test.ts (including its migration idempotency checks) β†’ deploy the additive migration β†’ deploy dual-read/dual-write application code β†’ enable UI flags. M1 requires no data cleanup. If Drizzle emits an ALTER TABLE ... ADD CONSTRAINT, add only the repository-required pg_constraint idempotency guard to the generated file. Rollback is application rollback plus flags off; the additive columns and enum can remain until a later contract migration.

pdf_documents.documentRole requires no database migration. It is already a nullable text column; add 'revision' and 'vendor_acknowledgment' to the TypeScript/Python allowlists and validation tests. documentRoleReason already stores why the classifier chose the role. Write order matters: no pdf_documents row exists at triage time, and the extraction save path today unconditionally rewrites document_role = 'primary_order' on completion (and the multi-attachment fan-out stamps primary_order/supporting per sibling). The triage verdict is therefore carried on the work item / extraction context and stamped at extraction-save, and the save path must never overwrite a non-null revision / vendor_acknowledgment role. resolveTarget treats null and primary_order as the sell-side default so every existing CK conf-PO document keeps working. A Temporal test asserts a fan-out sibling keeps its role.

extracted_orders: no schema change. A vendor ack extracts into the same shape (poNumber = the distributor’s PO #, customerName field carries the vendor name; the Revision tab labels it β€œVendor” when documentRole === 'vendor_acknowledgment'). ponytail: reusing customerName for the vendor name is a known smell; a rename/alias column is the upgrade path if the buy-side grows past one connector.

extracted_order_items also requires no migration. Its existing erp_item_id remains the persisted catalog selection. WhereFour also needs an SKU for some added-line writes, so Confirm resolves that id through the connection-scoped Typesense catalog and stamps erpItemSku into the server-authored diff_snapshot. Do not add a denormalized SKU column solely for this workflow.

No PostgreSQL P21 replica table. erp-replica.ts explicitly marks those tables demo-only and prohibits new production readers/writers. Existing P21 PO bronze/silver data is published to two connection-scoped Typesense collections (purchase_orders, purchase_order_lines) in B1; that serving-index rollout is specified in Β§4.5. This also means B1/B2 have no Drizzle migration.

Not added: header_diff, approved_at, order_versions, parent_order_id, a revision counter, or a new event table. The existing decided_at is the approval timestamp because Confirm inserts an approved row. Target, snapshot, and resolutions become immutable at insert; patch status, timestamps, and images advance forward on that row. Rows are newest-first per extracted order.

apps/webapp/src/lib/revision/targets/
types.ts RevisionTargetAdapter {
kind: 'sales_order' | 'purchase_order'
canApply: boolean
findCandidates(ctx): Promise<RevisionOrder[]> // β†’ engine input
mapChangeSet(cs, resolutions): { items, header, blocked }
apply(ctx, mapped, guard): Promise<ApplyResult>
}
wherefour-sales-order.ts (moves today's findOrdersByPoNumber + apply.ts mapping here)
p21-purchase-order.ts (new; candidates from Typesense projection; live apply only after B0)
index.ts resolveTarget(documentRole, connection.type) β†’ adapter | null

Do not expand IERPService with speculative optional purchase-order methods. B0 first proves P21’s read/update transaction. If it passes, add a focused IPurchaseOrderRevisionService implemented by Prophet21Service; the adapter checks that interface and the connection-level p21_purchase_order_update_v1 flag. ErpOrderResult gains optional sanitized priorState and currentState so the same live read used for the lost-update check becomes the audit image; other connectors may omit them.

lookup-by-po.ts, revision-decision.ts, and revision-apply.ts call resolveTarget() instead of instanceof WherefourService. Every route resolves the document, extracted order, connection, and revision row under the authenticated organization, with one deliberate exception mirroring review.astro: a platform support admin may READ (SSR revision rows, lookup) under the document’s organization, audited through SupportAccessAudit; Confirm and Apply stay viewer-org only, so the tab renders read-only cross-org instead of empty. Apply uses order_revisions.connectionId exactly; it never falls back to the organization’s default connection.

  • RevisionLine.erpItemId?: string and erpItemSku?: string explicitly carry catalog identity, separate from partNumbers[]. confToRevisionOrder fills the id from extracted_order_items.erpItemId; the decision route resolves the SKU from the connection-scoped catalog. An added line is applicable only after the existing Items-tab match flow has persisted the id. The Revision UI opens ERPSearchModal and then reloads/re-diffs. It never embeds catalog identity inside line_resolutions.
  • DiffLine.lineKey: string is generated on the server from canonical source identity: existing line id when present, otherwise the extracted item id. Index/part-number-derived keys are forbidden because reorder/re-match changes their meaning. The server stamps keys into diff_snapshot; the client merely returns choices keyed by those values.
  • ChangeSet.header: HeaderDelta[], where HeaderDelta carries a constrained field name plus normalized old/new display values. diffHeader is pure. Header deltas are display-only for every connector in this plan; a row is labelled Shown only and cannot be counted in the Apply CTA. A sell-side line whose only delta is the promised date is treated the same way (WhereFour writes count/price per line only): shown with a note, skipped by the rail’s a/s/Enter keys, and excluded from β€œN of N changes will apply” and the Ready meter. lineMoneyChanged in engine.ts is the one predicate the mapper, server view model, and client store share for this.
  • mapChangeSet applies only line rows resolved to revision. current and dispute write nothing. revision on removed maps to deletion; revision on added maps to an addition only when erpItemId is present; promos retain the existing custom-line path. Zero mapped mutations is never a successful no-op ERP write: nothing shown and nothing to write is a NO_APPLICABLE_CHANGES mapping failure; an all-date-only WhereFour set maps ok with zero items so the decision is still recorded (Submit stays blocked), and revision-apply answers it 409 REVISION_NOT_APPROVED. An approved row whose mapping is empty at apply time is moved to recorded and answered 422 NO_APPLICABLE_CHANGES instead of reconciling as applied.

Entry and hydration. The server decides the first tab before paint. Precedence: valid explicit ?tab= β†’ revision for a vendor acknowledgment or an existing newest revision row β†’ the current header/items default. A fresh asynchronous sell-side match never steals focus after hydration; it raises the signal bar and the operator opens the tab. With review_revision_tab_v1 on, Revision is always present in the tab list, including no-match and error states.

RevisionSignalBar replaces RevisionStrip + PoHistoryStrip: one row under the header, amber for matches, red only for lookup failures, and one CTA. It renders nothing for a clean no-match unless a sibling-document hit exists.

Final layout. The left rail is the change list. It has the same progress meter vocabulary as Items, then Needs attention, Changes, and collapsed Unchanged groups. The center pane renders only the selected target/change: target banner or target-choice card, one focused MismatchDecisionCard, and the decision/apply block. The existing document pane remains visible. This is the approved round-3 layout; the earlier all-in-one change table in the review transcript is superseded.

  • A target and every actionable change reuse MismatchDecisionCard choice tiles. There are no radio controls.
  • Rows default to revision. MismatchSettledBanner shows the current choice and its reversible action. Sell-side choices are revision/current; buy-side also permits dispute, which records the disagreement and writes nothing for that line.
  • An unmatched/added line opens the existing ERPSearchModal. Matching writes erpItemId through the existing item-match route, then the workbench reloads/re-diffs; Confirm resolves the corresponding SKU server-side. Confirm is disabled until each actionable added/unmatched line is matched or removed from the revision.
  • Header rows show old/new values and Shown only; they have no choice control and are excluded from the Apply count.
  • After a successful apply, target selection and line choices are immutable, the banner reads Applied/verified, and Submit remains disabled to prevent a duplicate create. View before/after reads the stored images.
  • While the newest saved decision is revise, the footer and the header chip describe the revision instead of the new-order submit checklist, in every phase and not only after apply. Both name the connector and the ERP order number from the saved row, for example β€œRevision of WhereFour SO29083”, with the phase beneath it: β€œRecorded Β· no ERP write”, β€œApply from the Revision tab”, β€œApply needs attention Β· see the Revision tab”, β€œApplying to the ERP”, or β€œApplied Β· submit stays locked”. The footer CTA reads β€œOpen Revision tab” and, like ⌘⏎, opens the Revision tab ahead of the split reroute. The target is always named by its ERP order number, never by the document’s PO number. start_clean keeps the ordinary checklist, and approval-only orgs are exempt because nothing is written to the ERP there. Source: $revisionSubmitLock in stores/pdfOrder/revision-view-model.ts.
  • Buy-side labels use ordered, vendor confirmed, and flag for dispute; Submit is unavailable because a vendor acknowledgment is not a sales order.

Component inventory:

Disposition Component/file Responsibility
New review-v2/revision/RevisionSignalBar.tsx Merge ERP match and sibling-document signals into one status/CTA.
New review-v2/revision/RevisionRail.tsx Progress meter, grouped line/header rows, keyboard navigation, document-focus action.
New review-v2/revision/RevisionCenter.tsx Target selector/banner, focused decision card, confirm/apply/history states.
New lib/revision/view-model.ts Pure conversion from lookup/snapshot/persisted choices into rail + center view models; no network calls.
Modify ReviewIssuesRail.tsx / ReviewCenterPane.tsx Render revision rail/center for the Revision tab; preserve every other tab.
Modify ReviewV2Hydrator.tsx / review-v2.ts Add the tab, seed newest rows/history synchronously from SSR, apply URL precedence, reset/reseed on order swap.
Modify MismatchDecisionCard.tsx Controlled selected tile, stable id, correct aria-checked, selected styling, optional caution copy. Existing callers get explicit ids.
Modify MismatchDecisionCard.tsx (MismatchSettledBanner export) Shared success/neutral/dispute tones and action; replaces the scaffold’s duplicate SettledLine.
Modify ReadinessFooter.tsx / ReviewSubmitFlow.tsx Revision-specific readiness and submit lock; remove the reject-flow PO-search escape hatch after tab GA.
Modify review.astro Read flags/document role/revision rows, compute initial tab, mount one signal bar, retain the old strips only for flag-off fallback.
Retire after GA RevisionStrip.tsx, PoHistoryStrip.tsx, RevisionPanel.tsx Delete after two stable weeks with the tab enabled.
Prototype only review-v2/revision/mock-data.ts Move useful cases to test fixtures; never import mock data in production.

State machine (per extracted order):

idle β†’ loading β†’ candidates β†’ selected β†’ approved β†’ applying β†’ applied
└── lookup_error ──retry/searchβ”€β”€β”˜ β”œβ”€β”€ patch_failed ──retryβ”€β”€β”˜
└── verification_failed ──re-check──▢ loading
selected ──start clean──▢ start_clean

applying requires an approved row (409 NOT_APPROVED). Unmatched added lines fail confirmation (422 UNRESOLVED_LINES). Apply after success returns alreadyApplied. A conditional approved|patch_failed β†’ patch_started update allows only one concurrent caller to own the ERP write. Verification failure requires a re-lookup and a new decision row; it never rewrites the old snapshot.

Refresh survives. SSR loads revision rows for all extracted orders in one org-scoped query ordered by (extractedOrderId, createdAt DESC), groups the first row as current, and passes history plus current rows to the hydrator. The composite M1 index supports this query. Swapping orders reseeds from that map rather than showing the previous order’s state.

Responsive/accessibility. Below md, existing mobile pane tabs switch among changes, decision, and document. Choice tiles retain 44px targets and arrow-key navigation. Rail rows use buttons rather than nested links; the PDF-focus action is a sibling button. MismatchDecisionCard reports the actual selected tile, settled actions expose aria-pressed, and the signal bar uses role="status" for progress/success and role="alert" only for failures.

vendor email ──▢ triage (Gemini) ──▢ category 'vendor_acknowledgment' [flag vendor_ack_intake_v1]
β”‚ β”‚
β”‚ β–Ό
β”‚ pdf_documents.documentRole = 'vendor_acknowledgment'
β”‚ extraction: same extracted_orders shape; poNumber = distributor PO #
β–Ό
work_item (sourceType 'email', unchanged) ──▢ inbox row badge "Vendor ack" ──▢ review?tab=revision
β”‚
p21-purchase-order adapter.findCandidates β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
reads Typesense purchase_orders + purchase_order_lines
β†’ RevisionOrder { sourceOrderId = po_no, lines[lineId = line_no, erpItemId = item_id] }
engine.scoreCandidates / diffLines (ordered vs confirmed)
operator decides ──▢ adapter.apply ──▢ P21 updatePurchaseOrder [flag p21_purchase_order_update_v1]

What already exists. Staging already contains P21 purchase_orders and purchase_order_lines DLT resources, full merge-safe hints, transformers, and bronze/silver dbt models. Do not rebuild them. Their dbt models are still excluded from the canonical transform job pending real-run validation, and there is no webapp serving index.

Also already exists β€” do not rebuild, port. The ordermatic-workbench repo (customers/genfit/genfit_p21.py, genfit_ack_extract.py, 02_ack_writeback_demo.py; customers/pf-markey/01_ack_discovery.py, 02_ack_reconciliation.py, 03_ack_evaluation.py) already has a working P21 client, a Gemini vendor-ack extraction schema, and reconciliation/evaluation logic built and run against a live Genfit sandbox and PF Markey’s own ack corpus. See Β§12 for what it proves and the webapp-side porting task it implies for A1/B0.

B1 serving-index rollout (not a PostgreSQL migration). Gate: do not start step 1 until a design partner (ASF or PF Markey) has signed and supplied a working P21 credential (Β§8 risk 6, Β§12) β€” this phase is real infrastructure spend, not a no-cost placeholder.

  1. Validate/materialize stg_purchase_orders and stg_purchase_order_lines for one P21 sandbox connection, then add the four existing bronze/silver models to the canonical dbt transform asset list.
  2. Add Dagster assets for two new Typesense collections. purchase_orders uses id {connection_id}:{company_no}:{po_no} and fields connection_id (facet), normalized_po_no, po_no, company_no, vendor_id, order_date, date_due, and the available modification token. purchase_order_lines uses id {connection_id}:{company_no}:{po_no}:{line_no} and adds line_no, item_id, description, parsed quantities/prices, UOM, completion/cancel state, due date, and line modification token.
  3. Both assets filter by one validated connection partition, reconcile deletes with the existing retained-ratio guard, and fail the run on malformed numeric values rather than silently indexing a wrong diff. Candidate queries always include an exact connection_id filter and exact normalized PO predicate; vendor id only corroborates/ranks and never broadens the query.
  4. Materialize the new collections for the sandbox, compare header/line counts against Iceberg, then enable read-only lookup. Collection deletion is the serving-index rollback; Iceberg remains the source of truth.

B0 hard gate. Before any P21 write code, prove in the Genfit sandbox: live header+line read by immutable identity, the PO update transaction and required fields, line-add/remove support, concurrency/version behavior, idempotent retry, and post-write verification. If any one is unavailable, canApply=false; the product ships read/diff/record/copy-summary and the operator keys P21 manually. Do not infer purchase-order support from P21’s existing sales-order updateOrder method.

B0 is partially pre-run, not blind. ordermatic-workbench/customers/genfit/genfit_p21.py already proves live auth (Infisical universal-auth β†’ P21 /api/security/token) and a live Transaction API write against the Genfit Playground 2 sandbox β€” update_line() sets acknowledged, acknowledged_date, expected_ship_date, date_due, price, and notes via TABPAGE_18/TABPAGE_19, with a dry_run toggle the notebook’s own __main__ self-test disables for one real sandbox write. Still open before B0 can close PASS: Ticket No/Carrier ID/Tracking No (the fields the AFC Tooling P21 screenshot in Β§12 highlights as what β€œAcknowledged” actually touches on their tenant) are untested; there is no multi-line batching, retry, or idempotency handling; locked/ completed POs are explicitly unhandled; and GenFit’s tab-page/DataElement names are a tenant-specific screen customization, unverified against ASF’s or PF Markey’s own P21 configuration. B0 is a tenant-specific re-verification against the design partner’s own screens plus the gaps above, not a from-zero spike.

Approved demo exception. B0 remains NOT PASS for general Prophet21 purchase-order apply. For the explicitly approved Genfit Playground connection only, p21_purchase_order_update_v1 may enable one existing-line, quantity-only update. The service re-reads the PO, requires the persisted version token, validates the immutable line and item identities, posts only the quantity delta, and verifies the target plus every unchanged active line and protected header field. Adds, removals, price/date/header changes, mixed or multi-line operations, and unusable POs remain record-only. This exception is demo evidence, not ASF/P.F. Markey compatibility or a B0 PASS.

Triage/intake requires sender-to-vendor corroboration before the PO lookup so it cannot become a cross-tenant PO-number oracle. The LLM prompt change must add at least 20 order_change attachment cases and 10 vendor-ack cases to the email triage eval, with no regression on the existing corpus.

4.6 Feature flags (all default off, Flagsmith, flags.json + segment overrides)

Section titled β€œ4.6 Feature flags (all default off, Flagsmith, flags.json + segment overrides)”
Flag Scope Gates
enable_order_revision_match (exists) connection lookup + decision + newest-row submit guard
wherefour_order_revision_apply_v1 (exists) connection WhereFour apply; also returned as applyEnabled by revision-decision and the review SSR so the workbench withholds Apply while it is off (currently held OFF live by per-identity overrides)
eclipse_e4_order_revision_apply_v1 (exists, code only) connection Eclipse E4 apply gate named by revisionApplyFlagFor; E4’s mapper never emits an item, so apply always resolves NO_APPLICABLE_CHANGES. Not registered in flags.json.
review_revision_tab_v1 (new) connection Renders the Revision tab + signal bar instead of the three strips. Off = today’s UI, byte-identical.
revision_intake_v1 (new) connection Triage: order_change + attachment + PO on file β†’ process as documentRole='revision' (phase A-in). Off = today’s skip.
vendor_ack_intake_v1 (new) connection Triage category + documentRole + extraction of vendor acks
p21_purchase_order_candidate_source_v1 (new) connection Read-only P21 PO candidate source for the vendor-ack lookup/decision routes. Interim Genfit-demo reader: request-time GET p21_view_po_hdr/p21_view_po_line behind P21PurchaseOrderReader; the B1 Typesense serving index (Β§4.5) remains the target read path
p21_purchase_order_update_v1 (new) connection Default-off Genfit demo exception: one existing-line quantity update with strict pre-read and post-write verification. All other P21 changes remain record-only; B0 remains NOT PASS.

Kill order on incident: flip review_revision_tab_v1 off β†’ strips return; apply flags off β†’ decisions still record, nothing writes.

The staging identity override for wherefour_order_revision_apply_v1 is an A2 verification prerequisite, not deferred scope: lift it only for the internal test connection immediately before the controlled test, then restore it unless the rollout owner explicitly promotes that connection.

Lookup β€” GET /api/erp/orders/lookup-by-po?extractedOrderId=.... The server loads the order/document/connection under the authenticated organization, checks the read flag, resolves the adapter, and derives the PO from the stored extracted order. Manual search adds a bounded query parameter but never a connection id. Return at most ten scored candidates plus truncated; cap the total server crawl at 10 seconds and the browser request at 20 seconds. A truncated lookup is an incomplete check, not a clean one: the signal bar shows amber β€œcheck incomplete” and the submit path treats it like lookupError. Preserve the existing 200-with-lookupError shape for connector failures so the signal bar can explain and retry them.

Confirm β€” POST /api/erp/orders/revision-decision. The canonical strict Zod input is:

{
extractedOrderId: string;
decision: 'revise' | 'start_clean';
selectedErpOrderId?: string | null; // required for revise; null (today's client sends it on start_clean) is treated as absent
lineResolutions?: Record<string, 'revision' | 'current' | 'dispute'>;
}

The browser does not send target number, tier, signals, candidate count, version token, dollars, header diff, or diff snapshot. For revise, the server re-fetches the selected target, re-runs scoring/matching/diffing, stamps stable line keys, resolves persisted item ids to connection-scoped catalog SKUs, validates that resolution keys are members of the new snapshot, defaults omitted actionable rows to revision, and rejects unmatched added lines. A version/identity change returns 409 STALE_TARGET with no row inserted. The single insert contains the authoritative snapshot and returns revisionId plus the persisted view model. For start_clean, target/snapshot fields are null and the row is immediately terminal.

A new decision is refused with 409 ALREADY_APPLIED when any row for the extracted order is patch_started or patch_succeeded; a start_clean can never follow a landed or in-flight apply. dispute is accepted only when the resolved target is a purchase_order (422 otherwise). Confirm fails closed (409 CONNECTION_MISMATCH) unless the document’s own erpConnectionId resolves to an active connection in the organization β€” the org-default fallback is never used to stamp connectionId.

During A2–A5 only, a compatibility wrapper strips exactly these legacy keys the current revision.ts client still sends β€” revisesErpOrderNumber, confidenceTier, candidateCount, matchedSignals, diffSnapshot, whereFourUpdatedAt, dollarsChanged β€” before the strict parse; their values are ignored and only the key names are logged (the snapshots carry customer names and prices). Any other unknown key still fails validation, so the wrapper cannot become a passthrough. A5 deletes the list.

Apply β€” POST /api/erp/orders/revision-apply. Strict input is { revisionId: string }, not extractedOrderId. The route verifies that the row belongs to the org and is still newest for its extracted order, loads exactly row.connectionId, checks the connector write flag again, parses the stored snapshot/resolutions with Zod, and claims it with one conditional update from approved or a known-safe patch_failed to patch_started. The newest-row check and the claim are one statement (UPDATE … SET patch_status='patch_started' WHERE id = $revisionId AND patch_status IN ('approved','patch_failed') AND NOT EXISTS (newer row for this extracted order)), so two Confirms cannot both be applied; the older one returns 409 SUPERSEDED. No unique index is added β€” production already holds several non-terminal revise rows per extracted order, so the conditional statement, not a constraint, is the guard, and M1 stays free of data cleanup. The idempotency key order-revision:{revisionId} dedupes our own claim only; WhereFour accepts no idempotency header, so a retry after an ambiguous outcome must compare live line content against the desired state, never just the version token.

The adapter re-reads the live target and compares target_version_token before writing. The same read is returned as priorState; post-write verification returns currentState. An explicit pre-write failure becomes retriable patch_failed. A stale target, timeout after dispatch, verification mismatch, or unknown outcome becomes verification_failed and requires Re-check/new decision; it is never blindly retried. A stale patch_started row is reconciled by a read-only verify: desired state β†’ success, unchanged state β†’ safe retry, mixed or unreadable state β†’ VERIFY_UNKNOWN. The reconcile runs on any SSR load or Re-check of the tab when a patch_started row is older than the apply timeout, so the UI self-heals instead of showing 409 ALREADY_APPLIED with no action.

Store. Extend stores/pdfOrder/revision.ts as the single owner rather than adding a second revision store. Keep per-order lookup versions and persisted-row maps; actions are lookup, selectTarget, setResolution, confirmDecision, applyRevision, recheck, and seedFromSsr. reset-on-swap.ts resets transient request state and reseeds persisted state for the selected order.

Submit invariant. revision-guard.ts evaluates the newest revision row: any revise decision blocks sales-order Submit in every patch status, while start_clean allows it β€” and independently of recency, any patch_succeeded row for the extracted order blocks Submit permanently (the order already exists in the ERP as a revision). submit.ts separately hard-rejects vendor_acknowledgment documents regardless of client/UI state. Applying a revision closes the work item the way manual submit does since #3325: on patch_succeeded set work_items.status='submitted' and bump updated_at, leave archived_at null (the lifecycle sweep archives later and still applies the customer-facing β€œSubmitted” mailbox label), and dispatch the processed-email writeback for the extracted order with the revised ERP order number. It never reopens the create-order path. A vendor acknowledgment has no sales-order submit and therefore no label path; B2 defines its own close (status submitted, no writeback) explicitly.


Implementation starts from current origin/staging; this planning branch was cut from an older staging tip and keeps falling behind. Rebase before coding and re-run the file map.

S0 evidence ───────────────────────────────────────────────────────────────┐
M1 schema/contracts β†’ A1 engine+adapter β†’ A2 trusted sell-side β†’ A3 store/SSR β†’ A4 UI β†’ A5 cleanup
└──────────── A-in sell-side intake β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
B0 P21 write spike ─┬→ B1 serving indexes β†’ B2 buy-side read/intake
└────────────────────────→ B3 P21 apply (only on PASS)

Superseded by Β§12 (2026-09-08, binding): the P21 track (B0–B3) now runs ahead of A2’s WhereFour vertical and A4’s WhereFour-specific polish, not after them β€” both live design partners are P21-only. A0 and A1 are unaffected; see Β§12 for the revised order and the gate on B1/B2’s start.

  • Label the last 30 days of CK order_change skips: attachment/no attachment, zero delta/non-empty delta, and known/unknown sender. Record only aggregate counts here; do not copy customer data into the repository.
  • Verify the per-connection WhereFour order URL before enabling the optional external link.
  • Schedule the controlled staging apply and the temporary internal-connection Flagsmith override. This is an operational prerequisite, not a code commit.
  • Run B0 in parallel: document the live P21 read/update fields and give the gate an explicit PASS, READ_ONLY, or FAIL result.

M1 β€” Database expansion and shared contracts (PR 1)

Section titled β€œM1 β€” Database expansion and shared contracts (PR 1)”

Modify: packages/db/src/schema/order-revisions.ts, its exports/tests, and apps/webapp/src/lib/revision/types.ts. Generate: one Drizzle migration after rebasing; if other schema work lands first, reset only the migration folder to the new staging base and regenerate per the repository migration runbook.

Add every column/index in Β§4.1, Zod schemas for persisted snapshots and resolutions, RevisionLine.erpItemId/erpItemSku, DiffLine.lineKey, and HeaderDelta. This PR does not change runtime behavior. Exit: generated migration passes the idempotency suite; old rows deserialize using documented defaults; rollback is proven by running the old application against the expanded schema.

New: lib/revision/targets/{types,wherefour-sales-order,index}.ts, lib/revision/view-model.ts. Modify: lib/revision/{engine,adapters,apply}.ts and their tests. Moveβ€”not copyβ€”the WhereFour candidate/mapping logic. Add stable line keys, header display deltas, explicit item identity, resolution filtering, and zero-mutation rejection. Exit: existing engine cases remain green; adapters have no database/UI imports; an unsupported connection returns a typed reason.

A2 β€” Trusted sell-side vertical and lifecycle repair (PR 3)

Section titled β€œA2 β€” Trusted sell-side vertical and lifecycle repair (PR 3)”

Modify: lookup-by-po.ts, revision-decision.ts, revision-apply.ts, WherefourService.updateOrder, ErpOrderResult, revision-guard.ts, submit.ts, and route/guard tests.

  • Implement the request/response and trust-boundary contract in Β§4.7.
  • Populate target number/version and server-derived evidence; use revisionId for apply; load row.connectionId; capture both images; classify unknown outcomes; reconcile abandoned claims before retry.
  • Make the newest-row submit invariant explicit and hard-reject vendor documents.
  • For one rolling-deploy window, decision parsing accepts deprecated extra keys but ignores/logs them, and apply accepts extractedOrderId only as a deprecated fallback that resolves the newest row. Remove both compatibility paths in A5.

Exit: a route-level decision→apply test uses the actual browser payload and reaches the fake adapter; no fixture injects revisesErpOrderNumber; cross-org, wrong-connection, stale-version, concurrent apply, and timeout-after-dispatch tests all fail closed.

A3 β€” Persisted store, SSR, and multi-order behavior (PR 4)

Section titled β€œA3 β€” Persisted store, SSR, and multi-order behavior (PR 4)”

Modify: stores/pdfOrder/revision.ts, review-v2.ts, reset-on-swap.ts, ReviewV2Hydrator.tsx, and review.astro. Extend the existing storeβ€”do not add a parallel decision store. Add SSR row maps/history, initial-tab precedence, per-order request versions, and revision actions. Exit: refresh, browser back, deep link, order swap during lookup, and order swap after apply all restore the correct row without a post-paint tab jump.

A4 β€” Revision tab and signal bar (PRs 5a–5b)

Section titled β€œA4 β€” Revision tab and signal bar (PRs 5a–5b)”

5a shared primitives: fix MismatchDecisionCard selected state/a11y and add tones/actions to MismatchSettledBanner; update every existing caller and test.

5b workbench: land RevisionSignalBar, RevisionRail, RevisionCenter, the view-model wiring, footer/submit states, document focus action, responsive layout, PostHog events, and the inbox row badge (Revision from the newest revision row; Vendor ack from documentRole β€” gate D8/E1) behind review_revision_tab_v1. Replace prototype mock imports with fixtures. Keep old strips only on the flag-off branch. The row’s PDF-focus control is a sibling button, never an element nested inside the row button. Exit: keyboard-only, 320/768/1024/1440px, empty/error/weak/unmatched/approved/applying/ applied/verification-failed, and multi-order scenarios pass.

A-in β€” Sell-side revision intake (parallel after A1, separate PR)

Section titled β€œA-in β€” Sell-side revision intake (parallel after A1, separate PR)”

Modify: apps/temporal-worker/activities/gemini.py, workflows/email_processing.py, role allowlists, infrastructure/flagsmith/configs/flags.json, and eval/workflow tests. Behind revision_intake_v1, process order_change only when an attachment is present and the referenced PO is on file for the connection (the sender check was dropped, see the Superseded note under Phase 3); write documentRole='revision' and its reason. With the multi-attachment fan-out on (extract_all_order_attachments_v1, live for CK and VMS), a change email carrying the revised PO plus the original yields two extracted orders; A-in extracts one revision document per email (the primary attachment) and the eval adds a revised-plus-original two-attachment case. An attachment that extracts to zero lines, or whose PO-on-file lookup fails, falls back to today’s skip with a structured reason rather than creating an empty work item. Shadow-log first, then enable for the internal connection. Exit: β‰₯20 new cases pass with no existing-corpus F1 loss; zero-line and lookup-failure cases assert skip.

B1 β€” P21 read serving layer (PR 6, independent of B0 write result)

Section titled β€œB1 β€” P21 read serving layer (PR 6, independent of B0 write result)”

New/modify: Dagster Typesense assets/tests and canonical dbt asset coverage for the already-existing PO models; webapp server-side Typesense query helper. Perform the four-step rollout in Β§4.5. Exit: exact-PO lookup is connection-isolated, header/line counts reconcile to Iceberg, deletion reconciliation is guarded, and the API never exposes a candidate from a different connection.

B2 β€” Vendor acknowledgment intake and read-only workbench (PR 7)

Section titled β€œB2 β€” Vendor acknowledgment intake and read-only workbench (PR 7)”

Gated on the same signed design-partner credential as B1 (Β§8 risk 6, Β§12) β€” does not start against zero signed customers. Add the triage role/category behind vendor_ack_intake_v1, sender/vendor corroboration, extraction labels, inbox badge/filter, P21 read adapter, and buy-side copy/dispute choices. Skip customer header suggestions and auto-bind for vendor documents. Exit: β‰₯10 vendor-ack eval cases plus route/UI tests; Confirm records a decision, Copy Summary works, sales-order Submit is unavailable, and Apply is absent for any write type B0 did not pass.

B3 β€” General P21 apply (per write type B0 passes)

Section titled β€œB3 β€” General P21 apply (per write type B0 passes)”

Amended 2026-09-17 for the ASF pilot (debt/backlog/supplymatic-asf-phase-1-design.md, Plan amendments): B0 PASS is recorded per write type on the partner’s own tenant β€” Acknowledged, acknowledged date, promised dates, quantity, unit price β€” and B3 proceeds for the passed types only. Line add, remove and cancel stay manual in P21. A line is all-or-nothing: if any changed field on it needs a type B0 did not pass, nothing on that line is written.

Add IPurchaseOrderRevisionService, the proven Prophet21 implementation, version-token construction, mapping, write verification, and the p21_purchase_order_update_v1 double gate. First enable only the sandbox/internal connection. Exit: controlled create/read/change/verify evidence, replay with the same idempotency key, stale-target rejection, and mixed-result reconciliation.

The Genfit quantity-only demo exception above does not start or satisfy B3.

Remove the three legacy strip/panel components, deprecated request-body fallbacks, prototype-only files, and stale docblocks. Do not remove legacy database columns; that is a separately observed contract migration after all deployed versions have stopped reading them.


Layer Required cases Command / evidence
Migration New enum/default, nullable compatibility, descending index, old-row Zod defaults, newest migration covered by idempotency tests pnpm --filter @repo/db test
Engine Stable keys across reorder; added/removed/promo/unmatched; nil/equal/changed headers; resolution filtering; no applicable mutation pnpm --filter webapp test -- src/lib/revision
Decision API Strict ids/enums, body limit, org isolation, server recomputation, stale target, extra/missing resolution keys, unmatched line, start-clean path webapp route tests with DB/adapter fakes
Apply API revision-id binding, newest-row check, exact connection, flag off, atomic claim race, prior/current images, safe explicit retry, timeout unknown, stale/mismatch verify, idempotent success revision-apply.test.ts plus adapter tests
Submit newest revise blocks in all statuses; newest start_clean permits; three-row history ordering; vendor role always blocks revision-guard.test.ts, submit route tests
Store/SSR explicit URL precedence, vendor/current-row initial tab, no focus theft, refresh, back, multi-order reseed, stale lookup discard targeted store + hydrator tests
UI selected tile reports true, no nested interactive elements, keyboard rail/choices, match-and-rediff, exact confirm count, immutable applied view, 320–1440px Vitest/RTL plus recorded staging UI test
Intake attachment and corroboration gates, no-attachment unchanged, role/reason persistence, vendor skips customer resolution Temporal unit/workflow tests + eval corpus
Dagster/Typesense full hints unchanged, connection partition/filter, PO normalization, numeric parse failure, counts, retained-ratio guard, delete reconciliation pnpm --filter @erp-unlocked/dagster test targeted to new assets
Controlled ERP scratch WhereFour order: lookup β†’ decide β†’ apply β†’ verify β†’ refresh; stale edit rejection; same-key replay timestamped staging run note with IDs redacted
P21 gate live read/version/update/verify, add/remove support, timeout/replay behavior B0 spike document; required before B3 exists

Before each PR: pnpm lint, pnpm --filter webapp test for webapp work, pnpm --filter @repo/db test for schema work, and pnpm --filter @repo/temporal-worker test / pnpm lint:python for intake work. Use pnpm --filter @erp-unlocked/dagster test only for Dagster-scoped changes; the root Python test command does not cover the Temporal worker. In a Claude Code worktree under /Volumes, prefix every webapp/db pnpm command with bash scripts/with-native-node.sh (the default shell is Rosetta x64 and vitest/rollup fail without it).


  • order_revisions already carries typed reporting columns; add PostHog events revision_tab_opened, revision_decision_recorded {decision, tier, candidateCount}, revision_applied {targetKind, changes, dollarsChanged}, revision_apply_failed {status} β€” same wiring as trackOrderOpened.
  • Structured server logs on the three routes already exist; add targetKind and adapter name to each.
  • Day-1 panel: decisions/day by tier and decision; apply success vs patch_failed vs verification_failed; median time from lookup to apply.
  • Alert: any patch_failed or verification_failed on a connection with its apply flag on (there should be ~0/day), and any patch_started older than five minutes awaiting reconciliation.
  1. Register all new flags OFF; deploy M1 before code that writes its columns.
  2. Deploy A1–A3 with the old UI still active. Validate read-only lookup, SSR hydration, submit guards, and compatibility request logging.
  3. Enable review_revision_tab_v1 for the internal staging connection. Run the recorded UI suite with ERP write flags OFF.
  4. Temporarily enable the WhereFour apply flag for that connection and perform the controlled scratch-order test. Promote the UI and apply flags separately; a UI rollout never implicitly authorizes writes.
  5. Shadow-log A-in before enabling processing. Enable sell-side intake for one connection only after the eval and aggregate S0 review.
  6. Roll B1/B2 independently as read-only. B3 has its own sandbox/internal/tenant rollout and cannot reuse a sell-side write flag.
  7. After two stable weeks, A5 removes flag-off UI and deprecated payload support.

Rollback order is write flag OFF β†’ intake flag OFF β†’ UI flag OFF. This stops ERP mutation first while preserving decisions/audit. M1 is additive and is not rolled back during an incident. A successful ERP write followed by an application error is recovered from the persisted patch_succeeded row, not by replaying the write.


Failure Stored state Operator/system rescue
Migration applied, old app still serving old columns plus nullable/defaulted M1 columns Old app continues; flags stay off until new code is healthy.
Lookup/Typesense/ERP unavailable no decision row; transient lookup_error Explain source, retry/manual search; Submit remains protected by existing duplicate guard.
Target changes between lookup and Confirm no new row; 409 STALE_TARGET Re-check and present the new diff.
Two users Confirm different targets two immutable rows; newest wins Apply rejects the older revision id as superseded; history shows both actors.
Two callers Apply the same row one patch_started claim Loser polls/refreshes; no second ERP call.
Explicit failure before ERP mutation patch_failed, failure code/detail Retry the same revision id with the same idempotency key.
Timeout or process death after dispatch patch_started then reconciled, or verification_failed/VERIFY_UNKNOWN Read-only verification decides success/safe retry; otherwise human re-check. Never blind retry.
ERP version changed verification_failed/STALE_TARGET Re-check, create a new decision row, and keep the failed row as history.
ERP accepted only part of the payload verification_failed with before/after images Stop writes; show mismatch; hand-correct or create a new reviewed decision.
Database response lost after decision/apply commit authoritative row already present Refresh SSR restores it; client request is safe to repeat.
Work-item close fails after verified apply patch_succeeded; work item still open Retry only the idempotent close; submit guard prevents duplicate order creation.
Vendor document misclassified as sales order role/reason evidence, no revision decision yet Server submit-role check fails closed; correct role and re-run lookup.
Serving index is stale/missing read-only P21 lookup error No P21 apply offered; rebuild collection from Iceberg and compare counts.

Remaining product/technical risks:

  1. P3 is unverified. The user explicitly retained buy-side scope despite no linked customer artifact. B0 and the read-only value test remain the economic gates; B3 is not built merely because the UI can represent it.
  2. P21 PO write API unknown β€” now partially answered. B0 is still a hard gate, but genfit_p21.py (Β§4.5, Β§12) already proves the auth flow and a live Transaction API write for acknowledgment/date/price fields against a P21 sandbox. What remains genuinely unknown is tenant-specific: whether ASF’s and PF Markey’s own P21 screen customizations expose the same tab-page fields, and whether Ticket No/Carrier ID/Tracking No (the fields their own screenshot shows populated on acknowledgment, Β§12) are reachable the same way. If not, buy-side becomes β€œdiff + record + hand-key”, which is still a product.
  3. Center-pane density. The approved rail/focused-card layout avoids a wide side-by-side table. Validate at 1024px and promote to Approach B only if real operators still cannot scan it efficiently.
  4. customerName carrying the vendor name on buy-side rows is a smell (ponytail, noted in Β§4.1).
  5. WhereFour apply flags are held OFF live by overrides not in the repo. A2 cannot be verified against a real ERP until the hold is temporarily lifted for the internal staging connection.
  6. Buy-side has no live P21 tenant (accepted at gate D1). Genfit acquired 2026-07-31; ASF is a POC. B0–B2 use Genfit’s test tenant and the ASF POC only; B3 production verification waits for a live P21 customer. Reviewer finding F3. Addendum 2026-09-07: demand is now named (P3 addendum) β€” All State Fasteners and P.F. Markey are both prospective design partners with their own P21 instances. The realistic path to a working credential is now the design partnership (NDA, inbox/PO-history access, then their P21 login), not Epicor support for the shared POC tenant. Addendum 2026-09-08 (binding, see Β§12): B1’s and B2’s start, not only B3’s write, is now gated on a signed design-partner credential from ASF or PF Markey β€” real Typesense/Dagster/triage/UI surface does not get built ahead of a working P21 login. This closes the ponytail finding that B1/B2 were scoped to start β€œindependent of B0” against zero signed customers.
  7. Vendor acks in the CSR inbox (accepted at gate D1). The reviewer persona for a vendor ack is a buyer, not a CSR. Mitigation: B1’s inbox badge + a documentRole filter so a CSR can hide vendor acks; fallback shape is Approach E. Reviewer finding F5.
  8. Competitive direction (noted). Conexiom (P21/Eclipse partnership 2026-07-14) and Proton lead with P21; nobody lists WhereFour/E4. Unverified web claim from the reviewer; the sell-side WhereFour work is uncontested either way. Reviewer finding F9.
  9. Ignored-banner duplicate path stays open (accepted at gate D3). A strong match shown and ignored writes no row, so only the UI and #2959’s server-side duplicate guard stand between a CSR and a duplicate order. User chose decision-only persistence.
  10. Auto-landing can hide Items. SSR auto-lands only for vendor acknowledgments or an already-persisted revision decision. Fresh sell-side matches show the signal bar and never change tabs after paint.

9. Relationship to order-lifecycle-expansion-plan (2026-06-12)

Section titled β€œ9. Relationship to order-lifecycle-expansion-plan (2026-06-12)”

That plan’s order_events table (EDI-860 change codes, disposition state machine, MCP-first UX) was approved and never built. This plan takes its UX goal (a place to resolve changes) and its write-path principles (stale-link guard, human approval, one PATCH per approval) but keeps order_revisions as the store: it exists, it is written today, and it already has the patch lifecycle. If a change email arrives with no attached document (plain-language β€œmake it 50 not 40”), it has no pdf_documents row and therefore no review page; that case is exactly what order_events was for and stays deferred to TODOS.md with that pointer.


11. Addendum (2026-09-07): named buy-side demand, a proposed resequencing, and an open positioning question

Section titled β€œ11. Addendum (2026-09-07): named buy-side demand, a proposed resequencing, and an open positioning question”

Added by a follow-up /office-hours vision session after this plan’s autoplan review (Β§ below) had already closed. Not itself CEO/design/eng-reviewed β€” treat Β§3/Β§5’s approach and phase table as authoritative until a follow-up review confirms or revises the sequencing below. Nothing else in Β§0–§10 changes; this section only adds evidence and a recommendation.

Demand (updates P3 and Β§8 risk 6 / reviewer finding F3). All State Fasteners (Pat, Director of Supply Chain β€” Granola, 2026-09-03) and P.F. Markey/AFC Tooling (Brandi Goodman β€” Granola, 2026-08-11) are both named P21 buyers with specific, differing asks β€” see the P3 addendum above. Neither has signed nor supplied a working P21 credential, so F3’s core finding (no live tenant) is unchanged; but the plan’s β€œthe user’s word alone” framing of buy-side demand is no longer accurate.

Proposed resequencing (a recommendation, not a decision). Pull B1 (P21 PO serving indexes) and B2 (vendor-ack intake, read-only workbench, copy summary) ahead of E9 (the deferred Eclipse E4 sell-side adapter), scoped to ASF’s and P.F. Markey’s connections only. B3 (the P21 write) stays exactly where Β§4.5/Β§8 gate it, behind a B0 PASS. B0’s test list should add β€œmark acknowledged and attach in P21” β€” P.F. Markey’s actual workflow β€” alongside the line-level update the plan currently assumes is the only write shape. Full reasoning: ~/.gstack/projects/ERP-Unlocked-ordermatic/mini-boone-claude-youthful-margulis-38b1fe-design-20260907-125718.md.

Open positioning question (CEO-review-level, not resolved here). Both accounts describe their own problem in inbox/ticket terms β€” P.F. Markey already runs it as a Zendesk queue; Pat asked unprompted for β€œone view … to see what’s unconfirmed, price-flagged, or needs action” across two ERPs. The Revision tab’s approved rail (progress meter, Needs attention/Changes/Unchanged groups, Β§4.4) already has that shape, built for vocabulary-reuse reasons and never named as a queue. Whether Ordermatic should adopt β€œagentic inbox for industrial-supply back offices” as the platform frame β€” one level above β€œorder lifecycle automation” β€” is analyzed in the vision doc above and is a question for the next CEO/design review, not an architecture change made by this addendum.


12. Resequencing decision (2026-09-08): interfaces first, P21 as the first ERP, WhereFour deprioritized

Section titled β€œ12. Resequencing decision (2026-09-08): interfaces first, P21 as the first ERP, WhereFour deprioritized”

Decision (David Boone, binding for implementation). This section supersedes Β§5’s phase order and Β§3/Β§0’s β€œsell-side first” rationale where they conflict. Β§0–§4 and Β§6–§10 are otherwise unaffected.

Both live design-partner leads β€” All State Fasteners and P.F. Markey/AFC Tooling (Β§0 P3 addendum) β€” are P21 shops. Neither uses WhereFour. Build the ERP-agnostic seam first (M1 + A1, unchanged β€” nothing here touches the interfaces themselves), then implement one ERP end to end before investing further in the other. That ERP is P21, because that is where the live demand is, not WhereFour.

This does not deprioritize the WhereFour dead-wiring fixes (A0, Β§2.3): they are cheap, independent, and real production pain for Cleveland Kitchen today, and proceed on their own PR regardless. What moves behind the P21 track is the larger WhereFour completion β€” A2’s vertical half and A4’s WhereFour-specific polish. A3 (store/SSR) and A4 (tab, signal bar) are already ERP-agnostic by design (Β§4.4): they render whatever RevisionTargetAdapter resolves to, so they get built once, generically β€” with buy-side states included from the start, not retrofitted later β€” and serve P21 first without a WhereFour-only detour.

Real PO + acknowledgment examples, from both design partners (should seed the eval corpus S0/A-in and B2 call for, Β§4.5/Β§5, rather than only synthetic data):

  • All State Fasteners β€” β€œOutgoing PO examples” thread (Doug Baden, CIO, 2026-09-03): two live outgoing PO documents on P21’s standard print layout (PO Number, Supplier ID, Buyer Name, Ship Via, per-line Item ID/Ship Date/Arrive Date/Qty/UOM/Net Unit Price/Extended Price). Also on file: historical sales- and purchase-order-line exports (Alex Obelar, 2026-08-25) and a business-case proposal. Correction 2026-09-17: that proposal is not signed. ASF agreed to the $10K verbally; the written scope is still unsigned and ASF signs it in week 0, before any B1 spend (debt/backlog/supplymatic-asf-phase-1-design.md, Plan amendments). Evidence retained in the sales inbox, not this repo.
  • P.F. Markey / AFC Tooling β€” β€œMeeting recap 8/11” thread (Brandi Goodman, 2026-08-11): a matched PO + vendor-acknowledgment document pair and, critically, a screenshot of P21’s own Purchase Order Entry screen mid-acknowledgment on a real vendor PO. It shows Acknowledged (checkbox), Ticket No, Carrier ID, Tracking No, and Status as the fields P21 actually surfaces for this workflow, alongside the standard header/line fields β€” the concrete field target Β§4.5/B0 was missing. Evidence retained in the sales inbox, not this repo.

Prior art already built, in ordermatic-workbench β€” do not rebuild, port:

  • customers/genfit/genfit_p21.py β€” a working, dependency-light P21 client. Proves live auth (Infisical universal-auth β†’ P21 /api/security/token) and a live Transaction API write (update_line(): acknowledged, acknowledged_date, expected_ship_date, date_due, price, notes via TABPAGE_18/TABPAGE_19) against the Genfit sandbox, with a dry_run toggle the notebook’s own __main__ self-test disables for one real sandbox write.
  • customers/genfit/genfit_ack_extract.py β€” a Gemini vendor-ack extraction schema (header: vendor/ack#/PO#/date/terms/ship-via; lines: line#/part#/description/ qty/UOM/price/promised ship & delivery dates/backorder/status note; plus surcharges and order total) β€” the target shape for A-in/B2’s extractor, not a new schema to design.
  • customers/genfit/02_ack_writeback_demo.py β€” chains extract β†’ reconcile (by normalized item_id) β†’ write β†’ read-back-and-diff, end to end.
  • customers/pf-markey/02_ack_reconciliation.py β€” a generic, ERP-agnostic line-matching engine (reconcile_document/compare_pair): matches by line_number first, falls back to unique part_number, and explicitly flags duplicate_or_split_line, reused_ack_line, unmatched_ack_line, substituted_part_number, missing_required_field as needs_review rather than guessing. Reusable comparison-engine prior art, not PF-Markey-specific.
  • Still genuinely missing, per that same code: no Ticket No/Carrier ID/Tracking No handling (see the AFC Tooling screenshot above); no multi-line batching, retry, or idempotency; no locked/completed-PO handling; and every tab-page/field name is GenFit’s own P21 screen customization β€” unverified against ASF’s or PF Markey’s own P21 tenant. B0 is re-verification against a design partner’s real screens plus these gaps, not a from-zero spike.
  • The PF Markey notebooks are explicitly gated in their own README from touching production code, the webapp, or P21 writeback until crystallization criteria clear (60 adjudicated positives, zero false clears, β‰₯50% layout coverage, β‰₯30% shadow-tested labor reduction) β€” none met yet (12 synthetic pairs today). Porting the reconciliation algorithm into the webapp for a signed design partner is in scope for B1/B2; porting the notebook or its synthetic data is not.
M1 schema/contracts β†’ A1 engine + adapter interfaces (ERP-agnostic, unchanged)
β”‚
β”œβ”€β–Ά A0 WhereFour dead-wiring fixes (cheap, independent, CK pain β€” own PR, unaffected)
β”‚
└─▢ P21 track (priority β€” both live leads are P21):
B0 tenant re-verification (base: genfit_p21.py + the AFC Tooling field
list, not a blind spike) β†’ [gate: signed ASF/PF Markey credential,
Β§8 risk 6] β†’
B1 serving indexes β†’ B2 buy-side intake/read workbench β†’
B3 P21 apply (per write type B0 passed on the partner's own tenant)
β”‚
β–Ό follows P21, or runs opportunistically in parallel β€” no
longer gates or precedes it
A2 WhereFour trusted sell-side vertical β†’ A4 WhereFour-specific polish β†’ A5 cleanup

  • β€œChanges (N)” inbox queue pill and change cards (lifecycle plan UX) β€” expansion candidate.
  • Plain-text change emails without a document (order_events).
  • Buy-side for Eclipse E4 / Acumatica / NetSuite.
  • Auto-apply policy seam.
  • Rollback from before_image (populated by this plan; a β€œRevert” action is a follow-up once one real apply has needed it).
  • Renaming customerName β†’ counterparty on extracted_orders.

10.1 Open questions (raised at /ship 2026-09-07, decide before the phase that names them)

Section titled β€œ10.1 Open questions (raised at /ship 2026-09-07, decide before the phase that names them)”
  • S1 (A1): should Confirm carry the lookup token it was rendered against, so a stale candidate list cannot be confirmed after a Re-check? Default: yes, 409 STALE_LOOKUP on mismatch.
  • S4 (A2): after a partial apply, the content-compare on retry must treat already-applied lines as satisfied, not as drift. Default: compare against after_image per line, not the whole order.
  • S6 (A-in / B2): vendor acknowledgments that are not ours need a not_ours decision instead of start_clean, and submit must reject them before B2. Default: add not_ours to the decision enum in A2.
  • S7 (A-in): accept-by-default plus a DMARC-unaligned sender is a spoofing surface; surface alignment on the target card and drop accept-by-default when unaligned. Default: yes, amber β€œsender not verified” chip.
  • S11 (A1): Typesense candidate search should use scoped keys per org rather than the admin key in the browser path. Default: scoped keys, already the pattern elsewhere in the webapp.

Assets live in the repo at apps/docs/public/design/revision-tab-20260903/ and render on the docs site under /design/revision-tab-20260903/. Winner r3-F.png (round 3, 2026-09-03), companion states r3-G..L, approved.json, the token-based harness variants2.html, and the per-round feedback JSON. Round 2 rated 5/5; round 3 refinements approved on submit. Round-1 variants and the round-2 set are superseded and were not committed.

State Image
Winner: sell-side reviewing, focused change as MismatchDecisionCard r3-F
First open: three target tiles + search field r3-G
Needs-match line r3-H
1024px laptop r3-I
Buy-side, Dispute as third tile r3-J
Applied: green banner, Submit disabled r3-K
Header field shown for reference r3-L

Β§4.4 is the authoritative description of this layout. The design-shotgun rounds and the Design Phase 2 transcript below are history; where they differ from Β§4.4 (toggle rows, radio pickers, a β€œbind” action, a β€œRevert” label, a spanning RevisionWorkbench island or ChangeSetTable), Β§4.4 wins.


GSTACK REVIEW β€” Phase 1: CEO Review (via /autoplan, mode SELECTIVE EXPANSION)

Section titled β€œGSTACK REVIEW β€” Phase 1: CEO Review (via /autoplan, mode SELECTIVE EXPANSION)”
Premise Verdict Evidence
P1 one surface for customer + vendor changes Accepted with a caveat The operator job is isomorphic (target β†’ diff β†’ decide β†’ apply) and the engine is already source-agnostic. The caveat: the data is not symmetric. Sell-side target = sales order the distributor controls; buy-side target = purchase order whose counterparty (the vendor) is the source of truth for confirmed qty/date. On the buy-side the β€œrevision” is the vendor overriding the distributor, so β€œaccept” means β€œconcede”, not β€œcorrect”. The tab copy must reflect that (Β§4.4 buy-side labelling) or CSRs will misread a vendor short-ship as an error to reject.
P2 review page is home Accepted review.astro already hosts claim, heartbeat, set-aside, submit gates, doc viewer, 14 flag reads. A second page would duplicate ~600 lines of SSR access control. applyInitialReviewTab gives a deep link for free.
P3 buy-side demand Unverified β€” challenged No design, issue, memory, Coda page, or gbrain entry mentions vendor-ack automation. The quote-to-cash vision memo (2026-05-07) lists six stages and procurement is not one. Triage taxonomy has no vendor category. The strongest adjacent signal is the shipped short-supply pipeline (P21, read-only), which proves vendors’ emails do reach the inbox. This is the gate question.
P4 sell-side first Accepted CK duplicate-order incident 2026-08-10; apply flags exist but the UI path is dead-wired; a controlled live PATCH already succeeded (#3028). Finishing is cheap and the pain is real.
P5 reuse over rebuild Accepted Β§2.1 maps 15 sub-problems to existing code. New code is confined to: header diff, erpItemId on lines, adapter seam, tab UI, buy-side triage/extraction/P21 adapter.

Doing nothing: CK keeps producing duplicate WhereFour orders whenever a clerk records β€œrevise” and then submits, or ignores the strip; every confirming PO costs a manual WhereFour edit. Real, recurring, customer-reported.

See Β§2. Nothing in this plan rebuilds an existing capability; the three strips are replaced, not re-implemented (their DeltaRow/CandidateCard/state machine move into the tab).

CURRENT STATE THIS PLAN 12-MONTH IDEAL
Sell-side revision: WF only, three Sell-side complete on WF: one tab, header Every inbound document classified by
strips, apply dead-wired, decision + line diff, added-line binding, survives intent (new / revise / vendor-ack /
lost on refresh, no history. refresh, history, approved→apply lifecycle. cancel / rfq) lands on the review page
Buy-side: nothing. Target adapter seam (capability, not in the right mode, targets the right
Engine: source-agnostic but unused instanceof). Buy-side P21 gated on a spike; ERP record on WF / E4 / P21 / Acumatica,
beyond WF. diff+record works even if P21 write does with policy-gated auto-apply for strong-
not. tier low-dollar changes and a revert.

Delta: this plan moves toward the ideal on every axis except auto-apply and revert, which it deliberately leaves as seams (approved state, before_image).

See Β§3 (A: extend review page β€” recommended; B: dedicated page; C: MCP-first). Auto-decided A (P1 completeness: everything the operator needs is where they already are; P5 explicit: no new access-control surface). A vs B is close on UX (side-by-side width) β†’ logged as a TASTE DECISION for the final gate.

Complexity check: Phases A1–A3 touch ~14 files and add 2 new modules (lib/revision/targets/, RevisionWorkbench). Smell triggered. Challenge: can the goal be met with fewer parts? Yes for the bug fixes (A0 = 4 files, ships alone). No for the rest: the adapter seam is what lets buy-side exist without a second instanceof chain, and the tab is the stated deliverable. Mitigation: A0 is its own PR; A1–A3 is one stack; B* is gated on B0.

Minimum set that achieves the stated goal: A0 + A3 (tab on WF only, no adapter, no header diff). Rejected as the plan baseline because it would hard-code instanceof WherefourService into a fourth place and leave added lines permanently blocked β€” the very complaint the CK clerk hits.

Expansion scan (candidates, auto-decided per the 6 principles):

# Candidate Effort (human / CC) Blast radius Decision Principle
E1 Inbox row badge β€œRevision” / β€œVendor ack” (from newest order_revisions row or documentRole) 0.5 d / 30 min inbox row projection (3 files, outside plan files) TASTE β€” borderline radius; the plan’s Β§4.5 already assumes a badge for buy-side P2 borderline
E2 β€œChanges (N)” inbox queue pill + change cards (lifecycle plan UX) 3 d / 3 h inbox cockpit, work-item projection, API (>5 files) Defer β†’ TODOS P2 outside radius
E3 β€œRevert” action from before_image 1 d / 1 h new ERP write path + flag Defer β†’ TODOS P2 outside; new ERP write needs its own flag + spike
E4 Auto-preselect a lone strong candidate 0.2 d / 10 min tab Reject Contradicts the load-bearing β€œnever preselect” rule (PO is non-unique account-wide; wrong pick = wrong PATCH). Not P3-cleaner, it is P1-incomplete on safety.
E5 β€œCopy change summary” (plain text of header+line deltas) 0.3 d / 15 min RevisionWorkbench Accept P1: it is the whole product for buy-side while B0 is unproven (CSR hand-keys P21 from a clean diff)
E6 Empty change set β†’ one-click β€œMark as duplicate” that prefills the existing set-aside reason 0.3 d / 15 min RevisionWorkbench + existing reject flow Accept P4 DRY: reuses ReviewSubmitFlow’s prefilled reason; P1 completeness of the identical-document case
E7 Keyboard: a/s accept/skip on focused change row, j/k move 0.3 d / 15 min RevisionWorkbench Accept P1; review-v2 already has a Cmd+K convention
E8 β€œOpen in WhereFour” link on the target card 0.2 d / 10 min target card; needs per-connection WF base URL (tenant subdomain exists in extraConfig) TASTE β€” small but depends on a URL pattern not verified here P2 borderline
E9 Eclipse E4 sales-order target adapter (E4 has updateOrder; PO lookup exists only via Temporal checkErpForPoNow) 3 d / 3 h E4 service, Temporal Defer β†’ TODOS P2 outside radius
E10 Plain-text change emails with no document (order_events) XL new table, triage, MCP Defer β†’ TODOS (already Β§9/Β§10) P2 outside

Accepted into scope: E5, E6, E7. Taste (final gate): E1, E8, A-vs-B. Deferred: E2, E3, E9, E10. Rejected: E4.

Platform potential: RevisionTargetAdapter is the seam. Any connector that can find-by-PO and update-by-id becomes a revision target with one file; the tab, table, store, and lifecycle do not change. That is the infrastructure argument for A1 even if buy-side is cut.

Step 0E β€” Temporal interrogation (human hours; CC β‰ˆ 10–20x faster)

Section titled β€œStep 0E β€” Temporal interrogation (human hours; CC β‰ˆ 10–20x faster)”
HOUR 1 (foundations) Where does the tab body mount? β†’ ReviewCenterPane's ReviewTab switch; add 'revision'.
How does SSR know a decision exists? β†’ review.astro selects the newest order_revisions row for
each extracted order when review_revision_tab_v1 is on (one query, in the existing flag block).
RESOLVED NOW: hydrate via ReviewV2Hydrator props, same as every other flag.
HOUR 2-3 (core) Line key must survive a re-render but not a re-lookup β†’ existing.lineId ?? 'conf:<part>:<idx>'.
line_resolutions must be validated server-side (zod: action enum, erpItemId string ≀64, keys must
exist in the persisted diff_snapshot) β€” apply never trusts client keys.
Which header fields can WF apply? ship-to + order date (updateOrder contract); others "shown, not applied".
HOUR 4-5 (integration) ReviewSubmitFlow's search-by-po escape hatch must REMAIN while review_revision_tab_v1 is off
(flag-off = byte-identical). Remove only at GA.
Multi-order workbooks: revision state is per extracted order β€” order.ts:201 already re-fires
loadRevisionMatch on switch; the new atoms must reset there AND in reset-on-swap.ts.
Support admin cross-org view: decision/apply endpoints read the VIEWER's org; a support admin sees the
tab but gets 403 on write β€” same asymmetry as approvalOnlyMode, document it in the tab's error copy.
HOUR 6+ (polish/tests) Register every new flag in infrastructure/flagsmith/configs/flags.json β€” a flag read in code but
absent from flags.json is inert (shipped-but-unregistered pattern, memory). Run webapp vitest via
with-native-node.sh in worktrees. Triage prompt change β†’ packages/eval email-triage suite.

SELECTIVE EXPANSION (autoplan override; feature enhancement on an existing system). Approach A under this mode.

CODEX SAYS (CEO β€” strategy challenge): [codex-unavailable] β€” ChatGPT backend returned 404 on /models and /responses, retry with an explicit model rejected (gpt-5.1-codex not supported on a ChatGPT account). Degraded to subagent-only for all phases.

CLAUDE SUBAGENT (CEO β€” strategic independence): nine findings, two verified in code by the orchestrator and marked βœ”.

# Finding Sev Verified Disposition
F1 Revision emails never reach the review page: triage prompt classifies β€œa PO change request for an already-entered PO” as skip (gemini.py:1830, 1856); the skip branch persists an email_events row and creates no work item / no pdf_document (email_processing.py:182-195). _unverified_po_override flips order_changeβ†’process only when the PO is NOT on file β€” the inverse of a revision. Only confirming POs that look like new orders reach the strips today (that is CK’s EDI-then-conf case, which is why the strips work for CK at all). CRITICAL βœ” Add intake phase (gate Q2)
F2 The cited incident is dedupe, not diff: #2959 already added a server-side duplicate guard; the residual gap it names (β€œbanner showed, clerk ignored, no row written”) is not closed by a tab because order_revisions rows exist only on explicit decision. For EDI customers the conf usually has zero delta. HIGH βœ” (revision-guard.ts:12-20 says exactly this) Accept: auto-persist a detected row on a strong match so the submit guard demands a decision; E6 (empty diff β†’ archive as duplicate) already accepted (gate Q3)
F3 Buy-side P21 has no live customer: P21 connections are genfit_prod (Genfit acquired 2026-07-31, 0 orders/wk since 07-24) and conn_asf_p21 (POC). prophet21-service.ts has no PO write. CRITICAL βœ” (segment-overrides.json; memory) USER CHALLENGE (gate Q1)
F4 β€œBuy-side: nothing” is overstated: short-supply pipeline (ShortSupplyEmailWorkflow + 3 ops-mcp tools, P21 read-only) is vendor backorder handling. MEDIUM βœ” (Explore report) Correct Β§0 table; if buy-side proceeds, it extends short-supply intake, not the CSR review page
F5 One surface for two personas is asserted: candidate scoring (customer/ship-to/date signals) is meaningless for a vendor ack (distributor PO # is a unique key); the reviewer is a buyer, not a CSR; vendor acks in the CSR inbox pollute the queue. Shared abstraction reduces to the diff table + engine. HIGH partially (engine signals confirmed) Folded into Q1; P1 downgraded to β€œengine-level reuse, not surface-level” pending user
F6 Premises β€œto be confirmed” with no gate mechanism; June plan’s Sprint 0 (hand-label 83 emails, size duplicate-PO groups, CK ping) never ran. MEDIUM βœ” (no artifact in repo) Add Sprint-0-lite as A1 entry gate (1 day)
F7 A0 cannot be verified in prod: CK apply flags held OFF by live identity overrides not in repo. MEDIUM βœ” (segment-overrides.json:228,234) A0 exit criterion: override lifted on staging 4f234677
F8 Approach C’s reversal (June D1 β€œMCP-first”) unexplained; alternatives D (intake fix), E (short-supply extension), F (split plans) absent. MEDIUM β€” Β§3 gains D/E/F; C reversal justified: June D1 predates the review-v2 surface CK’s CSRs now live in
F9 Competitive: Conexiom (P21/Eclipse partnership 2026-07-14) and Proton lead with P21 and market PO-ack automation; neither lists WhereFour/E4/Acumatica. P21 buy-side attacks incumbents where strongest. MEDIUM unverified (web) Note in Β§8; strengthens Q1
CEO DUAL VOICES β€” CONSENSUS TABLE:
═══════════════════════════════════════════════════════════════
Dimension Claude Codex Consensus
──────────────────────────────────── ──────── ────── ─────────
1. Premises valid? INVALID N/A FLAGGED (F1 verified, F3 verified)
2. Right problem to solve? INVALID N/A FLAGGED (dedupe + intake before diff)
3. Scope calibration correct? INVALID N/A FLAGGED (buy-side P21 β†’ user challenge)
4. Alternatives sufficiently explored?INVALID N/A FLAGGED (D/E/F added)
5. Competitive/market risks covered? UNCERTAIN N/A N/A
6. 6-month trajectory sound? INVALID as scoped / VALID if reshaped N/A FLAGGED
═══════════════════════════════════════════════════════════════
Single voice; every critical finding flagged regardless. Two verified criticals (F1, F3) go to the premise gate.
A0 dead-wiring fixes + F7 exit criterion (1 d / 1 h)
A0b auto-persist `detected` row on strong match; empty-diff β†’ archive (1 d / 1 h) ← closes the incident class
A-in intake: order_change + attachment + PO on file β†’ process,
pdf_documents.documentRole='revision', flag revision_intake_v1,
β‰₯10 triage eval cases (budget moved from vendor-ack to sell-side) (2 d / 2 h) ← without this the tab sees a trickle
S0 Sprint-0-lite: label the last 30 d of CK order_change skips;
size zero-delta vs non-empty-diff (1 d / 2 h) ← gates A1–A3
A1–A3 adapter seam + engine additions + Revision tab (8 d / 8 h) ← if S0 shows a material diff tail
B* buy-side: PARKED β€” revive trigger = named live customer on an ERP with a known PO write API;
if revived, intake extends the short-supply pipeline, review surface reuses the tab.

CEO Sections 1–11 (SELECTIVE EXPANSION; every finding auto-decided per the 6 principles and logged)

Section titled β€œCEO Sections 1–11 (SELECTIVE EXPANSION; every finding auto-decided per the 6 principles and logged)”
review.astro (SSR)
β”œβ”€ flags: enable_order_revision_match, review_revision_tab_v1, apply flags
β”œβ”€ NEW: newest order_revisions row per extracted order (one DISTINCT ON query, flag-gated)
└─ ReviewV2Hydrator ──▢ stores/pdfOrder/{review-v2 (ReviewTab+'revision'), revision (+ $lineResolutions,
$headerDiff, $history, $approvedAt)} ◀── reset-on-swap.ts, order.ts:201
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β–Ό β–Ό β–Ό
RevisionSignalBar (replaces RevisionStrip + ReviewCenterPane ── tab 'revision' ──▢ RevisionWorkbench ReviewSubmitFlow
PoHistoryStrip; merges lookup + po-history) β”œβ”€ TargetPicker (CandidateCard, search-by-po) (escape hatch kept
β”œβ”€ ChangeSetTable (DeltaRow, HeaderDeltaRow, Bindβ†’ERPSearchModal) while flag off)
β”œβ”€ DecisionBar (start clean / confirm / apply / re-check)
└─ HistoryList
β”‚ GET lookup-by-po β”‚ POST revision-decision (+line_resolutions, header_diff) β”‚ POST revision-apply
β–Ό β–Ό β–Ό
lib/revision/targets/index.ts resolveTarget(document, connection) ─── capability check, NOT instanceof ───┐
β”œβ”€ wherefour-sales-order.ts β†’ WherefourService.findOrdersByPoNumber / updateOrder (unchanged) β”‚
└─ p21-purchase-order.ts β†’ p21_purchase_orders PROJECTION (PG, Dagster-fed) / Prophet21Service.updatePurchaseOrder (B2)
lib/revision/engine.ts (+diffHeader) ── pure ── lib/revision/apply.ts (+erpItemId path) ── order_revisions (+3 cols)
INTAKE (A-in, temporal-worker): email ─▢ triage ─▢ [order_change ∧ attachment ∧ PO on file ∧ revision_intake_v1] ─▢ process
└─ else skip (today) documentRole='revision' ─▢ work_item ─▢ review?tab=revision

Findings (auto-decided):

# Finding Decision Principle
S1.1 SSR β€œnewest decision row” hydrate would be one query per extracted order on multi-order workbooks. One DISTINCT ON (extracted_order_id) … ORDER BY created_at DESC query for all orders on the document, wrapped in raceBackfill (3 s) like the other SSR backfills; failure hydrates idle + logs. P3, P5
S1.2 Β§4.5 says buy-side candidates β€œread the warehouse projection”. Bronze/silver live in Iceberg-on-R2 (DuckDB), not in the webapp’s Postgres (memory: warehouse βˆ‰ webapp PG). A webapp route cannot query bronze. Add a Dagster-fed PG projection p21_purchase_orders (header + lines, per connection), same pattern as wherefour-po-numbers.ts (2-hourly). Candidate lookup reads PG. Adds one table + one Dagster asset to B2. P5 explicit, P4 (reuse projection pattern)
S1.3 Header deltas: whether WherefourService.updateOrder accepts ship-to / order-date changes is asserted, not verified in this review. A2 task: verify against ErpOrderUpdateInput; any field the contract lacks renders β€œshown, not applied”. No new write fields added to the connector in this plan. P5
S1.4 resolveTarget must use the document’s connection (as the three routes already do via getOrgScopedConnectionOrDefault), never the org default, or a multi-connection org diffs against the wrong ERP. Codify in targets/index.ts signature: resolveTarget(connection) takes the resolved connection, not an org id. Test for a two-connection org. P5
S1.5 Coupling: RevisionWorkbench reads 9 atoms across two store files. Acceptable β€” same shape as ReviewCenterPane. Split into four child components (TargetPicker / ChangeSetTable / DecisionBar / HistoryList) so each reads ≀3 atoms. P5
S1.6 Single point of failure: WhereFour API for lookup (crawl up to 50 pages). Already bounded by 15 s client timeout and 200-with-lookupError contract. No change. Signal bar’s error state is the visible failure. β€”
S1.7 Rollback: flags. review_revision_tab_v1 off β†’ strips return (kept in tree until GA). Apply flags off β†’ decisions record, nothing writes. Migration is additive (nullable/defaulted); no down-migration needed. Documented in Β§9 rollout. β€”

Scaling: 10Γ— load is 10Γ— lookups; each is one WF crawl (bounded). 100Γ— would hit WF rate limits before us β€” surface as lookupError (already). Production failure scenario per integration point: WF 401 mid-crawl β†’ wherefour_auth_rejected reason β†’ red bar with β€œverify manually”; WF updated_at drift between decision and apply β†’ verification_failed β†’ Re-check path; P21 projection stale (Dagster lag ≀2 h) β†’ candidate shows stale totals β†’ tier chips still honest; expectedUpdatedAt unavailable on P21 β†’ adapter must implement its own guard (compare projection last_modified to live read) or refuse apply. Logged as B2 requirement.

METHOD/CODEPATH | WHAT CAN GO WRONG | EXCEPTION / SIGNAL
----------------------------------------|--------------------------------------------|---------------------------------
lookup-by-po β†’ findOrdersByPoNumber | WF timeout / 5xx | fetch error β†’ lookupError:true
| WF 401 (rotated creds) | wherefour_auth_rejected
| Infisical/config missing | no_db_credentials
| non-advancing paginator | guard throws β†’ lookupError
| >50 pages (old PO) | silent truncation ← GAP
lookup-by-po β†’ scoreCandidates/diff | conf has 0 lines | overlap 0/0 β†’ NaN? ← verify
review.astro SSR newest-row hydrate | PG slow / down | raceBackfill 3 s β†’ idle, log
revision-decision | zod: bad line_resolutions key/action | 400
| key not in persisted diff_snapshot | 422 UNRESOLVED_LINES
| erpItemId not in this connection's catalog | ← GAP (see S3.1)
| flag off | {recorded:false, reason:'flag_off'}
revision-apply | no 'revise' row | 409 NOT_A_REVISION
| row not 'approved' | 409 NOT_APPROVED (new)
| claim lost (two tabs) | 409 (existing conditional UPDATE)
| blocked lines | 422 {blockedLines}
| WF updated_at drift | verification_failed (existing)
| WF PATCH 5xx / timeout (65 s) | patch_failed
| already applied | {alreadyApplied:true}
diffHeader | field nil on one side | delta with old/new undefined β†’ render "β€”"
resolveTarget | no adapter for connector | {enabled:false, reason:'unsupported_erp'}
A-in triage (gemini.py) | LLM malformed / refusal | existing retry 3Γ— then skip (today's path)
| "PO on file" lookup fails | ← must fail to SKIP (today's behavior) + log
| attachment present but not a PO | extraction yields 0 lines β†’ work item with 0 lines ← GAP
p21-purchase-order adapter (B2) | projection stale (>2 h) | show `as of` timestamp; tier still honest
| P21 PO write unsupported (B0 fails) | adapter.apply β†’ 'unsupported' β†’ tab copy
| no version token | adapter-level guard or refuse ← B2 requirement
EXCEPTION / SIGNAL | RESCUED? | RESCUE ACTION | USER SEES
------------------------------|----------|------------------------------------------------------|----------------------------------------
lookupError (any) | Y | 200 + reason; store status='error' | red bar + "Search by PO" CTA
>50 pages truncation | N ← GAP | add `truncated:true` to response; bar says "checked last 60 d / 50 pages" | honest caveat instead of silent no-match
0-line conf NaN overlap | ? ← verify | guard: total=0 β†’ overlap {0,0}, tier 'weak' | weak candidate, caution line
SSR hydrate timeout | Y | idle + warn log | tab starts fresh; history loads client-side on open
400 / 422 on decision | Y | inline error under Confirm; Confirm stays enabled | "Couldn't save β€” 1 line still needs an ERP item"
erpItemId not in catalog | N ← GAP | server: Typesense getProductsByErpIds check β†’ 422 | "Bound item not found in {ERP} catalog"
409 NOT_APPROVED | Y | disable Apply until approved | Apply hidden until "Confirm changes"
verification_failed | Y (new) | Re-check β†’ re-lookup β†’ new decision row; old row = history | banner with Re-check button
patch_failed | Y | Retry (same row; claim re-taken after 5 min) | red inline + Retry
triage PO-on-file lookup fail | Y | skip (today) + structured log with connection/PO | nothing (as today) β€” but LOGGED
0-line revision work item | N ← GAP | intake: require β‰₯1 extracted line or route to skip w/ reason | avoids an empty review page

Five GAPs, each with a specified rescue; all become tasks (T-list).

Threat L I Mitigated?
S3.1 Client-supplied erpItemId in line_resolutions binds a foreign or nonexistent sku into a live PATCH Med High GAP β†’ fix: decision route resolves each erpItemId against the connection’s catalog (TypesenseSearchService.getProductsByErpIds) and rejects unknowns 422; apply re-derives from the persisted row only.
S3.2 line_resolutions keys not in the snapshot (forged keys) Med Med zod + set-membership against diff_snapshot β†’ 422.
S3.3 IDOR on the three routes Low High Existing org-scoped extractedOrderId load; unchanged. Add a test for cross-org id β†’ 404.
S3.4 Support admin sees Apply for another org’s document, gets 403 Med Low Same asymmetry as approvalOnlyMode; tab copy: β€œApply is available to members of this organization.” Hide Apply when viewer org β‰  document org (SSR knows both).
S3.5 A-in widens the LLM-processed population to order_change emails; prompt-injection surface = same as any processed email (extraction is structured-output; no tool use). Low Med No new mitigation beyond existing hardening; eval cases include an adversarial body.
S3.6 Vendor documents (B1) enter a CSR org; a vendor could spoof a β€œPO confirmation” to alter a PO Low High Apply is human-approved and flag-gated; trusted-sender logic unchanged. Record vendor sender on the row (emailEvent.fromEmail already on the document).
S3.7 Audit trail β€” β€” order_revisions carries clerk_user_id; add nothing. Each lifecycle step is a row (decision, approve, apply) β†’ history is the audit.
S3.8 New secrets / deps β€” β€” None.
LOOKUP INPUT(extractedOrderId) ─▢ VALIDATE(org-owned, flag, adapter) ─▢ TRANSFORM(confToRevisionOrder, crawl, score, diff+diffHeader) ─▢ PERSIST(none) ─▢ OUTPUT(candidates)
nil β†’ 400 no adapter β†’ enabled:false 0 lines β†’ weak tier (S2 verify) stale β†’ version guard discards
empty po β†’ enabled:false flag off β†’ enabled:false WF error β†’ lookupError partial (>50 pages) β†’ truncated:true
DECISION INPUT(decision, resolutions, header_diff) ─▢ VALIDATE(zod, keysβŠ‚snapshot, erpItemId∈catalog) ─▢ PERSIST(order_revisions row, status recorded|approved) ─▢ OUTPUT(id)
nil selection + 'revise' β†’ 400 unknown key β†’ 422 / unknown sku β†’ 422 dup rows allowed (history) β€”
APPLY INPUT(extractedOrderId) ─▢ VALIDATE(flag, newest row approved, claim) ─▢ TRANSFORM(adapter.mapChangeSet from row) ─▢ PERSIST(before_image, PATCH, after_image, status) ─▢ OUTPUT
β€” not approved β†’ 409 / lost claim β†’ 409 blocked β†’ 422 drift β†’ verification_failed β€”
INTAKE INPUT(email) ─▢ TRIAGE(order_change?) ─▢ GATE(attachment ∧ PO on file ∧ flag) ─▢ PROCESS(documentRole='revision') ─▢ WORK ITEM ─▢ review?tab=revision
no attachment β†’ skip (today) PO lookup fails β†’ skip + log 0 lines β†’ skip w/ reason (S2 GAP) β€”
Interaction Edge case Handled? How
Apply double-click Y conditional patch_started claim (existing)
Apply navigate away mid-apply Y server completes; SSR rehydrates on return
Apply second tab applies Y 409 from claim; UI shows β€œapplied elsewhere β€” refresh”
Confirm unbound added line Y Confirm disabled + explanatory count
Target list 0 candidates Y empty state + search + Start clean
Target list 30 candidates GAP β†’ fix show top 10 by score, β€œShow N more”; never hide a strong
Change set 200 lines Y unchanged collapsed; changed/added/removed always expanded; virtualization not needed (<500 rows)
Lookup switch order mid-lookup (workbook) Y revisionLookupVersion guard (existing)
Lookup PO field edited after lookup GAP β†’ fix PoHistoryStrip’s 500 ms debounce re-fire moves into the signal bar; lookup re-fires on PO change
Signal bar PO-history says β€œin ERP” but lookup errored Y (new) one bar, one sentence: β€œPO on file in WhereFour Β· couldn’t load candidates β€” Search by PO”
Refresh after decision / after apply Y (new) SSR hydrate
Back button after apply Y state is server-side; no client-only truth
Intake job triage runs twice (Temporal retry) Y existing idempotency on email_events
Intake job PO-on-file lookup slow Y 1-min activity timeout (existing)
# Finding Decision
S5.1 DRY instanceof WherefourService in lookup-by-po.ts, revision-apply.ts, search-by-po.ts, po-history.ts resolveTarget() in A1 replaces all four; po-history.ts keeps its E4 Temporal branch as an adapter-less special case until E9.
S5.2 DRY money() formatter in RevisionPanel.tsx:38 duplicates a currency formatter elsewhere in review-v2 Use the shared one (lib/order-utils or the design-system @repo/ui numeral helper); verify at A3.
S5.3 Naming RevisionCandidate.order vs engine RevisionOrder β€” same concept, two names Keep RevisionCandidate (client) and ScoredCandidate (engine) but drop .order in favor of .candidate when the tab is built? No β€” churn with no user value (P3). Leave; document.
S5.4 Over-engineering check RevisionTargetAdapter with two implementations Justified: the second (P21 PO) is in scope by user decision D1; interface with two impls is not speculative.
S5.5 Under-engineering confirm() dialogs Replace with the existing review-v2 modal primitive (ERPSearchModal’s dialog shell) β€” a11y and undo copy.
S5.6 Complexity revision.ts store would gain 6 atoms + 3 actions Split: revision.ts (lookup/candidates) and revision-decision.ts (decision/apply/history). Both registered in reset-on-swap.
S5.7 Smell customerName carries vendor name on buy-side Accepted ponytail smell; TODO for a counterparty alias.
S5.8 Stale docblocks revision.ts and RevisionPanel.tsx describe a β€œRevision rail tab” that does not exist Fix in A3 when the tab actually exists (Stale Diagram Audit).
NEW UX FLOWS NEW DATA FLOWS NEW CODEPATHS
- signal bar 4 states (+merged po-history) - SSR newest-row hydrate - resolveTarget + 2 adapters
- tab auto-land (strong / vendor ack / ?tab=) - decision with line_resolutions+header - diffHeader
- target pick, search-by-po escape hatch - apply from approved row (+before_image) - apply: erpItemId path, blocked path
- accept/skip/bind per line, header apply/skip - intake: order_changeβ†’processβ†’revision - triage gate (attachment ∧ PO on file ∧ flag)
- confirm β†’ approved β†’ apply β†’ applied - P21 PO projection β†’ candidates (B2) - revision-guard (unchanged semantics)
- verification_failed β†’ re-check NEW ASYNC: Dagster p21_purchase_orders (B2) NEW INTEGRATIONS: P21 PO write (B2)
- history list; copy summary; mark duplicate
PATH TEST TYPE EXISTS? HAPPY / FAILURE / EDGE
decisionβ†’apply round trip (real body) integration NO β˜…REGRESSION revise saved with target β†’ apply reaches adapter / body lacks target β†’ 400 / start_clean β†’ 409
SSR hydrate unit(astro) NO row β†’ props / no row β†’ idle / PG timeout β†’ idle+log
diffHeader unit NO changed / equal / nil one side / both nil / whitespace-only
line_resolutions zod + snapshot membership unit NO valid / unknown key / bad action / erpItemId βˆ‰ catalog
apply erpItemId path unit partial added+erpItemId β†’ item / added w/o β†’ blocked / unmatched β†’ blocked
resolveTarget unit NO WF β†’ adapter / P21 β†’ adapter / E4 β†’ null / capability false β†’ null
state machine unit(store) NO every arrow in §4.4 incl. impossible transitions (NOT_APPROVED, applied→apply idempotent)
reset-on-swap registers new atoms unit exists pattern add atoms to fixture
signal bar merge RTL NO lookup done+po-history hit / lookup error+history hit / both empty β†’ null
tab auto-land unit(store) partial strong β†’ revision / weak β†’ header / ?tab=items wins / vendor ack β†’ revision
Confirm gating RTL NO unbound added β†’ disabled + count / all bound β†’ enabled
keyboard a/s/j/k RTL NO focus moves; aria-pressed toggles
30 candidates RTL NO top 10 + show more; strong never hidden
revision-guard w/ approved state unit exists approved-but-unapplied still blocks submit
triage gate py unit+eval NO β‰₯10 new cases; F1 on 55 held; adversarial body case
triage PO-on-file lookup failure py unit NO β†’ skip + log, never process
0-line revision doc py unit NO β†’ skip with reason
P21 projection asset (B2) py unit NO merge disposition, connection_id partition, never replace
P21 adapter apply (B2) unit NO unsupported β†’ 'unsupported'; guard without version token
E2E (staging, CK, flags on for internal org) manual β€” conf PO β†’ strong candidate β†’ bind added line β†’ approve β†’ apply β†’ WF order changed β†’ history shows 3 rows

2 am Friday test: the round-trip regression (it is the bug that exists today). Hostile QA: forge line_resolutions with a key from another order’s snapshot and an erpItemId from another connection. Chaos: kill WF mid-PATCH β†’ patch_failed, retry after 5 min reclaims. Pyramid: ~22 unit, 3 RTL, 1 integration, 1 manual E2E β€” right-side-up. Flakiness: none time-dependent except the 5-minute reclaim (inject clock). Evals: email-triage suite (A-in); po-extraction suite only if B1’s vendor-ack extraction prompt lands.

Path Concern Decision
SSR newest-row one query/document, indexed on extracted_order_id (exists) fine; add composite (extracted_order_id, created_at DESC) only if EXPLAIN shows a sort.
WF crawl up to 50 pages Γ— ~300 ms = 15 s worst case = client timeout already bounded; truncated:true (S2) makes the bound honest.
Change-set render ≀500 rows, no virtualization fine.
P21 projection read (B2) PG table, per-connection index on (connection_id, po_no) add index in schema.
Typesense catalog check on decision (S3.1) one batched getProductsByErpIds per decision negligible.
Intake one extra β€œPO on file” lookup per order_change email (already exists for _unverified_po_override) reuse that code path; no new query.
  • PostHog: revision_tab_opened {autoLanded, tier}, revision_decision_recorded {decision, tier, candidateCount, resolutions:{accept,skip,bind}}, revision_approved, revision_applied {targetKind, changes, dollarsChanged, durationMs}, revision_apply_failed {status}, revision_intake_processed {connectionId} (temporal-worker β†’ existing PostHog Python client).
  • Structured logs: the three routes already log; add targetKind, adapter, revisionRowId.
  • Metrics that say it works: decisions/day, applied/day, apply success ratio. Broken: patch_failed > 0 on a flag-on connection; verification_failed ratio > 10 %; intake processed=0 for a connection with revision_intake_v1 on for 3 days (silent gate).
  • Dashboard day 1: the four counts above + median lookupβ†’apply time.
  • Runbook verification_failed: open the order in WF, compare before_image to live, re-check in the tab, re-record. Runbook patch_failed: read after_image/error, check WF status allowlist (invoiced?), retry or start clean.
  • Debuggability: every step is an order_revisions row with snapshots; 3-week-old bug is reconstructible from the table alone.
  • Migration: three nullable/defaulted columns + one new enum + one enum member (documentRole) β€” additive, no rewrite, no lock beyond metadata. Generated by Drizzle; no pg_constraint guard needed (no constraint). Deploy order: migrate β†’ webapp β†’ temporal-worker (A-in) β€” old code ignores new columns.
  • Flags registered in flags.json in the same PR as the code (shipped-but-unregistered = inert). Segment overrides: org_internal_test first, then CK’s WF connection.
  • Ladder: A0 β†’ internal org (exit: override lifted on staging 4f234677, one controlled apply like #3028) β†’ CK canary 72 h β†’ GA of review_revision_tab_v1 β†’ delete strips. A-in: shadow first (log the would-process decision for 1 week, compare to skip count) β†’ on for CK.
  • Deploy-time window: old client + new server: server accepts bodies without line_resolutions (all-accept default). New client + old server: line_resolutions ignored β€” the client must not assume it was honored β†’ read it back from the response. Task.
  • Post-deploy 5 min: open one CK review page, signal bar renders, no console errors, revision_tab_opened fires. First hour: zero patch_failed.
  • Rollback: flag off (instant). Migration stays (additive).
  • Debt introduced: customerName for vendor; documentRole gains a second axis of meaning (revision, vendor_acknowledgment) β€” acceptable for two values; a third should become its own column. po-history.ts keeps an E4 special case until E9.
  • Path dependency: the adapter seam is the opposite of lock-in; the tab is promotable to a dedicated page without touching engine/table/routes.
  • Reversibility: 4/5 (flags + additive migration; the only one-way part is ERP writes that already happened, which before_image now records).
  • 1-year question: a new engineer reads targets/index.ts and sees every ERP that can be revised in one place. Yes.
  • Phase 2/3: E9 (E4 adapter), E3 (revert), auto-apply policy on approved rows, order_events for document-less changes. The architecture supports all four without schema churn.

Information architecture is specified (Target β†’ Changes β†’ Decision β†’ History) and the state table exists; the emotional arc has one flaw: β€œaccept” on a vendor ack means concede to the vendor β€” copy must say β€œConfirm vendor’s change” not β€œAccept”. AI-slop risk low (dense table UI, no cards-as-decoration). DESIGN.md alignment: tokens named; monospace numerals required on qty/price columns (add). Responsive intention stated. A11y stated. Recommendation: Phase 2 (design review) runs next in this pipeline; user flow diagram = Β§4.4 state machine.


See Β§10 plus: auto-persisted detected rows (rejected D3); E2 queue pill; E3 revert; E9 E4 adapter; E10 order_events; counterparty alias column; buy-side for E4/Acumatica/NetSuite; Approach B page (promotion path only); Approach E surface (fallback only).

Β§2.1 (15 sub-problems mapped) β€” plan reuses all of them; nothing rebuilt.

Step 0C. After this plan: sell-side complete on WF; seam for every ERP; buy-side P21 gated on B0; auto-apply and revert remain seams with the data (approved state, before_image) already in place.

CODEPATH | FAILURE MODE | RESCUED? | TEST? | USER SEES? | LOGGED?
--------------------------------|-----------------------------------|----------|-------|--------------------------------|--------
lookup crawl | >50 pages truncation | N→Y(fix) | Y | "checked last 60 d" caveat | Y
lookup score | 0-line conf NaN | ?β†’Y(fix) | Y | weak tier | Y
SSR hydrate | PG timeout | Y | Y | fresh tab | Y
decision | erpItemId βˆ‰ catalog | Nβ†’Y(fix) | Y | 422 copy | Y
decision | forged key | Y | Y | 422 copy | Y
apply | drift β†’ verification_failed | Y | Y | banner + Re-check | Y
apply | WF 5xx | Y | Y | red + Retry | Y
apply | two tabs | Y | Y | 409 copy | Y
intake | PO-on-file lookup fails | Y(skip) | Y | nothing (as today) | Y (new)
intake | 0-line revision doc | N→Y(fix) | Y | never reaches queue | Y
intake | flag on, zero processed 3 d | alert | β€” | β€” | Y
P21 projection (B2) | Dagster lag | Y | Y | "as of" stamp | Y
P21 apply (B2) | no version token | Y(refuse)| Y | "apply unavailable" copy | Y
ignored strong match (D3) | clerk submits anyway | partial (#2959 guard) | exists | duplicate guard 422 or nothing | partial ← ACCEPTED RISK (user D3)

No row is RESCUED=N ∧ TEST=N ∧ Silent after fixes; the D3 row is a knowingly accepted residual, not a gap.

System architecture (S1), data flow with shadow paths (S4), state machine (Β§4.4), error flow (S2 table), deployment sequence (S9), rollback (S9 + Β§4.6), intake flow (Β§4.5 + S1).

stores/pdfOrder/revision.ts header comment and RevisionPanel.tsx header reference a β€œRevision rail tab” that never existed β†’ correct in A3. debt/backlog/order-lifecycle-expansion-plan.md Β§UX is superseded by this doc β†’ add a pointer line there (docs-only).

  1. β€œChanges (N)” inbox queue pill + change cards (E2) β€” P3, M/S. 2. Revert from before_image (E3) β€” P2, S/S; needs own flag + spike. 3. Eclipse E4 sales-order revision adapter (E9) β€” P2, L/M; depends on a non-Temporal PO lookup. 4. Document-less change emails β†’ order_events (E10) β€” P2, XL/L; pointer to lifecycle plan. 5. Counterparty alias column replacing customerName on buy-side rows β€” P3, S/S. 6. Lift the live WF apply-flag identity overrides for org_internal_test (A0 exit criterion) β€” P1, XS.

Historical implementation tasks (CEO phase; superseded by authoritative Β§5)

Section titled β€œHistorical implementation tasks (CEO phase; superseded by authoritative Β§5)”

The checklist below records what the 2026-09-03 review proposed. Do not execute it directly: Β§4–§6 reconcile its later design/engineering corrections, remove the PostgreSQL PO projection, and define the current PR sequence.

  • T1 (P1, human: ~4h / CC: ~30min) β€” revision store + decision route β€” send and persist revisesErpOrderNumber; regression test through the real request body. Surfaced by: Β§2.3.1. Files: stores/pdfOrder/revision.ts, api/erp/orders/revision-decision.ts, revision-apply.test.ts. Verify: apply reaches adapter in test.
  • T2 (P1, ~4h / 30min) β€” review.astro β€” SSR newest-row hydrate (one DISTINCT ON query, raceBackfill). Surfaced by: Β§2.3.2, S1.1.
  • T3 (P1, ~2h / 15min) β€” flagsmith config β€” lift identity overrides on staging 4f234677 for internal org; A0 exit criterion. Surfaced by: F7.
  • T4 (P1, ~1d / 2h) β€” lib/revision/targets β€” RevisionTargetAdapter, resolveTarget(connection), WF adapter; replace 4 instanceof sites. Surfaced by: S5.1, S1.4.
  • T5 (P1, ~4h / 1h) β€” engine β€” diffHeader, RevisionLine.erpItemId, apply erpItemId path, 0-line guard. Surfaced by: Β§4.3, S2.
  • T6 (P1, ~4h / 1h) β€” schema β€” target_kind, header_diff, line_resolutions, documentRole members; generated migration. Surfaced by: Β§4.1.
  • T7 (P1, ~2h / 30min) β€” decision route β€” zod for line_resolutions, snapshot-membership, erpItemId ∈ catalog via Typesense; approved state. Surfaced by: S3.1, S3.2.
  • T8 (P1, ~2d / 3h) β€” RevisionWorkbench (4 children) + RevisionSignalBar + ReviewTab 'revision' + auto-land rule; strips kept behind flag. Surfaced by: Β§4.4.
  • T9 (P2, ~2h / 30min) β€” lookup β€” truncated:true, top-10 + show more, PO-change re-fire. Surfaced by: S2, S4.
  • T10 (P1, ~1d / 2h) β€” temporal-worker β€” intake gate + revision_intake_v1 + eval cases + shadow log. Surfaced by: D2.
  • T11 (P2, ~4h / 1h) β€” S0 Sprint-0-lite SQL + table in this doc. Surfaced by: F6.
  • T12 (P2, ~1d / 2h) β€” B2 β€” p21_purchase_orders PG projection (Dagster, merge disposition) + adapter candidates. Surfaced by: S1.2.
  • T13 (P2, ~2d / 3h) β€” B0 spike doc; adapter apply returns unsupported until it passes. Surfaced by: Β§4.5.
  • T14 (P2, ~2h / 30min) β€” observability events + alert rules. Surfaced by: S8.
  • T15 (P3, ~1h / 15min) β€” stale docblocks + lifecycle-plan pointer. Surfaced by: Stale Diagram Audit.
+====================================================================+
| MEGA PLAN REVIEW β€” COMPLETION SUMMARY |
+====================================================================+
| Mode selected | SELECTIVE EXPANSION (autoplan) |
| System Audit | branch = staging tip; 3 dead-wiring bugs; F1 intake drop confirmed |
| Step 0 | Approach A; premises P1,P2,P4,P5 accepted, P3 user-held (D1) |
| Section 1 (Arch) | 7 issues found (S1.2 bronze-not-in-PG is the real one) |
| Section 2 (Errors) | 22 error paths mapped, 5 GAPS (all fixed β†’ tasks) |
| Section 3 (Security)| 8 evaluated, 1 High-impact gap (S3.1) fixed |
| Section 4 (Data/UX) | 14 edge cases mapped, 2 unhandled β†’ fixed |
| Section 5 (Quality) | 8 issues found |
| Section 6 (Tests) | Diagram produced, 20 test specs, 1 REGRESSION|
| Section 7 (Perf) | 6 evaluated, 0 blocking |
| Section 8 (Observ) | 3 gaps β†’ events, alerts, 2 runbooks |
| Section 9 (Deploy) | 2 risks flagged (deploy-window body compat, flag registration) |
| Section 10 (Future) | Reversibility: 4/5, debt items: 3 |
| Section 11 (Design) | 2 issues (vendor copy, mono numerals) β†’ Phase 2 |
+--------------------------------------------------------------------+
| NOT in scope | written (10 items) |
| What already exists | written (Β§2.1, 15 items) |
| Dream state delta | written |
| Error/rescue registry| 22 paths, 0 CRITICAL GAPS after fixes |
| Failure modes | 14 total, 0 CRITICAL GAPS, 1 accepted risk (D3) |
| TODOS.md updates | 6 items proposed |
| Scope proposals | 10 proposed, 3 accepted, 3 taste, 4 deferred, 1 rejected |
| CEO plan | this document (Β§0–§10 + review) |
| Outside voice | Claude subagent only (Codex backend 404) |
| Lake Score | 11/12 recommendations chose complete option (D3 user-chosen shortcut excluded) |
| Diagrams produced | 7 (arch, data flow, state, error, deploy, rollback, intake) |
| Stale diagrams found | 2 |
| Unresolved decisions | 0 (3 taste β†’ final gate) |
+====================================================================+

Phase 1 complete. Codex: unavailable (backend 404). Claude subagent: 9 findings (2 critical, both verified). Consensus: single voice β€” 5/6 dimensions FLAGGED, 0 confirmed; premise gate resolved by user (D1 keep buy-side, D2 add intake, D3 reject auto-detect, D4 negative search). Passing to Phase 2.

GSTACK REVIEW β€” Phase 2: Design Review (via /autoplan)

Section titled β€œGSTACK REVIEW β€” Phase 2: Design Review (via /autoplan)”

0A initial rating: 5/10. The plan specifies layout, states and a state machine, but the post-apply footer, mobile table, buy-side verbs, default resolution, and tab reachability with zero candidates were unspecified or contradictory. A 10 for THIS plan: every Β§4.4 cell names what the CSR sees, the footer’s behavior after apply is a state, the table has a sub-480px form, and the buy-side reads as β€œrecord what the vendor said,” not β€œaccept.” 0B DESIGN.md: exists (apps/webapp/DESIGN.md, β€œCockpit frame, bright workspace”). All decisions calibrated to it: app-UI rules, tokens, Poppins/Inter, mono numerals, one accent CTA per surface, cards only when the card IS the interaction. 0C existing design leverage: ReviewCenterPane tab body + @container queries; ConfirmDialog from @repo/ui/overlay (already imported in ReviewCenterPane); ERPSearchModal dialog shell; CardSectionHeader-style eyebrows; ReviewMobileTabs panes; doc-viewer getPageRect highlight; DeltaRow/CandidateCard (restyled, not lifted verbatim β€” see Pass 5). 0D focus: all 7 passes (autoplan).

CODEX SAYS: [codex-unavailable]. CLAUDE SUBAGENT (design β€” independent): 7 dimensions rated 6/5/5/6/4/3/5; one CRITICAL (post-apply footer), nine HIGH. Litmus (app UI): brand NO Β· anchor NO Β· scan-headings YES Β· one-job NO Β· cards-necessary NO Β· motion NO Β· premium-without-shadows YES. Consensus scorecard = single voice; every critical/high finding carried into the passes below.

  • Fix: the change summary (3 changed Β· 1 added Β· 0 removed Β· net +$1,240.00, mono numerals) becomes the first row inside Changes, not a header suffix β€” it is the visual anchor of the tab.
  • Fix: Decision shows only current state + next action; timestamps and actors live in History only.
  • Fix: each change row highlights its source line in the doc viewer on focus/hover via the existing getPageRect path; unmatched rows show β€œCouldn’t locate on document” (same copy as Items).
  • Order stays Target β†’ Changes β†’ Decision β†’ History.

Pass 2 β€” Interaction states: 5 β†’ 9 (table corrections)

Section titled β€œPass 2 β€” Interaction states: 5 β†’ 9 (table corrections)”
Cell Was Now
Signal bar Β· vendor ack, 0 candidates nothing β€œVendor acknowledgment for PO {n} β€” no matching purchase order found Β· [Search]”
Signal bar Β· tiered copy β€œ(strong match)” hardcoded strong: β€œalready exists as #X”; partial: β€œmay be a revision of #X β€” review”; weak: β€œPO {n} also appears on #X” (no auto-land)
Entry contradiction β‰₯1 candidate auto-lands strong tier or vendor ack only; partial/weak = bar only (resolves Β§8.10)
Tab reachability hidden at 0 candidates tab is always in the tablist when review_revision_tab_v1 is on; 0 candidates shows Target-empty with search focused; error CTA and deep link always land
Change set Β· empty β€œMark as duplicate” undefined E6 = invoke the existing set-aside/reject exit (ReviewSubmitFlow reject step, prefilled β€œDuplicate of {ERP} #X (PO Y)”); it is a work-item exit, not a fourth decision enum
Change set Β· error β€” per-row inline error + retry for Bind failures; candidate lines failing to load β†’ row β€œcouldn’t load lines β€” Re-check”
Decision Β· empty β€” disabled β€œSelect an order to revise” (keep existing copy)
Decision Β· partial (apply flag off) unspecified for WF β€œChanges recorded. Apply to {ERP} isn’t enabled for this connection yet.” β€” same copy for WF and P21, parameterized
Decision Β· success β†’ footer unspecified ReadinessFooter reads β€œApplied as revision Β· #{X}” and Submit is disabled. Server: revision-guard blocks submit for ANY revise row regardless of patch_status (today it releases on patch_succeeded β€” revision-guard.ts:24,44) and apply success completes the work item through the existing completion path with the revised ERP order number. CRITICAL fix.
History Β· error β€” β€œHistory unavailable β€” refresh”
Multi-order workbook unspecified bar and tab key off editingOrder; atoms in reset-on-swap; server rejects a second revise against a target that already has an approved/applied row from another extracted order (409 TARGET_ALREADY_REVISED)
Re-check resolutions lost? resolutions carried over by line key where the key still exists; dropped keys listed: β€œ2 earlier choices no longer apply”
  • Confirmation stacking: no window.confirm. β€œConfirm changes” IS the confirmation (one button, shows the count: β€œApply 4 of 5 changes to #5830039”). Start clean uses ConfirmDialog.
  • Start clean is reversible until the order is submitted: β€œReopen revision” writes a new decision row; history shows both.
  • Buy-side verbs: row actions read β€œRecord confirmed” / β€œKeep as ordered”, plus β€œFlag for dispute” (resolution dispute: recorded, never applied, surfaces in History and in the Copy summary). Primary CTA β€œUpdate purchase order in P21”.
  • Phone: β€œOpen revision” sets $reviewMobilePane = 'center' and the tab.
  • Emotional arc: notice (amber, never red) β†’ orient (Target list, one strong candidate pre-focused but not selected) β†’ verify (diff with doc highlight) β†’ commit (one Confirm, one Apply) β†’ relief (green β€œApplied”, footer changes, work item leaves the queue). Where it used to break (refresh, five dialogs, submit-after-apply) is now specified.

Specifics resolved: default per-line resolution is accept (skip is the exception; Confirm shows the exact count β€” TASTE, see gate); β€œRevise #X β†’ Confirm changes” is one button; [View before/after] opens a right-side drawer (the ReviewErrorDrawer shell) with two mono JSON panes; UNCHANGED (7) β–Έ expands inline to the same row format, greyed; skeletons: 3 target rows (44px) + 5 change rows (36px), aria-busy; focus after Confirm β†’ Apply button, after Apply β†’ the status line, after Bind close β†’ the Bind button; 'revision' joins REVIEW_TAB_ORDER (review-v2.ts:401) as the last tab; Items-tab Enter/X/⌫ shortcuts are suppressed on this tab (Enter would collide with Confirm); a/s/j/k from E7 apply here only.

  • No nested cards. Sections are eyebrows (11px bold uppercase --om-surface-ink-3) + --om-border-surface dividers; only candidate rows are bordered selectable rows (the card IS the interaction).
  • One primary CTA per surface: while a revision is pending (selected…approved) the footer’s Submit demotes to secondary and reads β€œSubmit as new order instead” only after start_clean; after applied it is replaced (Pass 2). Apply/Confirm carry the orange.
  • One verb pair for rows (sell: Accept / Skip; buy: Record confirmed / Keep as ordered); β€œApply” reserved for the ERP write.
  • Deltas are neutral ink with sign text (40 β†’ 50 (+10)); red/green only for row status chips. DeltaRow/CandidateCard are restyled to tokens (drop hardcoded white, rgba(196,72,1,.3)).
  • Mono tabular numerals (--om-font-mono) on qty, price, totals, order #.
  • Breakpoint is the container, not the viewport: the center pane is minmax(320px,1fr) on a 1024px laptop (review-layout.ts:17). Use the @container the pane already opens. Below 480px container width each change is a stacked row: line 1 name (+ doc-highlight on focus), line 2 qty 40 β†’ 50 (+10) Β· $4.10 β†’ $3.75 (βˆ’$0.35) mono, line 3 one toggle β€œSkip this change” (default accept β†’ zero taps for a clean revision). Target rows keep 44px hit areas.
  • a11y: per-line toggle is a single switch (role="switch", aria-checked), not two aria-pressed buttons; controls carry item-scoped labels (β€œSkip change to Kimchi 16oz”); aria-live="polite" region for decision/apply transitions, role="alert" on failure; skeletons aria-busy; ERPSearchModal returns focus to the Bind button; signs remain text, never color-only; the table keeps <caption> + scope="col".
Decision Resolved
Default per-line resolution accept-all, Confirm shows count β€” confirmed at final gate (D7)
Tab visible with 0 candidates yes when flag on
Footer after applied β€œApplied as revision Β· #X”, submit disabled, guard blocks on any revise
β€œMark as duplicate” = existing set-aside exit with prefilled reason
Start clean reversibility reversible until submit
Auto-land tier strong or vendor ack only
Confirm vs Apply two steps, one button each; flag-off copy on Apply
Buy-side verbs + dispute Record confirmed / Keep as ordered / Flag for dispute
Breakpoint container query at 480px
Diff summary placement first row of Changes (anchor)
A-vs-B page A confirmed at final gate (D6)
+====================================================================+
| DESIGN PLAN REVIEW β€” COMPLETION SUMMARY |
+====================================================================+
| System Audit | DESIGN.md present; UI scope yes |
| Step 0 | 5/10 initial; all 7 passes |
| Pass 1 (Info Arch) | 6/10 β†’ 9/10 |
| Pass 2 (States) | 5/10 β†’ 9/10 (1 CRITICAL fixed: post-apply footer/guard) |
| Pass 3 (Journey) | 5/10 β†’ 8/10 |
| Pass 4 (AI Slop) | 6/10 β†’ 8/10 |
| Pass 5 (Design Sys) | 4/10 β†’ 9/10 |
| Pass 6 (Responsive) | 3/10 β†’ 8/10 |
| Pass 7 (Decisions) | 9 resolved, 2 taste β†’ gate |
+--------------------------------------------------------------------+
| NOT in scope | dedicated page (B); vendor dispute workflow beyond a flag |
| What already exists | ConfirmDialog, ERPSearchModal shell, @container, getPageRect, ReviewMobileTabs |
| TODOS.md updates | 0 new (dispute workflow folded into B1) |
| Approved Mockups | none generated (designer binary not run in autoplan) |
| Decisions made | 9 added to plan |
| Decisions deferred | 2 (taste) |
| Overall design score | 5/10 β†’ 8.5/10 |
+====================================================================+

Additional tasks: T16 (P1) revision-guard blocks any revise row + footer β€œApplied as revision” + work-item completion on apply; T17 (P2) container-query stacked rows + switch a11y; T18 (P2) doc-viewer highlight from change rows; T19 (P2) buy-side verbs + dispute resolution; T20 (P2) Reopen revision after start clean; T21 (P3) before/after drawer.

Phase 2 complete. Codex: unavailable. Claude subagent: 1 critical + 9 high findings, all applied. Consensus: single voice; scores 5β†’8.5. Passing to Phase 3.

GSTACK REVIEW β€” Phase 3: Eng Review (via /autoplan)

Section titled β€œGSTACK REVIEW β€” Phase 3: Eng Review (via /autoplan)”

Superseded 2026-10-08. Sell-side revision intake no longer requires sender corroboration. The unknown_sender gate was removed and the S3 risk accepted, see TODOS.md β€œRe-add a sender check to sell-side revision intake”. Every reference below to sender corroboration for sell-side intake (the S3 row, Decision 33, T27, the eval mix, the failure-mode table and the intake theme line) describes the earlier design, and S3 is accepted, not fixed. Buy-side vendor-acknowledgment intake still requires sender-to-vendor corroboration.

  1. Existing code: Β§2.1 (15 sub-problems). Eng review confirms reuse claims except two: applyInitialReviewTab has no ?tab= handling (A1) and WherefourService.updateOrder writes only items β€” no ship-to/date (A6). Both corrected in the body.
  2. Minimum change set: A0 (4 files). Everything else is the deliverable the user asked for; deferrals are already in Β§10.
  3. Complexity check: ~24 files, 2 new modules β†’ smell triggered; challenged in CEO 0D, committed by the user at the premise gate. Not re-argued.
  4. Search check: WebSearch not used in this run β€” in-distribution knowledge only. Layer notes: optimistic concurrency via expectedUpdatedAt [Layer 1]; adapter-by-connection-type registry [Layer 1]; container queries for the pane [Layer 1] (already in ReviewCenterPane). No EUREKA.
  5. TODOS cross-reference: TODOS.md β€œtrusted-sender authentication gap (CWE-290)” and β€œinternal-sender roster” bear directly on A-in’s sender-corroboration requirement (S3 below); neither blocks, both are cited. No deferred item bundles cleanly into this PR.
  6. Completeness / distribution: no new artifact type; nothing to publish.

CODEX SAYS: [codex-unavailable]. CLAUDE SUBAGENT (eng β€” independent): 6 architecture, 7 edge-case, 4 security, 4 hidden-complexity findings; all file:line grounded; all verified by the orchestrator where checked (review-v2.ts:393, revision-guard.ts:42-46, wherefour-service.ts:1520, pdf-documents.ts:275, eval ground-truth has 0 order_change cases).

ENG DUAL VOICES β€” CONSENSUS TABLE:
═══════════════════════════════════════════════════════════════
Dimension Claude Codex Consensus
────────────────────────────────── ────────── ────── ─────────
1. Architecture sound? UNCERTAIN N/A FLAGGED (A2 connection binding, A3 guard)
2. Test coverage sufficient? UNSOUND N/A FLAGGED (no decision-route test; 0 eval cases)
3. Performance risks addressed? UNCERTAIN N/A FLAGGED (E7 crawl > client timeout)
4. Security threats covered? UNSOUND N/A FLAGGED (S1, S3 oracle, S4 vendor submit)
5. Error paths handled? UNSOUND N/A FLAGGED (A3/A4 dead-ends)
6. Deployment risk manageable? SOUND/MED N/A N/A
═══════════════════════════════════════════════════════════════
# Finding (verified) Decision Principle
A1 applyInitialReviewTab ignores ?tab=; runs before the async lookup, so β€œauto-land on strong match” would yank the tab after paint. Initial tab is decided at SSR from ?tab=, the newest order_revisions row, or documentRole ∈ {revision, vendor_acknowledgment} (set by A-in), and passed as a hydrator prop. A first-visit strong match with no persisted row shows the signal bar CTA only β€” never a post-paint tab switch. Supersedes Design Pass 2 β€œauto-land on strong tier”. P5
A2 getOrgScopedConnectionOrDefault falls back to the org’s newest active connection; revision-apply re-resolves per call and ignores row.connectionId. With two adapters, a decision recorded against WhereFour could apply through another connection. ERPCapabilities has no update/PO fields. Apply loads the connection by row.connectionId exactly (no default fallback), 409 CONNECTION_MISMATCH otherwise. Adapter registry keyed by connection type (targets/index.ts: { wherefour: wherefourSalesOrder, prophet21: p21PurchaseOrder }); resolveTarget(connection) is a lookup, not a capability probe. P5 explicit
A3 unresolvedRevisions filters all rows: a revise row followed by start_clean blocks submit forever; verification_failed β†’ re-check β†’ new row also dead-ends. Design Pass 2 separately found the guard releases on patch_succeeded. Guard evaluates the newest row only: newest revise (any patch_status) β†’ block, footer copy by status (β€œApplied as revision Β· #X” / β€œRevision pending”); newest start_clean β†’ allow. One rule fixes both defects. Regression test with a 3-row history. P1, P5
A4 verification_failed retry is dead because updateOrder fails closed on drift. Re-check writes a new row with fresh wherefour_updated_at; old row is history. Consistent with A3. P1
A5 Multi-order workbooks swap via setEditingOrder without remounting the hydrator. SSR passes revisionRowsByOrderId: Record<extractedOrderId, newestRow>; order.ts:201 swap reseeds from it before firing the lookup. P5
A6 updateOrder sends only items (+ echoes order_date); no ship-to/date write path exists. Header deltas are display-only (β€œshown, not applied”) for every connector in this plan. Writing ship-to / requested date is a TODO with its own flag. Β§4.3 corrected. P3 pragmatic
β€” Production failure per integration: WF PATCH lands but client times out at 65 s β†’ row stuck patch_started 5 min then reclaimed β†’ second PATCH β†’ drift β†’ verification_failed. revision-apply records after_image synchronously before responding; client treats timeout as β€œunknown β€” refreshing” and re-reads the row instead of showing failure. P1

| # | Finding | Decision | | β€” | β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” | β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” | β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€” | –––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––– | | Q1 | revisesErpOrderNumber and selectedErpOrderId are the same WhereFour id sent as two client fields; one is never sent (Β§2.3.1). | Server derives revisesErpOrderNumber from selectedErpOrderId (adapter tells it how); the client field is removed. Simplest fix that makes the bug class impossible. | | Q2 | Line key derivation duplicated client/server ('conf:' + partNumbers[0] + ':' + index; partNumbers may be []). | Server stamps lineKey into each DiffLine at lookup time; the snapshot carries it; client echoes keys and never derives them. | | Q3 | applyRevision lacks the staleness guard loadRevisionMatch has. | Capture orderId at call start; compare before every store set; discard if swapped. | | Q4 | updateOrder:1520 maps erpItemId to inventory_id with Number(x) | | x; createOrder has the sku/numeric heuristic. | Bind sets erpItemSku when the bound catalog row’s id is sku-shaped; updateOrder adopts createOrder’s heuristic. Unit test both shapes. | | Q5 | before_image needs the pre-PATCH GET that updateOrder performs and discards. | ErpOrderResult gains optional priorState; WF updateOrder returns current there; apply persists it. No second GET. | | Q6 | Support-admin cross-org viewer hits 404 on all three routes. | SSR knows viewer org β‰  document org β†’ tab renders read-only (candidates from SSR hydrate only, no write controls, β€œViewing as support β€” read only”). | | Q7 | Existing ASCII docblocks in revision.ts / RevisionPanel.tsx reference a nonexistent rail tab. | Fixed in A3 (T15). |

CODE PATHS USER FLOWS
[+] api/erp/orders/revision-decision.ts [+] Sell-side revision
β”œβ”€β”€ [GAP] β˜…REGRESSION real store body β†’ insert has target β”œβ”€β”€ [GAP] [β†’E2E] conf PO β†’ strong β†’ bind added β†’ confirm β†’ apply β†’ WF changed β†’ footer "Applied"
β”œβ”€β”€ [GAP] zod line_resolutions (action enum, key βŠ† snapshot) β”œβ”€β”€ [GAP] revise β†’ start clean β†’ submit allowed (A3)
β”œβ”€β”€ [GAP] erpItemId βˆ‰ connection catalog β†’ 422 β”œβ”€β”€ [GAP] verification_failed β†’ re-check β†’ new row β†’ apply
β”œβ”€β”€ [GAP] approved transition; reopen after start_clean β”œβ”€β”€ [GAP] refresh mid-flow keeps decision + resolutions
└── [GAP] cross-org id β†’ 404 β”œβ”€β”€ [GAP] two tabs apply β†’ 409 copy
[+] api/erp/orders/revision-apply.ts β”œβ”€β”€ [GAP] client 65 s timeout while PATCH lands β†’ "refreshing" not "failed"
β”œβ”€β”€ [GAP] loads connection by row.connectionId; mismatch β†’ 409 └── [GAP] support admin read-only view
β”œβ”€β”€ [GAP] NOT_APPROVED 409 [+] Buy-side (B1/B2)
β”œβ”€β”€ [β˜…β˜… TESTED] claim/reclaim (existing) β”œβ”€β”€ [GAP] [β†’EVAL] vendor-ack triage + extraction cases
β”œβ”€β”€ [GAP] before_image from priorState β”œβ”€β”€ [GAP] submit hard-rejects documentRole=vendor_acknowledgment (S4)
└── [GAP] added line with erpItemSku vs erpItemId (Q4) └── [GAP] header suggestions / auto-bind skipped for vendor docs
[+] lib/orders/revision-guard.ts [+] Intake (A-in)
└── [GAP] β˜…REGRESSION newest-row rule, 3-row histories β”œβ”€β”€ [GAP] [β†’EVAL] β‰₯10 order_changeβ†’process + β‰₯10 order_changeβ†’skip cases (dataset has 0 today)
[+] lib/revision/{engine,adapters,apply,targets} β”œβ”€β”€ [GAP] sender corroboration required (S3 oracle)
β”œβ”€β”€ [GAP] diffHeader nil/equal/changed β”œβ”€β”€ [GAP] PO-on-file lookup fails β†’ skip + log
β”œβ”€β”€ [GAP] lineKey stamped, stable across re-diff └── [GAP] 0-line β†’ skip with reason
β”œβ”€β”€ [GAP] 0-line overlap guard [+] SSR
β”œβ”€β”€ [β˜…β˜…β˜… TESTED] matchLines/diffLines/scoreCandidates (existing) β”œβ”€β”€ [GAP] initial tab from ?tab= / row / documentRole
└── [GAP] resolveTarget by type; unknown β†’ null └── [GAP] revisionRowsByOrderId reseed on order swap
[+] stores/pdfOrder/{revision,revision-decision}.ts
β”œβ”€β”€ [β˜…β˜… TESTED] lookup staleness (existing) β”œβ”€β”€ [GAP] apply staleness └── [GAP] reset-on-swap registers new atoms
[+] lookup-by-po.ts β”œβ”€β”€ [GAP] truncated:true at cap └── [GAP] server crawl budget ≀ 10 s (E7)
COVERAGE (planned surface): 3/38 paths tested today (8%) | GAPS: 35 (2 REGRESSION, 1 E2E, 2 EVAL)

REGRESSION RULE: two regressions flagged CRITICAL β€” (1) decisionβ†’apply round trip through the real body (the live bug), (2) guard newest-row semantics (existing revise-then-start-clean lock). Both are P1 tests written before the code they protect. Eval: packages/eval/test-data/…/email-triage/ground-truth.json has zero order_change cases; A-in cannot be evaluated until β‰₯20 are added (10 that must flip to process, 10 that must stay skip: no attachment, PO not on file, unknown sender). Baseline F1 91.5% on the existing 55 must hold. 2 am Friday tests: (a) 3-row history reviseβ†’start_cleanβ†’submit passes; (b) client timeout during a landing PATCH; (c) legacy customer id 404 β†’ 50-page crawl under budget returns truncated:true not error. Flakiness: the 5-minute reclaim and the 3 s SSR race need injected clocks; nothing else time-dependent.

# Finding Decision
E7 Customer-scoped 404 falls back to a 50-page account-wide crawl at up to 15 s/page under a 15 s client abort β†’ guaranteed error for legacy customer ids. Server-side total budget of 10 s on the crawl; stop early, return truncated:true + pages checked; client timeout raised to 20 s.
β€” SSR newest-row query, Typesense catalog check, P21 projection read as CEO S7; indexes noted.

NOT in scope: header-delta writes (A6 β†’ TODO); capability discovery for update/PO (registry by type instead); automatic reclaim tuning. What already exists: Β§2.1 with the two corrections above. Failure modes (new codepaths): all rows in the CEO registry plus: apply via wrong connection (fixed A2, tested, 409 visible, logged); stale revise row blocking submit (fixed A3, tested, footer copy, logged); intake oracle (fixed S3, tested, silent-by-design, logged); vendor doc submitted as sales order (fixed S4, tested, 422 visible, logged). 0 critical gaps after fixes. TODOS (auto-decided add): 7. Header-delta write path (ship-to / requested date) per connector behind its own flag β€” P2, M/S. 8. ERPCapabilities.orderUpdate / .purchaseOrderUpdate discovery so the registry can become capability-driven β€” P3, S/S.

Worktree parallelization

Step Modules Depends on
A0 fixes + Q1 derive target stores/pdfOrder, api/erp/orders, tests β€”
A1 adapter seam lib/revision/targets, api/erp/orders, api/extracted-orders A0
A2 engine + schema lib/revision, packages/db/schema β€”
A3 tab + signal bar + footer/guard components/orders/review-v2, stores/pdfOrder, lib/orders A1, A2
A-in intake + eval apps/temporal-worker, packages/eval, flags.json β€”
B2 projection + adapter apps/dagster, packages/db/schema, lib/revision/targets A1, B0
Lanes: Lane 1 A0 β†’ A1 β†’ A3 (shared api/erp/orders, stores). Lane 2 A2 (independent; packages/db schema also touched by B2 β†’ sequence B2 after A2). Lane 3 A-in (independent; Python). Lane 4 B0 spike β†’ B2 (after A1 + A2 merge). Launch 1+2+3 in parallel; B after. Conflict flag: A2 and B2 both add to packages/db/src/schema β†’ one migration each, sequential.
# Threat Decision
S1 line_resolutions.erpItemId is a client-supplied ERP write = CEO S3.1 (catalog check) + boundedJson 256 KB cap + action whitelist.
S3 A-in flip is sender-agnostic: anyone who guesses a real PO in a subject gets a review page that renders the live ERP order. Require sender corroboration before flipping: sender domain/address must resolve to the PO’s customer (reuse _sender_is_known_edi / alias lookup); otherwise stay skip with reason unverified_sender. Eval cases cover it.
S4 Vendor doc in a CSR org: customerName=vendor flows into header suggestions/auto-bind; could be submitted as a sales order. submit.ts hard-rejects documentRole='vendor_acknowledgment' (422); computeHeaderSuggestions, auto-bind, and ship-to resolution skip vendor docs. B1 tasks.
  • Step 0: scope accepted as committed at CEO gate (complexity smell acknowledged; A0 split out)
  • Architecture: 7 issues found (all auto-decided)
  • Code Quality: 7 issues found
  • Test Review: diagram produced, 35 gaps identified (2 CRITICAL regressions, 2 evals, 1 E2E)
  • Performance: 1 issue found (E7)
  • NOT in scope: written Β· What already exists: written (2 corrections)
  • TODOS.md updates: 2 items proposed (7, 8)
  • Failure modes: 0 critical gaps after fixes
  • Outside voice: Claude subagent only (Codex unavailable)
  • Parallelization: 4 lanes, 3 parallel / 1 sequential
  • Lake Score: 14/14 recommendations chose the complete option

Additional tasks: T22 (P1) apply loads connection by row.connectionId, 409 on mismatch; registry by type. T23 (P1) guard newest-row rule + 3-row regression test (merges T16). T24 (P1) server derives revisesErpOrderNumber; remove client field; decision-route test + round-trip regression. T25 (P1) server-stamped lineKey. T26 (P1) SSR initial tab + revisionRowsByOrderId; swap reseed. T27 (P1) sender corroboration in intake + β‰₯20 eval cases. T28 (P1) submit hard-rejects vendor docs; skip header suggestions/auto-bind for them. T29 (P2) erpItemSku on bind + updateOrder heuristic. T30 (P2) priorState on ErpOrderResult β†’ before_image. T31 (P2) apply staleness guard + timeout-as-unknown. T32 (P2) crawl budget 10 s + truncated. T33 (P2) support-admin read-only tab.

Phase 3 complete. Codex: unavailable. Claude subagent: 21 findings, all verified where checked, all applied. Consensus: single voice; 5/6 flagged β†’ fixed in plan. Passing to Phase 4 (DX skipped).

Skipped β€” no developer-facing scope detected. The deliverable is an operator (CSR/buyer) surface and internal adapters; no API, CLI, SDK, or agent-facing contract changes. (IERPService gains optional methods, but that interface is internal to the monorepo.)

  • Theme: submit-guard semantics β€” CEO (kept as-is), Design (releases after apply β†’ duplicate), Eng (all-rows filter β†’ permanent block). Three phases, three different defects in one 46-line file. High-confidence: the newest-row rule (A3) is the fix for all of them.
  • Theme: client-supplied ERP identifiers β€” CEO S3.1 and Eng S1 independently: erpItemId in line_resolutions must be catalog-verified server-side.
  • Theme: intake is load-bearing and dangerous β€” CEO F1 (emails never arrive) and Eng S3 (the flip is a PO oracle). Both true; sender corroboration is the reconciling constraint.
  • Theme: WhereFour apply flags held OFF live β€” CEO F7 and Eng hidden complexity; A0 cannot be proven without T3.
  • Theme: the plan’s β€œreuse as-is” claims β€” Eng found two false (?tab=, header writes), Design found DeltaRow colors unfit. Corrected; lesson: β€œreuse” must be verified against the function body, not the docblock.
# Phase Decision Classification Principle Rationale Rejected
1 CEO 0F Mode = SELECTIVE EXPANSION Mechanical autoplan override Enhancement of an existing system EXPANSION / HOLD / REDUCTION
2 CEO 0C-bis Approach A (extend review page) Taste (A vs B close on diff width) P1, P5 No new page/access-control surface; deep link via ?tab=revision; promotable to B later B dedicated page, C MCP-first
3 CEO 0D A0 (dead-wiring fixes) ships as its own PR before A1–A3 Mechanical P6, P3 Bug fix behind existing flags; unblocks CK now bundle into the stack
4 CEO 0D E1 inbox badge Taste P2 borderline 3 files outside plan radius, but Β§4.5 already assumes it β€”
5 CEO 0D E2 β€œChanges (N)” queue pill β†’ TODOS Mechanical P2 outside radius >5 files, inbox cockpit include now
6 CEO 0D E3 Revert from before_image β†’ TODOS Mechanical P2 New ERP write path needs own flag + spike include now
7 CEO 0D E4 auto-preselect lone strong candidate β†’ REJECT Mechanical P1 (safety completeness) Contradicts load-bearing never-preselect rule accept
8 CEO 0D E5 Copy change summary β†’ ACCEPT Mechanical P1 Whole buy-side product while B0 unproven defer
9 CEO 0D E6 empty diff β†’ Mark as duplicate β†’ ACCEPT Mechanical P4 DRY, P1 Reuses set-aside prefill defer
10 CEO 0D E7 keyboard accept/skip β†’ ACCEPT Mechanical P1 In radius, <1h CC defer
11 CEO 0D E8 Open-in-WhereFour link Taste P2 borderline URL pattern unverified β€”
12 CEO 0D E9 E4 sales-order adapter β†’ TODOS Mechanical P2 Outside radius (E4 service + Temporal) include now
13 CEO 0D E10 no-document change emails β†’ TODOS (order_events) Mechanical P2 Different substrate include now
14 CEO 0.5 Codex CEO voice: backend 404 on first run; one retry with explicit model Mechanical P6 Degrade to [subagent-only] if retry fails; do not retry per phase β€”
15 CEO gate D1 USER CHALLENGE β€” user keeps P21 buy-side as planned User decision β€” User’s direction stands over the single-voice critical finding (F3). Plan keeps B0–B2; F3/F5/F9 recorded in Β§8 as risks the user has accepted knowingly. B0 remains a hard gate. park / re-target / cut
16 CEO gate D2 β€” intake phase added to scope (order_change + attachment + PO on file β†’ process; documentRole='revision'; flag revision_intake_v1; β‰₯10 triage eval cases) User decision P1 Without it the tab sees only conf-POs that fool triage defer / hold
17 CEO gate D3 β€” auto-persist detected row REJECTED by user; decision-only as today User decision β€” User prefers zero CSR behavior change on submit; ignored-banner duplicate path stays open and is recorded as an accepted risk strong-only / all tiers
18 CEO gate D4 β€” prior buy-side designs: gbrain queried (4 keyword + 2 semantic); nearest hits are the GenFitβ†’Odoo PO migration plan and P21 access docs, neither is a vendor-ack design. Negative result recorded in Β§0 P3. Mechanical β€” P3 remains unverified; D1 stands on the user’s word β€”
19 Design P2 Post-apply: footer β€œApplied as revision”, submit disabled, guard blocks any revise row, apply completes work item Mechanical (critical) P1 Guard releasing on patch_succeeded re-opens the duplicate path leave as is
20 Design P2 Auto-land on strong tier or vendor ack only Mechanical P5 Resolves Β§4.4/Β§8.10 contradiction β‰₯1 candidate
21 Design P2 Tab always in tablist when flag on Mechanical P1 Deep link / error CTA must land hidden at 0
22 Design P2 β€œMark as duplicate” = existing set-aside exit, not a new enum Mechanical P4 DRY Reuses ReviewSubmitFlow reject step new enum value
23 Design P4 Default per-line resolution = accept, Confirm shows count Taste P3 Zero taps on a clean revision vs skim risk none-selected default
24 Design P3 Buy-side verbs Record confirmed / Keep as ordered / Flag for dispute Mechanical P1 β€œAccept” misreads a vendor override accept/skip
25 Design P3 Start clean reversible until submit; no window.confirm; ConfirmDialog Mechanical P1, P5 Existing primitive; undo path terminal + confirm()
26 Design P5/P6 Eyebrows not cards; one primary CTA; neutral deltas; mono numerals; container query 480px stacked rows; switch a11y Mechanical P5 DESIGN.md app-UI rules; pane is 320px on laptops as drafted
27 Eng S1 Initial tab decided at SSR (?tab=, newest row, documentRole); no post-paint auto-switch Mechanical P5 applyInitialReviewTab ignores ?tab= (verified) async auto-land
28 Eng S1 Apply binds to row.connectionId; adapter registry by connection type Mechanical P5 org-default fallback = wrong-ERP hazard on multi-connection orgs capability probe
29 Eng S1 Submit guard evaluates the newest row only (revise=block any status; start_clean=allow) Mechanical P1 fixes release-after-apply AND permanent-block defects per-row filter
30 Eng S1 Header deltas display-only for all connectors; writes β†’ TODO Mechanical P3 updateOrder has no ship-to/date write path build connector writes now
31 Eng S2 Server derives revisesErpOrderNumber from selectedErpOrderId; client field removed Mechanical P5 makes the Β§2.3.1 bug class impossible send both fields
32 Eng S2 Server-stamped lineKey in snapshot Mechanical P5 client/server derivation drift β†’ 422 storms derive both sides
33 Eng Sec Intake requires sender→customer corroboration before flipping order_change to process Mechanical P1 PO-guess oracle exposes live ERP order sender-agnostic flip
34 Eng Sec submit.ts hard-rejects vendor_acknowledgment; header suggestions/auto-bind skip vendor docs Mechanical P1 vendor could be auto-bound as a customer hide button only
35 Eng Test β‰₯20 order_change eval cases required before A-in ships (dataset has 0) Mechanical P1 prompt change with no eval coverage ship on the 55 alone
36 Eng Perf Server crawl budget 10 s + truncated:true; client timeout 20 s Mechanical P3 fallback crawl exceeds client abort leave as is
37 Final gate D6 Approach A (tab on review page) confirmed User decision β€” Promotable to B later B
38 Final gate D7 accept-by-default change rows; Confirm shows exact count User decision β€” Zero taps on clean revisions none-selected
39 Final gate D8 E1 inbox badge included (B1); E8 Open-in-WhereFour link included as P3, gated on verifying the per-connection base URL in A3 User decision β€” Two 15-minute wins defer
Review Trigger Why Runs Status Findings
CEO Review /plan-ceo-review Scope & strategy 1 CLEAR (via /autoplan) 10 proposals: 3 accepted, 3 taste (all resolved), 4 deferred, 1 rejected; premise gate D1–D4 by user
Codex Review codex exec Independent 2nd opinion 2 attempts UNAVAILABLE (backend 404; model rejected on ChatGPT account) β€”
Eng Review /plan-eng-review Architecture & tests (required) 1 CLEAR (PLAN via /autoplan) 21 findings, all applied; 2 regressions flagged; 0 critical gaps after fixes
Design Review /plan-design-review UI/UX gaps 1 CLEAR (via /autoplan) score 5/10 β†’ 8.5/10; 1 critical (post-apply footer) fixed; 9 decisions
DX Review /plan-devex-review Developer experience gaps 0 SKIPPED (no developer-facing scope) β€”
  • CROSS-MODEL: Claude subagents only (Codex unavailable this session). Single-voice; every critical finding was verified in code by the orchestrator before being acted on.
  • UNRESOLVED: 0 decisions unresolved.
  • VERDICT: CEO + ENG + DESIGN CLEARED β€” ready to implement. Start with A0 (T1/T24, T2, T3, T23) as its own PR; then A-in (T10, T27) in parallel with A1–A2; then A3; B0 spike gates B1–B2. Run /ship when a phase is done. | 40 | design-shotgun | Round-2 direction approved 5/5: rail = change list; MismatchDecisionCard for target + each change; settled banners express accept-by-default; no bind concept (Items-tab Needs match); no radios | User decision | β€” | Board feedback; supersedes Β§4.4 toggle rows and radio picker | round-1 A–E |