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

278 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.sh`**297/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
- [x] `catalog.sx` — product/variant/stock as facts
- [x] `cart.sx` — line items, add/remove/qty
- [x] `price.sx` — base pricing relation, subtotal; tax
- [x] `api.sx` + tests + scoreboard + conformance.sh
## Phase 2 — Promotions (relational)
- [x] promo rules: percentage, fixed, bundle, member rate
- [x] explicit stacking precedence; "best price" backward query
- [x] tests: stacking order, mutually-exclusive promos, member vs guest
## Phase 3 — Order lifecycle (flow + store)
- [x] order flow: reserve stock → await payment → fulfil
- [x] payment webhook resumes the suspended flow
- [x] order ledger as a `persist` stream; idempotent reconciliation
## Phase 4 — Reconciliation + federation
- [x] mismatch detection (paid≠ordered) as queries over the ledger
- [x] cross-instance catalog (federated marketplace) — out-of-scope stub
- [x] 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.
- [x] 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).
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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.
- [x] 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/<id>". 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)