tests/integration.sx — one narrative across every module: catalog -> stock check -> quote (promo+stack+tax) -> attribution -> order flow -> payment envelope -> settle -> recon -> refund flow -> ledger mismatch, asserting the seams tie together with consistent numbers. Proves the three-substrate composition (minikanren pricing + flow lifecycle + persist ledger) end to end. Total 297/297 across 18 suites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
18 KiB
commerce-on-sx: Catalog, cart, pricing & orders on miniKanren
DRAFT outline. The revenue vertical. Depends on
persist-on-sx(durable orders) andflow-on-sx(checkout as a durable flow). Don't start before persist-on-sx Phase 1 is green.
rose-ash's revenue engine — market (catalog), cart (checkout), orders (SumUp payment, reconciliation) — has no SX subsystem. The hard part of commerce isn't CRUD; it's pricing: discounts, bundles, tax, membership rates, promotions that stack (or don't). These are relations, and a relational engine can run them in multiple directions — forward ("what's the total?") and backward ("what promo code yields this total?", "which line item triggered the discount?").
That's a miniKanren fit. Pricing/promotion rules are relational; cart and order
lifecycle (reserve → pay → fulfil → reconcile) is a durable flow; the order
ledger is a persist stream. Commerce is the first real composition subsystem.
End-state: a catalog model, a relational pricing/promotion engine, a cart with deterministic totals, and an order lifecycle flow with payment-webhook reconciliation — all auditable via the event log.
Status (rolling)
bash lib/commerce/conformance.sh → 297/297 (18 suites; + integration) — roadmap + Phase 5 backlog + e2e composition proof complete
Ground rules
- Scope: only
lib/commerce/**andplans/commerce-on-sx.md. May import fromlib/minikanren/, and (once they exist)lib/persist/+lib/flow/. Do not edit substrates. - Architecture: prices/promotions are miniKanren relations over catalog facts;
a cart total is a deterministic query result (first solution under a fixed rule
order). Order lifecycle is a
flowthat suspends at the payment IO boundary. Money is integer minor units — never floats. - Determinism: promotion stacking must have explicit, tested precedence; totals must be reproducible from the cart + catalog snapshot.
- Commits: one feature per commit. Progress log + tick boxes.
Architecture sketch
Catalog + cart Total / order
product(id,price,tags) {:subtotal :discounts :tax :total}
│ ▲
▼ │
lib/commerce/catalog.sx lib/commerce/price.sx
— product / variant / stock facts — miniKanren pricing relations
│ — promo stacking, membership rates
▼ ▲
lib/commerce/cart.sx lib/commerce/order.sx (flow + store)
— line items, quantities — reserve→pay→fulfil→reconcile
│ — SumUp webhook = flow resume
▼ │
lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) ──┘
Phase 1 — Catalog + cart + deterministic totals
catalog.sx— product/variant/stock as factscart.sx— line items, add/remove/qtyprice.sx— base pricing relation, subtotal; taxapi.sx+ tests + scoreboard + conformance.sh
Phase 2 — Promotions (relational)
- promo rules: percentage, fixed, bundle, member rate
- explicit stacking precedence; "best price" backward query
- tests: stacking order, mutually-exclusive promos, member vs guest
Phase 3 — Order lifecycle (flow + store)
- order flow: reserve stock → await payment → fulfil
- payment webhook resumes the suspended flow
- order ledger as a
persiststream; idempotent reconciliation
Phase 4 — Reconciliation + federation
- mismatch detection (paid≠ordered) as queries over the ledger
- cross-instance catalog (federated marketplace) — out-of-scope stub
- tests: webhook replay, partial refund, double-charge guard
Phase 5 — Extensions (backlog; base roadmap complete) — ALL DONE (278/278)
Thesis-aligned deepenings of the relational/composition showcase. Pick the one that unlocks the most tests per effort each iteration.
- line-level discount attribution — "which line item triggered this discount?"
as a backward miniKanren query (
attribution.sx:promo-touchesorelation,lines-for-code/codes-for-lineboth directions,order-level-codesfor fixed). - time-windowed promotions —
window.sx: windowed promo(promo from until),active-ruleset/active-codes/windowed-quotegate by datetime; feeds the existing promo/stack/quote pipeline unchanged. Determinism preserved. - discount-aware tax policy —
nettax.sx:cart-quote-nettaxes the net (post-discount) base;allocate-discountspreads the basket discount across lines by extended share with largest-remainder so per-line shares sum exactly. - refund as a flow —
refund.sx: refund lifecycle (request → approve → settle) as a second flow-on-sx flow with two suspension points; idempotent settle, reject path, ledger-recorded; reuses order.sx flow helpers. - stock-constrained reservation —
stock.sx:can-reserve?/reserve-checkprecondition (host gates order-begin! on it, keeping the flow pure);reservation-shortfallsdetail;effective-availablenets out concurrent reservations;sufficient-stockorelational availability query. - provider-neutral payment-request envelope —
payment.sx:payment-requestmaterialises{:order :amount :currency :return-url}at the IO edge (amount from the ledger, currency/return-url host-supplied);pending-paymentsenumerates suspended orders with their envelopes (host poller seam). Engine stays vendor- agnostic;order-settle!(ref, amount)is the resume seam.
Progress log
- 2026-06-07 —
tests/integration.sx(hardening): end-to-end composition proof — one narrative across every module (catalog → stock check → quote[promo+stack+tax] → attribution → order flow → payment envelope → settle → recon → refund flow → ledger mismatch) asserting the seams tie together with consistent numbers (subtotal 2400, discount 210, tax 320, total 2510; settle→:ok; refund 510→ :underpaid; mismatch flagged). Proves the three-substrate composition. One env with both order+refund flows. integration suite 19/19; total 297/297 (18 suites). - 2026-06-07 —
refund.sx(Phase 5 backlog complete): refund lifecycle as a second flow-on-sx flow(lambda (oid) (begin (request 'approve oid) (request 'settle oid)))— two suspension points (approval = human/policy decision, settle = provider).refund-begin!records :refund-requested and suspends at approval;refund-approve!advances to settle;refund-settle!records :refunded (idempotent) and completes;refund-reject!records :refund-rejected and cancels the flow. Only :refunded moves the books, so requested/rejected refunds leave recon unchanged. Reuses order.sx flow helpers. refund suite 20/20; total 278/278 (17 suites). NB: conformance now has two env-building suites (order, refund) — each builds the ~150s flow env in its own process. - 2026-06-07 —
stock.sx(Phase 5 ext): stock-constrained reservation. Design choice: reservation is a precondition the host checks BEFORE order-begin! (validate → begin), keeping the order flow pure orchestration.available-stockreads the catalog stock facts;can-reserve?/reserve-check/reservation-shortfallsgate a cart;effective-available/line-reservable-with?net out concurrent reservations (no over-reserve);sufficient-stockois the multidirectional availability query. Only refund-as-flow remains in the backlog. stock suite 19/19; total 258/258 (16 suites). - 2026-06-07 —
nettax.sx(Phase 5 ext): discount-aware tax — the alternative to quote.sx's gross-tax default.cart-quote-nettaxes the NET (post-discount) base.allocate-discountspreads the basket-level discount across lines in proportion to extended price with a deterministic largest-remainder pass so per-line shares sum EXACTLY to the discount; each line is then taxed on its net at its class rate. Both policies reproducible from inputs; pick per jurisdiction. nettax suite 11/11; total 239/239 (15 suites). - 2026-06-07 —
window.sx(Phase 5 ext): time-windowed promotions. A validity window is kept SEPARATE from the promo tuple — windowed promo(promo from until)(inclusive int timestamps, nil = open bound).active-rulesetfilters to the plain promos live atatand feeds the existing promo/stack/quote pipeline unchanged (promo.sx untouched);active-promoo/active-codesis the backward "which codes are live at T?" query;windowed-quoteis the datetime-aware quote (deterministic inat). window suite 19/19; total 228/228. - 2026-06-07 —
payment.sx(Phase 5 ext, the item the user asked about): provider-neutral payment-request envelope, materialised at the IO edge from the ledger amount + host-supplied currency/return-url — keeps lib/commerce vendor- agnostic (SumUp/Stripe adapters live in the orders service).payment-requestbuilds the{:order :amount :currency :return-url}envelope;pending-paymentsis the host-poller seam listing suspended orders + their envelopes. Gotcha: a Scheme string carried as a flow payload round-trips back to SX wrapped as{:scm-string "..."}(numbers come back clean) — unwrap viascm->stringbefore using it as the oid. payment suite 7/7 + 1 order-suite integration test; total 209/209 (13 suites). - 2026-06-07 —
attribution.sx(Phase 5 ext): line-level discount attribution — the briefing's marquee "which line item triggered this discount?" query.promo-linesis the pure per-promo scope (percent/member → class lines, bundle → sku lines, fixed → order-level/none);promo-touchesorelates (code, line) for applying promos, run forward (lines-for-code) and backward (codes-for-line).order-level-codeslists applying fixed promos; predicateline-touched-by?. Additive — promo.sx amounts unchanged. attribution suite 16/16; total 201/201 (12 suites). - 2026-06-07 —
recon.sx+federation.sx(Phase 4 complete — roadmap done).recon.sx: reconciliation as relational queries over the ledger. Per-order summary tuples (id total paid refunded net status);recon-statuso/neto/mismatchoare miniKanren relations, so "which orders are overpaid?", "settled to net N?" are backwardrun*queries. Helpers: overpaid/underpaid/ settled/unpaid-orders, mismatched-orders, orders-with-net, ledger-discrepancy. Tests cover double-charge guard (two refs → :overpaid), partial refund (net < total → :underpaid), webhook replay (same ref twice → single :paid, :ok). 20/20.federation.sx(out-of-scope stub): a federated catalog is the UNION of each instance's product facts, so the SAME relations query cross-instance —fed-producto/fed-priceo,instances-with-sku,sku-offers, deterministiccheapest-offer. In-process mock, no real network/ActivityPub. 12/12. Total 185/185 across 11 suites. - 2026-06-07 —
order.sx(Phase 3 complete, checkboxes 1-2): order lifecycle as a flow-on-sx flow(lambda (oid) (begin (request 'reserve oid) (request 'payment oid) (request 'fulfil oid)))— pure orchestration carrying only the order-id; the SX driver services each request by appending to the persist ledger.order-begin!creates+reserves and leaves the flow SUSPENDED at payment;order-settle!(the webhook) resumes → fulfils, and is idempotent (only acts while waiting on payment, so a replayed webhook → :already-settled).order-flow-restart!simulates a process restart entirely Scheme-side (export→reset→reload→import) and the suspended order resumes correctly afterwards with the persist ledger intact. Composes all three substrates (minikanren pricing → flow lifecycle → persist ledger). order suite 21/21; total 153/153. Gotchas: flow ids start at 1; never return flow-make-env across the eval boundary (serializer hangs on the cyclic env); guest Scheme rejects:okkeyword as a value — use#t. Flow env build ~150s CPU; order suite runs single-process with timeout 560. - 2026-06-07 —
ledger.sx(Phase 3 piece, checkbox 3): order ledger as a persist event stream "order/". Status/total/paid/recon are projections (folds) over events — ledger is the single source of truth.order-pay/order-refundare idempotent viapersist/append-oncekeyed on the payment ref, so a replayed SumUp webhook records once (no double-charge).order-recon-ofclassifies :unpaid/:ok/:underpaid/:overpaid on net (paid−refunded) vs total;ledger-mismatchesfinds genuine paid≠ordered across all streams. Verified minikanren+scheme/flow+persist all coexist in one sx_server process. ledger suite 20/20; total 132/132. Next: order flow (reserve→pay→fulfil) as a Scheme flow-on-sx flow with webhook resume (checkboxes 1-2) — needs SX↔Scheme quote marshalling. - 2026-06-07 —
quote.sx(pricing capstone, bridges Phase 2→3):cart-quotecomposes price+promo+stacking into the deterministic priced quote{:subtotal :discount :tax :total :codes}with `total = subtotal - discount- tax`. Explicit tax policy: tax on GROSS per-line amounts (discount reduces payable, not tax base) — documented for the determinism contract. This quote is the value the Phase-3 order flow will carry. quote suite 13/13; total 112/112.
- 2026-06-07 —
stack.sx(Phase 2 complete): stacking precedence as a separate selection layer (precedence NOT in the rules, per the miniKanren design rule). Exclusivity = unordered code pairs;valid-stackingsenumerates every legal subset of applicable promos (powerset ∖ excluded combos);best-stackingis the deterministic max-total-discount selection (stable on ties).stacking-by-totalois the best-price backward query ("which legal stacking yields total D?"). Member vs guest falls out of applicable-promos. stack suite 16/16; total 99/99. - 2026-06-07 —
promo.sx(Phase 2 piece 1): four promo types as tagged tuples(:percent code class bps)/(:fixed code threshold amount)/(:bundle code sku n)/(:member code class bps). Per-promo discount is pure integer arithmetic;promo-discounto/promo-appliesoenumerate (code, amount) relationally — forward ("which apply?") and backward ("which code yields 2000?" → run* over applieso).applicable-promos/promo-amount-fordeterministic helpers. promo amounts viaprojectto ground the membero-bound promo. promo suite 17/17; total 83/83. Next: stacking precedence + best-price (stack.sx). - 2026-06-06 —
api.sx(Phase 1 complete): session facade{:ctx :cart}withcommerce-add/-remove/-set-qty/-total/-count/-lines,commerce-can-add?catalog validation,commerce-explainper-line audit breakdown ({:sku :variant :qty :unit :extended :tax}), and acommerce-checkoutPhase-3 stub. api suite 12/12; total 66/66. - 2026-06-06 —
price.sx: deterministiccart-subtotal(Σ unit×qty, variant delta defaults 0) + jurisdiction-relational tax.taxofacts indexed by (jurisdiction, product-class, customer-class)→bps, queried multidirectionally;apply-bpsrounds half-up with integer arithmetic only.cart-totalreturns{:subtotal :discounts :tax :total}(discounts 0 until Phase 2), reproducible from (context, cart).=does structural dict equality (order-independent), so total dicts compare directly. price suite 20/20; total 54/54. - 2026-06-06 —
cart.sx: cart as an ordered list of (sku variant qty) lines. Pure opscart-add(merges same line / appends),cart-set-qty(0 removes),cart-remove, pluscart-qty/cart-count/cart-skus/cart-empty?.cart-lineois the relational view (membero over the cart) — forward and backward. cart suite 18/18; total 34/34. - 2026-06-06 —
catalog.sx: catalog snapshot (products/variants/stock as fact tuples) + multidirectional accessor relations (producto/varianto/stocko, derivedpriceo/classo/unit-priceo) + deterministiccatalog-price/-class/-has?helpers.conformance.shharness + scoreboard. catalog suite 16/16. Gotcha: minikanrenrun-nmacro bindssinternally — query vars must avoids; tests compare reified results with=(notequal?, which fails on reified lists). Money = integer minor units throughout.
Phase 3 flow-integration notes (for the next iteration)
Order flow = checkboxes 1-2 (reserve→pay→fulfil as a flow-on-sx flow + webhook resume). Design is settled; the remaining work is mechanical but slow to iterate.
- flow is the Scheme-on-SX guest layer, not the SX/minikanren host. Load
order:
lib/guest/{lex,reflective/env,reflective/quoting}+lib/scheme/{parser, eval,runtime}+lib/flow/{spec,store,remote,host,api}. Confirmed it coexists with the minikanren + persist stacks in one sx_server process. - Driver API (SX side):
(flow-make-env)builds the env once;(flow-run-in env "<scheme-src>")evaluates a Scheme program string. Flows/driving are all Scheme:(flow/start flow input),(flow/resume id val),(flow/pending),(flow/status id),(flow/result id). Host ABI (host.sx):(request kind payload)suspends with a typed envelope;(flow-host-requests)lists pending. - Settled design: the Scheme flow carries ONLY the order-id (a string) and is
pure orchestration:
(defflow ordf (lambda (oid) (begin (request 'reserve oid) (request 'payment oid) (request 'fulfil oid)))). All IO/ledger work stays in SX — the SX driver services each request by appending to the persist ledger (ledger.sx) and resuming with a marker. Payment stays suspended until the webhook calls flow/resume. Marshalling is trivial (just strings). - GOTCHA (cost me a turn):
flow-make-envreturns a large/likely-cyclic env object; returning it from(eval "...")makes the harness serializer hang (got exit 0 with NO epoch-2 output). NEVER return the env — wrap as(begin (define env (flow-make-env)) :ok). Structure the flow suite likelib/flow/conformance.sh: load once, build env once, run all assertions in ONE process returning small count values. Budget a long timeout (flow's own suite uses 540s); env build is ~150s CPU and balloons under sibling-agent CPU contention.
Blockers
(none)