Files
rose-ash/plans/commerce-on-sx.md
giles 744bbb445c
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 36s
commerce: end-to-end composition integration suite (19 tests) — hardening
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>
2026-06-07 13:40:02 +00:00

18 KiB
Raw Blame History

commerce-on-sx: Catalog, cart, pricing & orders on miniKanren

DRAFT outline. The revenue vertical. Depends on persist-on-sx (durable orders) and flow-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.sh297/297 (18 suites; + integration) — roadmap + Phase 5 backlog + e2e composition proof complete

Ground rules

  • Scope: only lib/commerce/** and plans/commerce-on-sx.md. May import from lib/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 flow that 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 facts
  • cart.sx — line items, add/remove/qty
  • price.sx — base pricing relation, subtotal; tax
  • api.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 persist stream; 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-toucheso relation, lines-for-code/codes-for-line both directions, order-level-codes for fixed).
  • time-windowed promotions — window.sx: windowed promo (promo from until), active-ruleset/active-codes/windowed-quote gate by datetime; feeds the existing promo/stack/quote pipeline unchanged. Determinism preserved.
  • discount-aware tax policy — nettax.sx: cart-quote-net taxes the net (post-discount) base; allocate-discount spreads 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-check precondition (host gates order-begin! on it, keeping the flow pure); reservation-shortfalls detail; effective-available nets out concurrent reservations; sufficient-stocko relational availability query.
  • provider-neutral payment-request envelope — payment.sx: payment-request materialises {:order :amount :currency :return-url} at the IO edge (amount from the ledger, currency/return-url host-supplied); pending-payments enumerates 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-stock reads the catalog stock facts; can-reserve?/reserve-check/ reservation-shortfalls gate a cart; effective-available/line-reservable-with? net out concurrent reservations (no over-reserve); sufficient-stocko is 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-net taxes the NET (post-discount) base. allocate-discount spreads 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-ruleset filters to the plain promos live at at and feeds the existing promo/stack/quote pipeline unchanged (promo.sx untouched); active-promoo/active-codes is the backward "which codes are live at T?" query; windowed-quote is the datetime-aware quote (deterministic in at). 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-request builds the {:order :amount :currency :return-url} envelope; pending-payments is 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 via scm->string before 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-lines is the pure per-promo scope (percent/member → class lines, bundle → sku lines, fixed → order-level/none); promo-toucheso relates (code, line) for applying promos, run forward (lines-for-code) and backward (codes-for-line). order-level-codes lists applying fixed promos; predicate line-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/ mismatcho are miniKanren relations, so "which orders are overpaid?", "settled to net N?" are backward run* 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, deterministic cheapest-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 :ok keyword 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-refund are idempotent via persist/append-once keyed on the payment ref, so a replayed SumUp webhook records once (no double-charge). order-recon-of classifies :unpaid/:ok/:underpaid/:overpaid on net (paidrefunded) vs total; ledger-mismatches finds 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-quote composes 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-stackings enumerates every legal subset of applicable promos (powerset excluded combos); best-stacking is the deterministic max-total-discount selection (stable on ties). stacking-by-totalo is 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-applieso enumerate (code, amount) relationally — forward ("which apply?") and backward ("which code yields 2000?" → run* over applieso). applicable-promos/promo-amount-for deterministic helpers. promo amounts via project to 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} with commerce-add/-remove/-set-qty/-total/-count/ -lines, commerce-can-add? catalog validation, commerce-explain per-line audit breakdown ({:sku :variant :qty :unit :extended :tax}), and a commerce-checkout Phase-3 stub. api suite 12/12; total 66/66.
  • 2026-06-06 — price.sx: deterministic cart-subtotal (Σ unit×qty, variant delta defaults 0) + jurisdiction-relational tax. taxo facts indexed by (jurisdiction, product-class, customer-class)→bps, queried multidirectionally; apply-bps rounds half-up with integer arithmetic only. cart-total returns {: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 ops cart-add (merges same line / appends), cart-set-qty (0 removes), cart-remove, plus cart-qty/cart-count/cart-skus/cart-empty?. cart-lineo is 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, derived priceo/classo/unit-priceo) + deterministic catalog-price/ -class/-has? helpers. conformance.sh harness + scoreboard. catalog suite 16/16. Gotcha: minikanren run-n macro binds s internally — query vars must avoid s; tests compare reified results with = (not equal?, 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-env returns 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 like lib/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)