commerce: order lifecycle as a durable flow-on-sx flow (21 tests) — Phase 3 done
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 57s

order.sx — reserve -> await-payment -> fulfil as a flow-on-sx flow carrying
only the order-id; the SX driver services each request by appending to the
persist ledger. order-begin! creates+reserves and suspends at payment;
order-settle! (webhook) resumes -> fulfils, idempotent on replay
(:already-settled). order-flow-restart! simulates a process restart Scheme-side
and the suspended order resumes with the ledger intact. Composes all three
substrates: minikanren pricing -> flow lifecycle -> persist ledger.
Total 153/153 across 9 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 09:20:04 +00:00
parent cda35a1ed8
commit 85b288d22b
6 changed files with 224 additions and 9 deletions

View File

@@ -17,7 +17,7 @@ if [ ! -x "$SX_SERVER" ]; then
exit 1
fi
SUITES=(catalog cart price api promo stack quote ledger)
SUITES=(catalog cart price api promo stack quote ledger order)
OUT_JSON="lib/commerce/scoreboard.json"
OUT_MD="lib/commerce/scoreboard.md"
@@ -48,6 +48,17 @@ run_suite() {
(load "lib/persist/log.sx")
(load "lib/persist/kv.sx")
(load "lib/persist/idempotency.sx")
(load "lib/guest/lex.sx")
(load "lib/guest/reflective/env.sx")
(load "lib/guest/reflective/quoting.sx")
(load "lib/scheme/parser.sx")
(load "lib/scheme/eval.sx")
(load "lib/scheme/runtime.sx")
(load "lib/flow/spec.sx")
(load "lib/flow/store.sx")
(load "lib/flow/remote.sx")
(load "lib/flow/host.sx")
(load "lib/flow/api.sx")
(load "lib/commerce/catalog.sx")
(load "lib/commerce/cart.sx")
(load "lib/commerce/price.sx")
@@ -56,6 +67,7 @@ run_suite() {
(load "lib/commerce/stack.sx")
(load "lib/commerce/quote.sx")
(load "lib/commerce/ledger.sx")
(load "lib/commerce/order.sx")
(epoch 2)
(eval "(define ct-pass 0)")
(eval "(define ct-fail 0)")
@@ -69,7 +81,7 @@ run_suite() {
EPOCHS
local OUTPUT
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
OUTPUT=$(timeout 560 "$SX_SERVER" < "$TMP" 2>/dev/null)
rm -f "$TMP"
# The (list ct-pass ct-fail) result follows its (ok-len 2 N) ack line.

119
lib/commerce/order.sx Normal file
View File

@@ -0,0 +1,119 @@
;; lib/commerce/order.sx — order lifecycle as a durable flow-on-sx flow.
;;
;; The lifecycle (reserve -> await payment -> fulfil) is a Scheme flow running
;; in the flow-on-sx guest (lib/flow). The flow is PURE ORCHESTRATION: it
;; carries only the order-id and enforces step ordering + the suspension at the
;; payment IO boundary. All IO/state lives in SX: the SX driver here services
;; each flow request by appending to the persist ledger (ledger.sx).
;;
;; reserve -> SX appends :reserved, resumes (synchronous host effect)
;; payment -> flow stays SUSPENDED until the SumUp webhook resumes it
;; fulfil -> SX appends :fulfilled, resumes (synchronous host effect)
;;
;; Durability: the flow's replay log is plain data (flow-store-export), so a
;; suspended order survives a process restart — order-flow-restart! simulates
;; that entirely Scheme-side. Idempotency: order-settle! only resumes a flow
;; still waiting on payment, so a replayed webhook is a no-op at the flow level,
;; and order-pay is idempotent at the ledger level.
;; The flow definition (Scheme source). oid is in scope throughout the begin.
(define
order-flow-src
"(defflow order-lifecycle (lambda (oid) (begin (request (quote reserve) oid) (request (quote payment) oid) (request (quote fulfil) oid))))")
;; Build a flow env with the order flow registered. Never returns the env from
;; an eval boundary (the env is large/cyclic — serializing it hangs).
(define
order-make-env
(fn
()
(let
((env (flow-make-env)))
(begin (flow-run-in env order-flow-src) env))))
;; --- thin Scheme bridge (string-interpolated flow ops) ---
(define
order-flow-start
(fn
(env oid)
(flow-run-in env (str "(flow/start order-lifecycle \"" oid "\")"))))
(define
order-flow-resume
(fn
(env id sym)
(flow-run-in env (str "(flow/resume " id " (quote " sym "))"))))
(define
order-flow-status
(fn (env id) (flow-run-in env (str "(flow/status " id ")"))))
(define
order-flow-result
(fn (env id) (flow-run-in env (str "(flow/result " id ")"))))
;; The request kind the flow with this id is waiting on, or nil if it is not
;; suspended on a host request (done / cancelled / unknown).
(define
order-flow-waiting
(fn
(env id)
(let
((reqs (flow-run-in env "(flow-host-requests)")))
(let
((mine (filter (fn (r) (= (first r) id)) reqs)))
(if (empty? mine) nil (nth (first mine) 1))))))
;; Id out of a (flow-suspended id tag) start/resume result.
(define order-susp-id (fn (susp) (nth susp 1)))
;; --- high-level lifecycle (flow + ledger composed) ---
;; Create the order, start the flow, service the reserve step, and leave the
;; flow suspended at payment. Returns the flow id (needed to settle later).
(define
order-begin!
(fn
(env b oid at quote)
(begin
(order-create b oid at quote)
(let
((id (order-susp-id (order-flow-start env oid))))
(begin
(order-reserve b oid (+ at 1) {})
(order-flow-resume env id :reserved)
id)))))
;; Settle a payment: record it, resume the flow past payment, service fulfil.
;; Idempotent — only acts when the flow is still waiting on payment, so a
;; replayed webhook returns :already-settled without double-charging.
(define
order-settle!
(fn
(env b id oid ref at amount)
(if
(= (order-flow-waiting env id) "payment")
(begin
(order-pay b oid ref at amount)
(order-flow-resume env id :paid)
(order-fulfil b oid (+ at 1) {})
(order-flow-resume env id :fulfilled)
:settled)
:already-settled)))
;; Simulate a process restart: export the flow store, reset the runtime, reload
;; the flow definition, reimport the store. Done entirely Scheme-side so the
;; (large) store is never marshalled across the boundary. The persist ledger is
;; a separate store and is unaffected. Suspended flows resume afterwards.
(define
order-flow-restart!
(fn
(env)
(flow-run-in
env
(str
"(begin (define _saved (flow-store-export)) "
flow-reset-src
" "
order-flow-src
" (flow-store-import! _saved) #t)"))))

View File

@@ -7,9 +7,10 @@
"promo": {"pass": 17, "fail": 0},
"stack": {"pass": 16, "fail": 0},
"quote": {"pass": 13, "fail": 0},
"ledger": {"pass": 20, "fail": 0}
"ledger": {"pass": 20, "fail": 0},
"order": {"pass": 21, "fail": 0}
},
"total_pass": 132,
"total_pass": 153,
"total_fail": 0,
"total": 132
"total": 153
}

View File

@@ -12,4 +12,5 @@ _Generated by `lib/commerce/conformance.sh`_
| stack | 16 | 0 | 16 |
| quote | 13 | 0 | 13 |
| ledger | 20 | 0 | 20 |
| **Total** | **132** | **0** | **132** |
| order | 21 | 0 | 21 |
| **Total** | **153** | **0** | **153** |

View File

@@ -0,0 +1,67 @@
;; lib/commerce/tests/order.sx — order lifecycle as a flow-on-sx flow.
;; Uses (commerce-test name got expected) provided by conformance.sh.
;; Builds the (expensive) flow env once; all assertions share it.
(define env (order-make-env))
(define b (persist/mem-backend))
(define q1 {:codes (list) :subtotal 1000 :discount 0 :total 1200 :tax 200})
;; --- happy path: begin suspends at payment ---
(define id1 (order-begin! env b "O1" 100 q1))
(commerce-test "begin-status-reserved" (order-status b "O1") :reserved)
(commerce-test "begin-waiting-payment" (order-flow-waiting env id1) "payment")
(commerce-test "begin-not-yet-paid" (order-paid b "O1") 0)
;; --- settle: payment webhook drives fulfilment ---
(define s1 (order-settle! env b id1 "O1" "ref-1" 102 1200))
(commerce-test "settle-result" s1 :settled)
(commerce-test "settle-status-fulfilled" (order-status b "O1") :fulfilled)
(commerce-test "settle-flow-done" (order-flow-status env id1) "done")
(commerce-test "settle-recon-ok" (order-recon b "O1") :ok)
(commerce-test "settle-event-count" (len (order-events b "O1")) 4)
;; --- webhook replay: a second settle is a no-op ---
(define s1b (order-settle! env b id1 "O1" "ref-1" 102 1200))
(commerce-test "replay-already-settled" s1b :already-settled)
(commerce-test
"replay-no-extra-events"
(len (order-events b "O1"))
4)
(commerce-test "replay-recon-still-ok" (order-recon b "O1") :ok)
;; --- a second order gets its own flow id and suspends independently ---
(define id2 (order-begin! env b "O2" 200 q1))
(commerce-test "second-distinct-id" (not (= id1 id2)) true)
(commerce-test
"second-waiting-payment"
(order-flow-waiting env id2)
"payment")
(commerce-test "first-unaffected" (order-status b "O1") :fulfilled)
;; --- durability: a suspended order survives a process restart ---
(define id3 (order-begin! env b "O3" 300 q1))
(commerce-test "pre-restart-waiting" (order-flow-waiting env id3) "payment")
(define _restart (order-flow-restart! env))
(commerce-test
"post-restart-still-waiting"
(order-flow-waiting env id3)
"payment")
(commerce-test "post-restart-ledger-intact" (order-status b "O3") :reserved)
(define s3 (order-settle! env b id3 "O3" "ref-3" 302 1200))
(commerce-test "post-restart-settled" s3 :settled)
(commerce-test "post-restart-status" (order-status b "O3") :fulfilled)
(commerce-test "post-restart-recon-ok" (order-recon b "O3") :ok)
(commerce-test "post-restart-flow-done" (order-flow-status env id3) "done")

View File

@@ -21,7 +21,7 @@ reconciliation — all auditable via the event log.
## Status (rolling)
`bash lib/commerce/conformance.sh`**132/132** (8 suites: catalog, cart, price, api, promo, stack, quote, ledger) — Phases 1-2 done; Phase 3 ledger done
`bash lib/commerce/conformance.sh`**153/153** (9 suites: catalog, cart, price, api, promo, stack, quote, ledger, order) — Phases 1-3 done
## Ground rules
@@ -66,8 +66,8 @@ lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout)
- [x] 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
- [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
@@ -76,6 +76,21 @@ lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout)
- [ ] tests: webhook replay, partial refund, double-charge guard
## Progress log
- 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`/