Skip to content

feat(oms): Wave 0 — preconditions and correctness fixes (#2282–#2289) - #2438

Open
piotrswierzy wants to merge 22 commits into
mainfrom
oms-programme-waves-0-2
Open

feat(oms): Wave 0 — preconditions and correctness fixes (#2282–#2289)#2438
piotrswierzy wants to merge 22 commits into
mainfrom
oms-programme-waves-0-2

Conversation

@piotrswierzy

Copy link
Copy Markdown
Collaborator

Wave 0 of the OMS programme (backlog overview: docs/plans/oms-backlog-overview.md; execution ledger on the branch at docs/plans/oms-progress-ledger.md). Eight issues, each through the full per-issue process (plan → /pre-implement live-tree audit → plan review with all findings applied → Opus implementation → quality gate → diff review → merge), plus a wave-boundary consistency sweep over the seams between them, whose findings are applied in the closing commit.

What ships

Numbers

83+ files, ~4.9k insertions / ~290 deletions before the boundary commit; 2 migrations (1840000000000, 1841000000000 — synthetic-sequential, verified by check-migration-timestamps); every gate green per issue (core 247/3057, api 106/1392, web 355/3651+1s at final state) plus per-issue int-specs (cancelled-skip e2e, packed 5-case, amendment 4-case, attribution immutability).

Wave gates this unlocks

Wave 1a's entry criteria (#2284/#2286/#2283 merged; #2298 resolved) are all satisfied by this PR. Per the stacked-wave model, oms-programme-wave-1 continues from this branch's tip; merging this PR then folds main forward.

Closes #2282
Closes #2283
Closes #2284
Closes #2285
Closes #2286
Closes #2287
Closes #2288
Closes #2289
Closes #2298

🤖 Generated with Claude Code

piotrswierzy and others added 20 commits August 22, 2026 13:23
Ledger records the owner decisions (automation yes, severity rule, spike
method, integration model) and per-issue programme state; the spike doc
is the #2289 deliverable, closed via this wave PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…writes (#2285)

The derived key was sha256 over (connectionId, offerId, quantity), so any sequence
returning an offer to a previously-written quantity minted a dead command id and the
corrective marketplace write was silently swallowed - a latent oversell. The key now
carries an optional observation token (the inventory row's updatedAt, or a variant's
staleAt); an absent token keeps the legacy key but warns, so the residual degradation
is observable rather than silent. Never wall-clock now(), pinned by a stability test.
Old and new derived keys occupy disjoint keyspaces (the extra segment changes every
digest), so across a rolling deploy an offer receives at most one duplicate absolute
write to the same value - idempotent at every shipped adapter - and no key is reused,
so no corrective write is newly swallowed. Adapters and the propagate handler are
untouched; no schema change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…umers (#2286)

Every consumer branched with a two-arm if/else, so a third union member would
have compiled cleanly and been routed down whichever arm happened to be last —
in the relay's case re-stamped as `type: 'cancelled'` and pushed to every
participant of the order.

`OrderLifecycleRelayInput.event` is now DERIVED from `OrderLifecycleEvent` via a
distributive Omit rather than hand-restated, which is what makes a new member
reach the relay at all; a new shared `assertNever` (@openlinker/shared/types,
admitted under the engineering-standards pure-rule exception) backs it. All five
branch sites become `switch (event.type)` over a `never`-bound default: the relay
throws, while the four adapters return `{ outcome: 'unsupported' }` so an
out-of-tree adapter degrades to a surfaced no-op (ADR-055 forward-compat).

Verified by temporarily widening the union: all five sites fail to compile.
Existing dispatched/cancelled assertions are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
#2284)

A source-cancelled order was still created in every OrderProcessorManager
destination, because the provisioning path never read `order_records.cancelledAt`.
`OrderSyncService.syncOrder` now re-reads the record after resolving destinations
and withholds provisioning when it is cancelled — no lock, no `createOrder`, no
mapping write. Re-read rather than threaded from ingestion, whose snapshot is
stale in exactly this race; a missing record proceeds, a read failure propagates.

The skip is a distinct terminal `skipped_cancelled` result arm (never a `failed`
with a code, so nothing can route it into a retry), persisted per destination via
the widened `OrderSyncStatusFilterValues`. TypeScript-only union widening over a
jsonb payload — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
#2282)

Swap the repository upsert's full-object save() for a parameterized
INSERT ... ON CONFLICT DO UPDATE ... RETURNING *, so sourceConnectionId is
insert-only and sourceEventId follows one rule: same-source may advance,
cross-source frozen. The ADR-017 destination-echo guard stays as defence in
depth; the service now warn-logs a refused change rather than throwing.

The return contract is preserved byte-identically: fromRawRow resets every
out-of-band column (#2140/#2101/#1984/#2100/#2124) that RETURNING now carries,
and DO UPDATE moves updatedAt while never touching createdAt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Two nullable order_records columns recording the plain operator fact "this
order is packed" and who marked it, plus POST/DELETE /orders/:id/packed
(admin+operator, 200 on both). Deliberately a fact, not a state: recordStatus,
fulfillmentState, slaState and OrderHealth are untouched and nothing gates on
it, so it works for every order including omp_fulfilled ones.

Both columns move in ONE guarded statement (packedAt IS NULL to mark, NOT NULL
to clear), so a repeat mark preserves the FIRST actor as well as the first
instant — a per-column COALESCE would not. They join the toOrm/upsert exclusion
list and the fromRawRow reset, so a re-ingestion can never un-pack an order.

migration:generate/show could not be run here (DB auth is unavailable in this
worktree); the 1840000000000 timestamp and index name were verified structurally
against the migrations tail and the entity decorators.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…4/8)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
A re-poll overwrote orderSnapshot wholesale, so a line the source removed,
added or re-quantified — or an edited shipping address — left no trace at all.
A pure domain diff now runs against the already-loaded prior record BEFORE
persistIncomingSnapshot (and before the Step-4 throw that would otherwise lose
the fact on exactly the orders most likely to have been amended), persisting
lastAmendedAt + lastAmendmentChanges through a narrow single-writer statement
that is toOrm-excluded, so the poll that detects the amendment cannot erase it.

An internal fact only: no OrderLifecycleEventTypeValues member, no status, no
gate — one dated timeline entry. PII-free by construction (ids, SKUs and
quantities verbatim; an address contributes field NAMES, never values), and the
incoming address is projected through the one shared redaction helper before
comparison so hash-only mode cannot report a change on every poll forever.

migration:show could not run (local DB auth failure, known env gap); the
migration was verified structurally instead and check-migration-timestamps
passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…e engine (#2298)

The OMS design was written while #2161 was open; it has since merged, so A7's
matrix row, §2.1, §8 and ADRs 052/056 now describe the shipped router
(rule engine first, resolveSalesDocumentRouting as the fallback) rather than a
proposal, and the "no new DI edge" claim is narrowed to the orders edge the
boot int-spec actually pins.

§5.3(c) and the Wave-2 automation spec cite the shipped rule engine as the
house pattern, with an explicit storage-shape RECOMMENDATION: routing rules as
rows in the plugin's own oms_routing_rules table rather than
Connection.config.routing jsonb — flagged as a proposed ADR-054 amendment
pending orchestrator adjudication, not applied. Both residual sales-documents
leaf references gain the #2170 nuance ADR-053 already carries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…s_routing_rules)

Orchestrator adjudication on 2026-08-23 adopted the storage-shape recommendation
raised in the preceding commit: routing rules are stored as rows in the OMS
plugin's own oms_routing_rules table, superseding ADR-054's
Connection.config.routing jsonb sentence. Reason 1 — a RoutingExplanationStep
persisted on a routing_decisions row needs a stable rule id to stay readable
after the operator edits the list — was decisive on its own.

ADR-054's blockquote flips from PROPOSED/pending to AMENDMENT (ADOPTED), its body
from conditional to declarative, and its reversal gate becomes active (keeping the
classified prose-only form check-architecture-gates requires). Design §5.3(c) and
the R2 revision note follow. Wave 3's demand gate still governs when any of this
binds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…ders (#2288)

Surfaces #2287's operator packed fact: a tick (not a badge) first in the
desktop shipment stack and as a labelled fact on the mobile card, both from
one shared cell; a dated timeline entry pushed before the undated
invoicing-block row; and one mutation hook invalidating the whole orders
domain so all three agree.

The mark/unmark control lives on the always-rendered detail page, not in the
capability-gated shipment panel — packing is a fact about every order,
including one no shipping connection touches. Gated with
useWriteAccess('orders:write'): hidden from a viewer, visible-but-locked in
demo mode, mirroring the list's per-row Retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
…e comments (#2282/#2283)

Extend the `upsert` docblock and the in-method SQL comment with the amendment
pair `lastAmendedAt` / `lastAmendmentChanges` (#2283), so all four
excluded-column statements now agree verbatim on the same 16-column set.
Correct the stale "full-object `save()`" clause in the `toOrm` docblock —
since #2282 the write is a single raw-SQL statement; the race doctrine holds.
Ledger closes Wave 0 at 8/8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>

@piotrswierzy piotrswierzy left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tech-lead review — 🔄 Approve with changes

(Reviewed as tech lead; GitHub does not accept an approving review from the PR author's own account, so this is submitted as a comment. Treat the verdict as "approve once the BLOCKING item below is resolved".)

Scope: 83 files, +4965/−285, 9 issues, 2 migrations. Reviewed the write path (#2282), amendment diff + redaction (#2283), cancellation guard (#2284), idempotency token (#2285), exhaustiveness (#2286), packed slice (#2287/#2288), and the FE rollup.


🔴 BLOCKING — migration timestamp collides with open PR #2439

apps/api/src/migrations/1840000000000-add-order-record-packed.ts

PR #2439 (fix(prestashop): read the order's real currency…, also based on main, also open) introduces apps/api/src/migrations/1840000000000-reset-fx-stamp-for-mislabelled-prestashop-orders.ts. Both branches picked 1840000000000 as the next free synthetic prefix after main's tail (1839000000000) — correctly, in isolation.

Whichever merges second fails scripts/check-migration-timestamps.mjs on rule 1 (uniqueness across the union) and rule 3 (strictly greater than every migration on origin/main). Both rules are chained into pnpm lint, so this surfaces as a red pre-commit/CI on the loser's next push rather than as a silent problem — which is the good outcome, but it still blocks a merge.

Worth stating explicitly because migrations.md warns about exactly this window: "rare, but possible in branch-merge windows". It is not rare when two long-lived branches both fork from the same tail.

Resolution — needs a decision on merge order, not just a renumber:

  • If #2439 lands first: this branch renumbers to 1842000000000 / 1843000000000 (its own pair currently occupies 1840/1841), updating both filenames and both class suffixes.
  • If this lands first: #2439 renumbers to 1842000000000.

Since #2439 is a single small fix with a live-verified backfill and this is a nine-issue wave that Wave 1a already stacks on, landing #2439 first and renumbering here is the cheaper sequencing. Either way, the renumber is mechanical (filename prefix + class suffix), and neither migration has run anywhere yet, so no migrations-table reconciliation is needed.


What's right, and non-obviously so

  • #2282 is the right shape, and the reasoning for raw SQL is correct. sourceConnectionId is uuid NOT NULL with no DB default, so it must be on the INSERT half and absent from DO UPDATE — a shape save() genuinely cannot express, and read-before-write would reopen the webhook/poll race the toOrm doctrine exists to close. The sourceEventId CASE (same-source advances, cross-source frozen) preserves pre-#2282 behaviour exactly where it was correct and freezes it only where it was wrong. Keeping the caller-side ADR-017 echo guard as defence in depth rather than deleting it is right.
  • fromRawRow resetting the out-of-band columns is the subtle call, and it went the right way. RETURNING * genuinely carries the true values, and passing them through would have been a silent behaviour change to both OrderRecordService call sites — the documented contract ("those columns read empty; re-read via findById") is what callers were built against. Verified there are no column transformers on the write-set columns, so Object.assign off the raw driver row is safe (all text/uuid/varchar/jsonb/timestamptz; the decimal column is reset anyway).
  • #2283's single redactAddress rule is the load-bearing detail, and the header states the failure mode precisely: two implementations means every poll compares raw against redacted and reports a phantom shipping-address change forever. Running the diff pre-persist off the already-loaded record (so it survives item-resolution failure), and excluding both columns from toOrm so the detecting re-poll cannot erase its own finding, are both correct.
  • #2284 re-reads rather than threading the ingestion snapshot — right, because that snapshot is taken before item resolution and persist, i.e. stale in exactly the race being closed. Propagating a read failure instead of proceeding is the correct asymmetry.
  • #2285's observedAt MUST NOT be wall-clock is documented on the field itself, where the next implementer will read it, not just in the PR body. That is the one rule that, if broken, silently converts dedup into a write-per-tick.
  • #2286 deriving the relay input union from OrderLifecycleEvent rather than adding a sixth restatement is the fix that actually makes the guard hold; adding assertNever to five sites while leaving an independent copy of the union would have been guard theatre.
  • FE rollup: counting skipped_cancelled in its own branch — with the comment explaining that the else catch-all is not compiler-guarded — is exactly the right treatment, and refusing to add a sixth OrderHealth bucket keeps the #2081 partition invariant (KPI cards must sum to total) intact.

🟡 IMPORTANT

  1. assertNever's runtime JSON.stringify can render untrusted payload content into an exception message. libs/shared/src/types/exhaustive.types.ts — the value reaching a default: arm on the relay path is an OrderLifecycleEvent, which is source-derived. If any consumer's error propagates into a persisted sync_jobs failure message or an operator-facing surface, that renders the whole event object, which on some paths carries buyer address fields. Given #2283 in this same PR is explicitly PII-free by construction, the asymmetry is worth closing: render only the discriminant ((value as {type?: unknown})?.type) rather than the full object. The compile-time guarantee — the actual point — is unaffected.

  2. recordAmendment returns void, so the IS DISTINCT FROM no-op is unobservable. order-record.repository.ts — the guard is described as the second line of defence against a source re-reporting the same amendment every poll. Returning (result.affected ?? 0) > 0 (as markPacked / clearPacked do) would let the caller log the suppression, which is the only way anyone would ever learn the primary changes.length > 0 gate had stopped working. Every sibling guarded writer in this file already returns a boolean.

🟢 SUGGESTION

  1. inventory-sync.service.ts — the inventory_quantity_key_unversioned warn fires per item, so a bulk propagation over a connection whose callers all omit observedAt emits one line per offer. A per-command aggregate (count + connection) would keep the degradation observable without a log flood on exactly the large batch where it is most likely.

Checks

  • Layering: repository writes in infrastructure, policy in application services, controller thin. OrderSyncService → IOrderRecordService is a same-context service-interface dependency. ✅
  • assertNever in libs/shared/src/types admitted under the engineering-standards.md pure-rule exception, and the docblock says so rather than setting a silent precedent. ✅
  • Migrations: both have up() + down(); class suffix matches filename prefix; synthetic-sequential. ✅ (prefix uniqueness — see BLOCKING)
  • Authz: @Roles('admin','operator') on both packed routes; FE gates on useWriteAccess('orders:write', …) and correctly keeps the control out of the capability-gated shipment panel (packing is not a carrier capability). ✅
  • SQL: every statement parameterized; no interpolation. ✅
  • Tests: 4 int-specs (attribution immutability, packed, amendment, cancelled-skip e2e) plus unit coverage on each pure function. ✅

Once the timestamp collision is sequenced, this is ready.

norbert-kulus-blockydevs added a commit that referenced this pull request Aug 24, 2026
Two conflicts, both additive, both sides kept: the check:invariants chain
gains main's allegro-seller-defaults guard alongside this branch's
shipping-tax-split guard, and the PrestaShop adapter factory keeps main's
order-currency resolver field next to this branch's tax-rate resolver.

Migration prefixes renumbered, which is Piotr's blocking item 2. #2439
landed on main claiming 1840000000000, colliding head-on with this
branch's add-product-tax-rate. This epic's block moves to 1841000000000
through 1841000000004, and the analytics migration inherited from the
base branch moves to 1840000000001, which keeps it ahead of the
line-item alter that depends on it. #2438 is still open and also claims
1841000000000, so whichever of the two lands second has to renumber
again; this branch is now correct against main as it stands.

Green: type-check clean, check:invariants clean, libs/core + apps/api
4541 tests, prestashop 591.

Refs #2245

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: norbert-kulus-blockydevs <norbert.kulus@blockydevs.com>
@piotrswierzy

Copy link
Copy Markdown
Collaborator Author

Migration-collision update — it resolved against you, and it also moved

Since my review, main merged #2439, so the collision I flagged as a merge-order question is now settled fact:

  • main owns 1840000000000 (1840000000000-reset-fx-stamp-for-mislabelled-prestashop-orders.ts). This branch's 1840000000000-add-order-record-packed.ts now fails rule 1 (uniqueness) and rule 3 (strictly greater than main's newest).
  • 1841000000000 is no longer free either. feat(core,integrations,web,worker): per-line tax rate from the master to the invoice and the receipt #2260 rebased onto the new main and took 18410000000001841000000004, so 1841000000000-add-order-record-amendment.ts would collide with 1841000000000-add-product-tax-rate.ts there.

So both files need moving, and the obvious "shift by one" lands straight into #2260's block.

1842000000000 / 1843000000000 clears main's tail and #2260's range in one step. Filename prefix and class-name suffix on each; nothing has run anywhere, so no migrations-table reconciliation.

Two knock-on notes:

Current claims on the range, for whoever renumbers first:

Prefix Owner
1840000000000 main (merged, #2439)
1840000000001 #2260 (analytics read model, rebased)
18410000000001841000000004 #2260 (tax-rate block)
1842000000000+ free

My review's other two items (assertNever rendering a source-derived event into an exception message; recordAmendment returning void so its no-op guard is unobservable) are unchanged and still worth folding in.

Origin/main gained 1840000000000-reset-fx-stamp after this branch was cut,
colliding with the #2287 packed migration's prefix. Renamed 1840->1842
(1841 is this branch's amendment migration); class suffix updated; byte-
identical to the wave-1 lineage's copy so the stacked merge stays clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
@piotrswierzy

Copy link
Copy Markdown
Collaborator Author

Re-check after 53cbf6f7 — migration blocker cleared ✅, with one latent ordering dependency

The renumber landed correctly:

File Class suffix vs main tail (1840000000000)
1841000000000-add-order-record-amendment.ts AddOrderRecordAmendment1841000000000 greater ✅
1842000000000-add-order-record-packed.ts AddOrderRecordPacked1842000000000 greater ✅

Both filename prefixes and class suffixes agree, both clear main's newest, and the tree has no duplicate prefix — so rules 1, 2 and 3 all pass against main as it stands today. The blocking item from my review is resolved.

🟡 One thing to know before merging out of order

1841000000000 is free on main but is not free on #2014's branch, which absorbed #2260 and carries 1841000000000-add-product-tax-rate.ts through 1841000000006.

So the two branches are now order-dependent:

Not something to change now — a renumber to pre-empt a merge order that hasn't been decided just moves the problem. Worth deciding the order deliberately, given #2441 stacks on this branch and #2014 is a five-PR merge that presumably wants to land soon. Merging this one first is the cheaper sequence: two files here versus seven there.

Still open from my original review

Neither has been addressed and both stand:

  • assertNever renders a source-derived event into its exception message via JSON.stringify(value). On the relay path that value is an OrderLifecycleEvent carrying buyer address fields, and the message can reach a persisted sync_jobs failure or an operator surface. Render the discriminant only. This gets more load-bearing with feat(oms): Wave 1a — vocabulary leaves + derived order lifecycle phase (epic #2312) #2441, which adds several new unions that will grow assertNever call sites.
  • recordAmendment returns void, so its IS DISTINCT FROM no-op guard is unobservable — the caller can't log that the primary changes.length > 0 gate stopped suppressing. Every sibling guarded writer in that repository returns a boolean.

Verdict unchanged: approve once those two are folded in (or explicitly deferred with a reason) — the migration objection is gone.

…841000000006

Origin/main gained 1841000000006 (#2014 analytics) after the wave branches
were cut, outranking the #2283 amendment migration and failing every commit
on check-migration-timestamps. Renamed 1841000000000 -> 1845000000000 (next
free; sorts after 1842 packed / 1843 locations / 1844 provenance — all
independent ALTERs, order-free). Same recipe as the 1840->1842 packed rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Piotr Swierzy <piotr.swierzy@blockydevs.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment