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.
0. Problem statement
Section titled β0. Problem statementβ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.
Premises (to be confirmed at the gate)
Section titled βPremises (to be confirmed at the gate)β- 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,
~/.gstackartifacts, 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,~/.gstackartifacts, 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_revisionstable, and WhereFourIERPService.updateOrdercontract. Build new only where nothing exists (buy-side ERP read/write, header diff, explicit added-line item identity).
1. Goals / non-goals
Section titled β1. Goals / non-goalsβGoals
- 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.
- Fix the dead wiring so the existing WhereFour sell-side apply path works end to end from the UI and survives a page refresh.
- 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.
- 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).
- 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_eventsPhase-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.
2. What already exists (leverage map)
Section titled β2. What already exists (leverage map)β2.1 Reusable as-is
Section titled β2.1 Reusable as-isβ| 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. |
2.2 Exists but is the wrong shape
Section titled β2.2 Exists but is the wrong shapeβRevisionStrip+PoHistoryStrip+RevisionPanelare three independentclient:onlyislands 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 oneRevisionSignalBar+ the Revision tab.RevisionCandidate.changeSetis lines-only. No header deltas.RevisionLine.partNumbersblends customer part #, vendor part #, and ERP item id into one array, soapply.tscannot recover an ERP sku for anaddedline and unconditionally blocks it.ReviewSubmitFlow.tsx:701-795carries a manualsearch-by-poescape hatch inside the reject step. It belongs in the Revision tabβs target picker.
2.3 Broken today (P1 fixes, no design needed)
Section titled β2.3 Broken today (P1 fixes, no design needed)β- Apply cannot succeed from the UI.
recordDecisionnever sendsrevisesErpOrderNumber;revision-decision.ts:80only reads that field;revision-apply.ts:127-133500sMISSING_TARGET_ORDER. The test fixture hardcodes the value and hides it. - Saved decision vanishes on refresh.
$savedDecision/$decisionStatusare never rehydrated fromorder_revisions; the Apply button disappears after reload. verification_failedis a dead end. Once WhereFourupdated_atmoves, every retry fails the lost-update guard; the documented recovery (re-record the decision) has no UI.before_imageis never written although declared required for rollback.
3. Alternatives considered
Section titled β3. Alternatives consideredβ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; thedata-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-controlsurface) and it is also the complete path for the operator: everything they need is where theyalready are. B is the right call only if the side-by-side diff proves unusable inside thecenter pane; A's tab can be promoted to B later without touching the engine, table, or endpoints.4. Design
Section titled β4. Designβ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_idis the connectorβs immutable target id.revises_erp_order_numberremains 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_snapshotcontains the complete typedChangeSet, including display-only header deltas. A separateheader_diffcolumn would duplicate it and is not added.matched_signals,candidate_count,confidence_tier, anddollars_changedare likewise computed and written by the server, never trusted from the browser.- Confirming
reviseinsertspatch_status='approved'when the mapping produced at least one writable line, otherwiserecorded(an all-date-only WhereFour revision, or any Eclipse E4 revision). Confirmingstart_cleaninsertspatch_status='recorded'. The laneβs ERP-write flag never decides the persisted status: the route returns it asapplyEnabledand the workbench withholds Apply while it is off, so anapprovedrow becomes appliable on the next load after the flag is turned on. before_imageis the sanitized state read in the same ERP update operation immediately before the write;after_imageis 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.
4.2 Revision target adapter (server)
Section titled β4.2 Revision target adapter (server)β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 | nullDo 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.
4.3 Engine additions
Section titled β4.3 Engine additionsβRevisionLine.erpItemId?: stringanderpItemSku?: stringexplicitly carry catalog identity, separate frompartNumbers[].confToRevisionOrderfills the id fromextracted_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 opensERPSearchModaland then reloads/re-diffs. It never embeds catalog identity insideline_resolutions.DiffLine.lineKey: stringis 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 intodiff_snapshot; the client merely returns choices keyed by those values.ChangeSet.header: HeaderDelta[], whereHeaderDeltacarries a constrained field name plus normalized old/new display values.diffHeaderis pure. Header deltas are display-only for every connector in this plan; a row is labelledShown onlyand 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βsa/s/Enter keys, and excluded from βN of N changes will applyβ and the Ready meter.lineMoneyChangedinengine.tsis the one predicate the mapper, server view model, and client store share for this.mapChangeSetapplies only line rows resolved torevision.currentanddisputewrite nothing.revisiononremovedmaps to deletion;revisiononaddedmaps to an addition only whenerpItemIdis 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 aNO_APPLICABLE_CHANGESmapping failure; an all-date-only WhereFour set mapsokwith zero items so the decision is still recorded (Submit stays blocked), andrevision-applyanswers it 409REVISION_NOT_APPROVED. Anapprovedrow whose mapping is empty at apply time is moved torecordedand answered 422NO_APPLICABLE_CHANGESinstead of reconciling as applied.
4.4 UI and component changes
Section titled β4.4 UI and component changesβ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
MismatchDecisionCardchoice tiles. There are no radio controls. - Rows default to
revision.MismatchSettledBannershows the current choice and its reversible action. Sell-side choices arerevision/current; buy-side also permitsdispute, which records the disagreement and writes nothing for that line. - An unmatched/added line opens the existing
ERPSearchModal. Matching writeserpItemIdthrough 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/afterreads 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_cleankeeps the ordinary checklist, and approval-only orgs are exempt because nothing is written to the ERP there. Source:$revisionSubmitLockinstores/pdfOrder/revision-view-model.ts. - Buy-side labels use
ordered,vendor confirmed, andflag 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βββΆ loadingselected ββstart cleanβββΆ start_cleanapplying 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.
4.5 Buy-side intake (P21 first)
Section titled β4.5 Buy-side intake (P21 first)β 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.
- Validate/materialize
stg_purchase_ordersandstg_purchase_order_linesfor one P21 sandbox connection, then add the four existing bronze/silver models to the canonical dbt transform asset list. - Add Dagster assets for two new Typesense collections.
purchase_ordersuses id{connection_id}:{company_no}:{po_no}and fieldsconnection_id(facet),normalized_po_no,po_no,company_no,vendor_id,order_date,date_due, and the available modification token.purchase_order_linesuses id{connection_id}:{company_no}:{po_no}:{line_no}and addsline_no,item_id, description, parsed quantities/prices, UOM, completion/cancel state, due date, and line modification token. - 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_idfilter and exact normalized PO predicate; vendor id only corroborates/ranks and never broadens the query. - 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.
4.7 API, store, and trust-boundary contract
Section titled β4.7 API, store, and trust-boundary contractβ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.
5. Implementation phases
Section titled β5. Implementation phasesβ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.
S0 β Evidence and external prerequisites
Section titled βS0 β Evidence and external prerequisitesβ- Label the last 30 days of CK
order_changeskips: 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, orFAILresult.
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.
A1 β Pure engine and target adapter seam (PR 2)
Section titled βA1 β Pure engine and target adapter seam (PR 2)β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
revisionIdfor apply; loadrow.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
extractedOrderIdonly 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.
A5 β GA cleanup (PR 9, after two stable weeks)
Section titled βA5 β GA cleanup (PR 9, after two stable weeks)β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.
6. Verification matrix
Section titled β6. Verification matrixβ| 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).
7. Observability
Section titled β7. Observabilityβorder_revisionsalready carries typed reporting columns; add PostHog eventsrevision_tab_opened,revision_decision_recorded {decision, tier, candidateCount},revision_applied {targetKind, changes, dollarsChanged},revision_apply_failed {status}β same wiring astrackOrderOpened.- Structured server logs on the three routes already exist; add
targetKindand adapter name to each. - Day-1 panel: decisions/day by tier and decision; apply success vs
patch_failedvsverification_failed; median time from lookup to apply. - Alert: any
patch_failedorverification_failedon a connection with its apply flag on (there should be ~0/day), and anypatch_startedolder than five minutes awaiting reconciliation.
7.1 Deployment and rollback
Section titled β7.1 Deployment and rollbackβ- Register all new flags OFF; deploy M1 before code that writes its columns.
- Deploy A1βA3 with the old UI still active. Validate read-only lookup, SSR hydration, submit guards, and compatibility request logging.
- Enable
review_revision_tab_v1for the internal staging connection. Run the recorded UI suite with ERP write flags OFF. - 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.
- Shadow-log A-in before enabling processing. Enable sell-side intake for one connection only after the eval and aggregate S0 review.
- Roll B1/B2 independently as read-only. B3 has its own sandbox/internal/tenant rollout and cannot reuse a sell-side write flag.
- 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.
8. Risks, failures, and rescue paths
Section titled β8. Risks, failures, and rescue pathsβ| 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:
- 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.
- 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 whetherTicket 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. - 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.
customerNamecarrying the vendor name on buy-side rows is a smell (ponytail, noted in Β§4.1).- 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.
- 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.
- 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
documentRolefilter so a CSR can hide vendor acks; fallback shape is Approach E. Reviewer finding F5. - 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.
- 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.
- 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.
New evidence
Section titled βNew evidenceβ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, andStatusas 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 viaTABPAGE_18/TABPAGE_19) against the Genfit sandbox, with adry_runtoggle 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 normalizeditem_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 byline_numberfirst, falls back to uniquepart_number, and explicitly flagsduplicate_or_split_line,reused_ack_line,unmatched_ack_line,substituted_part_number,missing_required_fieldasneeds_reviewrather than guessing. Reusable comparison-engine prior art, not PF-Markey-specific.- Still genuinely missing, per that same code: no
Ticket No/Carrier ID/Tracking Nohandling (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.
Revised phase order
Section titled βRevised phase orderβ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 cleanup10. NOT in scope (deferred, see TODOS.md)
Section titled β10. NOT in scope (deferred, see TODOS.md)β- β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 onextracted_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_LOOKUPon 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_imageper line, not the whole order. - S6 (A-in / B2): vendor acknowledgments that are not ours need a
not_oursdecision instead of start_clean, and submit must reject them before B2. Default: addnot_oursto 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.
Approved mockups (design-shotgun, 2026-09-03)
Section titled βApproved mockups (design-shotgun, 2026-09-03)β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 |
![]() |
| First open: three target tiles + search field | ![]() |
| Needs-match line | ![]() |
| 1024px laptop | ![]() |
| Buy-side, Dispute as third tile | ![]() |
| Applied: green banner, Submit disabled | ![]() |
| Header field shown for reference | ![]() |
Β§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)βStep 0A β Premise challenge
Section titled βStep 0A β Premise challengeβ| 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.
Step 0B β Existing code leverage
Section titled βStep 0B β Existing code leverageβ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).
Step 0C β Dream state
Section titled βStep 0C β Dream stateβ 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).
Step 0C-bis β Implementation alternatives
Section titled βStep 0C-bis β Implementation alternativesβ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.
Step 0D β SELECTIVE EXPANSION analysis
Section titled βStep 0D β SELECTIVE EXPANSION analysisβ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.Step 0F β Mode
Section titled βStep 0F β ModeβSELECTIVE EXPANSION (autoplan override; feature enhancement on an existing system). Approach A under this mode.
Step 0.5 β Dual voices (CEO)
Section titled βStep 0.5 β Dual voices (CEO)β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.Reshape proposed to the user (pending gate)
Section titled βReshape proposed to the user (pending 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)βSection 1 β Architecture
Section titled βSection 1 β Architectureβ 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=revisionFindings (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.
Section 2 β Error & Rescue Map
Section titled βSection 2 β Error & Rescue Mapβ 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 pageFive GAPs, each with a specified rescue; all become tasks (T-list).
Section 3 β Security & Threat Model
Section titled βSection 3 β Security & Threat Modelβ| 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. |
Section 4 β Data Flow & Interaction Edge Cases
Section titled βSection 4 β Data Flow & Interaction Edge Casesβ 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) |
Section 5 β Code Quality
Section titled βSection 5 β Code Qualityβ| # | 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). |
Section 6 β Test Review
Section titled βSection 6 β Test Reviewβ 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 rows2 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.
Section 7 β Performance
Section titled βSection 7 β Performanceβ| 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. |
Section 8 β Observability
Section titled βSection 8 β Observabilityβ- 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_failedratio > 10 %; intake processed=0 for a connection withrevision_intake_v1on for 3 days (silent gate). - Dashboard day 1: the four counts above + median lookupβapply time.
- Runbook
verification_failed: open the order in WF, comparebefore_imageto live, re-check in the tab, re-record. Runbookpatch_failed: readafter_image/error, check WF status allowlist (invoiced?), retry or start clean. - Debuggability: every step is an
order_revisionsrow with snapshots; 3-week-old bug is reconstructible from the table alone.
Section 9 β Deployment & Rollout
Section titled βSection 9 β Deployment & Rolloutβ- Migration: three nullable/defaulted columns + one new enum + one enum member (
documentRole) β additive, no rewrite, no lock beyond metadata. Generated by Drizzle; nopg_constraintguard needed (no constraint). Deploy order: migrate β webapp β temporal-worker (A-in) β old code ignores new columns. - Flags registered in
flags.jsonin the same PR as the code (shipped-but-unregistered = inert). Segment overrides:org_internal_testfirst, 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 ofreview_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_resolutionsignored β 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_openedfires. First hour: zeropatch_failed. - Rollback: flag off (instant). Migration stays (additive).
Section 10 β Long-Term Trajectory
Section titled βSection 10 β Long-Term Trajectoryβ- Debt introduced:
customerNamefor vendor;documentRolegains a second axis of meaning (revision,vendor_acknowledgment) β acceptable for two values; a third should become its own column.po-history.tskeeps 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_imagenow records). - 1-year question: a new engineer reads
targets/index.tsand sees every ERP that can be revised in one place. Yes. - Phase 2/3: E9 (E4 adapter), E3 (revert), auto-apply policy on
approvedrows,order_eventsfor document-less changes. The architecture supports all four without schema churn.
Section 11 β Design & UX (CEO-level)
Section titled βSection 11 β Design & UX (CEO-level)β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.
Required outputs (CEO)
Section titled βRequired outputs (CEO)βNOT in scope
Section titled βNOT in scopeβ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).
What already exists
Section titled βWhat already existsβΒ§2.1 (15 sub-problems mapped) β plan reuses all of them; nothing rebuilt.
Dream state delta
Section titled βDream state deltaβ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.
Failure Modes Registry
Section titled βFailure Modes Registryβ 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.
Diagrams produced
Section titled βDiagrams producedβ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).
Stale Diagram Audit
Section titled βStale Diagram Auditβ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).
TODOS.md updates (auto-decided: add all six)
Section titled βTODOS.md updates (auto-decided: add all six)β- β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 replacingcustomerNameon buy-side rows β P3, S/S. 6. Lift the live WF apply-flag identity overrides fororg_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
4f234677for internal org; A0 exit criterion. Surfaced by: F7. - T4 (P1, ~1d / 2h) β lib/revision/targets β
RevisionTargetAdapter,resolveTarget(connection), WF adapter; replace 4instanceofsites. 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,documentRolemembers; generated migration. Surfaced by: Β§4.1. - T7 (P1, ~2h / 30min) β decision route β zod for
line_resolutions, snapshot-membership,erpItemId β catalogvia Typesense;approvedstate. 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_ordersPG projection (Dagster, merge disposition) + adapter candidates. Surfaced by: S1.2. - T13 (P2, ~2d / 3h) β B0 spike doc; adapter
applyreturnsunsupporteduntil 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.
Completion Summary (CEO)
Section titled βCompletion Summary (CEO)β +====================================================================+ | 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)βStep 0 β Design scope
Section titled βStep 0 β Design scopeβ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).
Step 0.5 β Dual voices (design)
Section titled βStep 0.5 β Dual voices (design)β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.
Pass 1 β Information architecture: 6 β 9
Section titled βPass 1 β Information architecture: 6 β 9β- 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
getPageRectpath; 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β |
Pass 3 β User journey: 5 β 8
Section titled βPass 3 β User journey: 5 β 8β- Confirmation stacking: no
window.confirm. βConfirm changesβ IS the confirmation (one button, shows the count: βApply 4 of 5 changes to #5830039β). Start clean usesConfirmDialog. - 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.
Pass 4 β AI slop risk: 6 β 8
Section titled βPass 4 β AI slop risk: 6 β 8β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.
Pass 5 β Design-system alignment: 4 β 9
Section titled βPass 5 β Design-system alignment: 4 β 9β- No nested cards. Sections are eyebrows (11px bold uppercase
--om-surface-ink-3) +--om-border-surfacedividers; 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 afterstart_clean; afterappliedit 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/CandidateCardare restyled to tokens (drop hardcodedwhite,rgba(196,72,1,.3)). - Mono tabular numerals (
--om-font-mono) on qty, price, totals, order #.
Pass 6 β Responsive & accessibility: 3 β 8
Section titled βPass 6 β Responsive & accessibility: 3 β 8β- Breakpoint is the container, not the viewport: the center pane is
minmax(320px,1fr)on a 1024px laptop (review-layout.ts:17). Use the@containerthe pane already opens. Below 480px container width each change is a stacked row: line 1 name (+ doc-highlight on focus), line 2qty 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 twoaria-pressedbuttons; controls carry item-scoped labels (βSkip change to Kimchi 16ozβ);aria-live="polite"region for decision/apply transitions,role="alert"on failure; skeletonsaria-busy; ERPSearchModal returns focus to the Bind button; signs remain text, never color-only; the table keeps<caption>+scope="col".
Pass 7 β Unresolved design decisions
Section titled βPass 7 β Unresolved design decisionsβ| 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 completion summary
Section titled βDesign completion summaryβ +====================================================================+ | 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_sendergate 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.
Step 0 β Scope challenge
Section titled βStep 0 β Scope challengeβ- Existing code: Β§2.1 (15 sub-problems). Eng review confirms reuse claims except two:
applyInitialReviewTabhas no?tab=handling (A1) andWherefourService.updateOrderwrites onlyitemsβ no ship-to/date (A6). Both corrected in the body. - Minimum change set: A0 (4 files). Everything else is the deliverable the user asked for; deferrals are already in Β§10.
- Complexity check: ~24 files, 2 new modules β smell triggered; challenged in CEO 0D, committed by the user at the premise gate. Not re-argued.
- 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 inReviewCenterPane). No EUREKA. - 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. - Completeness / distribution: no new artifact type; nothing to publish.
Step 0.5 β Dual voices (eng)
Section titled βStep 0.5 β Dual voices (eng)β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βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββSection 1 β Architecture (auto-decided)
Section titled βSection 1 β Architecture (auto-decided)β| # | 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 |
Section 2 β Code quality (auto-decided)
Section titled βSection 2 β Code quality (auto-decided)β| # | 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). |
Section 3 β Test review
Section titled βSection 3 β Test reviewβ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.
Section 4 β Performance
Section titled βSection 4 β Performanceβ| # | 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. |
Required outputs (eng)
Section titled βRequired outputs (eng)β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. |
Security (eng additions to CEO S3)
Section titled βSecurity (eng additions to CEO S3)β| # | 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. |
Eng completion summary
Section titled βEng completion summaryβ- 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).
Phase 3.5 β DX Review
Section titled βPhase 3.5 β DX Reviewβ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.)
Cross-phase themes
Section titled βCross-phase themesβ- 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:
erpItemIdinline_resolutionsmust 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 foundDeltaRowcolors unfit. Corrected; lesson: βreuseβ must be verified against the function body, not the docblock.
Decision Audit Trail
Section titled βDecision Audit Trailβ| # | 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 |
GSTACK REVIEW REPORT
Section titled βGSTACK REVIEW REPORTβ| 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
/shipwhen 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 |






