A Transaction/Backtracking Pattern, Built From What Already Exists
A design pattern rather than a new language feature: "try a candidate solution, keep the result only if it succeeds, back out cleanly if it fails" — motivated directly by GOAP-found solutions, but generally useful anywhere a program wants to attempt something risky without hand-rolling bespoke error-handling for it. The interesting part isn't a new mechanism at all: PatLang's existing closures and its existing virtual filesystem already provide almost everything needed, and the small amount that didn't exist is a page of ordinary PatLang, not a language change. See the journey page this follows on from for how the underlying GOAP/synthesis work motivated the question in the first place.
Closures already isolate the ordinary-state half of this, for free
PatLang closures capture their free variables as genuine value copies at creation time, not references — confirmed directly in the interpreter (MakeClosure/CallValue, rust-runtime/src/ir/interpreter.rs): captured values are cloned onto the stack when the closure is made, and calling it runs those copies through an ordinary function call with its own local frame. There is no shared mutable cell connecting a closure's insides back to the scope that created it. That means a candidate solution running as a closure body can only affect the outside world through its own return value — "run it, then decide whether to keep what it produced" is already the natural shape of an ordinary closure call plus an if on the result, not something that needs building.
Nesting works too, confirmed directly rather than assumed — a closure returning another closure, three levels deep, each level correctly capturing both its own parameter and whatever the enclosing levels captured, with independently correct state per call (two differently-parameterized outer closures never share state, and a partially-applied closure can be reused correctly more than once):
let make_adder3 = |a| do
let level2 = |b| do
let level3 = |c| do
return a + b + c
end
return level3
end
return level2
end
let add10_20 = make_adder3(10)(20)
print(add10_20(1)) # 31
print(make_adder3(100)(20)(1)) # 121 -- independent, not shared state
print(add10_20(2)) # 32 -- reusable
The one real gap: file state, and the fix that needed no engine changes
The single piece closures don't cover is anything written through the VFS (vfs_read/vfs_write/vfs_list/vfs_delete/vfs_exists — a flat path-to-content map, see rust-runtime/src/ir/hosts.rs). A candidate's file writes take effect immediately and globally regardless of whether the overall attempt is later judged a success. The fix is a small library, built entirely in PatLang on the existing VFS calls, no Rust or host changes:
txn_begin(id)snapshots every live path (skipping other transactions' own backup areas) into a backup namespace, plus a manifest of exactly which paths existed.txn_rollback(id)deletes any path absent from that manifest (created during the failed attempt) and restores every manifested path to its original content, then discards the backup.txn_commit(id)does almost nothing — the live state already is the successful result, so committing just discards the now-unneeded backup.txn_try(id, candidate)ties it together: begin, call the candidate closure, commit or roll back based on its own return value, hand that value back unchanged.
Verified with a real test (friendly_cli/test_vfs_transaction.patlang): a failing candidate that both modifies an existing file and creates a new one has both changes undone by rollback, byte-for-byte; a succeeding candidate doing the identical shape of changes has both kept by commit; an untouched third file is never even touched either way; and no backup-namespace residue is left behind after either outcome.
Running transactions on separate fibers: safe when disjoint, genuinely broken when not
The natural next question — can more than one of these run concurrently on fibers, and if more than one succeeds, what gets kept — was tested directly rather than reasoned about. Two fibers, each running its own transaction, interleaved exactly like the fiber generator demo (alternating fiber_resume calls from the driver):
# Fiber body, deliberately yielding mid-transaction so the driver can
# interleave the other fiber's own begin/write/yield in between.
make a function called txn_fiber_body takes cfg returns result
txn_begin(cfg[0])
vfs_write(cfg[1], cfg[2])
fiber_yield(0)
txn_commit(cfg[0]) # or txn_rollback, depending on the case
return vfs_read(cfg[1])
end
Disjoint files: safe. Two fibers, each touching only its own path, interleaved via manual fiber_resume alternation — both finish with exactly the value each one wrote, no interference at all.
The same shared file: genuinely not safe — demonstrated concretely, not just asserted as a theoretical risk. With both fibers committing to the same path: whichever commits last silently wins, and neither fiber is ever told the other one existed — a classic lost update. Worse, with one fiber committing and the other later rolling back: the fiber that succeeded and committed has its own already-persisted write silently destroyed by the other fiber's rollback restoring an earlier snapshot — and the succeeding fiber's own read-back of "what did I just write" reflects the corrupted value, not what it actually wrote. Real output from the actual test run (friendly_cli/test_vfs_transaction_fibers.patlang):
fiber1 believed it committed: true, saw its own write as: value-from-fiber2
fiber2 believed it committed: true, saw its own write as: value-from-fiber2
ACTUAL final shared.txt: value-from-fiber2 -- whichever committed LAST silently wins
--- one commits, one rolls back ---
fiber2 believed it committed: true, saw its own write as: shared2-original
ACTUAL final shared2.txt: shared2-original
DATA LOSS -- fiber2's own successful, committed write was silently
overwritten by fiber1's rollback
The practical answer to "what do we keep if more than one succeeds": this prototype doesn't decide that for you, and shouldn't try to — the honest recommendation is to give concurrent candidates disjoint path namespaces (e.g. a per-candidate prefix) so they never actually collide during the attempt, then have the calling code explicitly compare the candidates' own returned results afterward and promote whichever one it prefers into the real location as its own separate, deliberate step. Baking an automatic conflict-resolution policy into the transaction mechanism itself is a bigger, separate feature, not attempted here.
Composability with the SQL layer, for free
PatLang's SQL/RDBMS layer (self_hosting/lib/rdbms.patlang) has its own transaction concept (rdb_begin/rdb_commit/rdb_rollback), but it's purely in-memory staged rows — nothing touches the VFS until rdb_commit actually calls vfs_write to persist the table. That means the generic VFS transaction above can wrap around an already-rdb_commit-ted change and still undo it, useful when the SQL write itself succeeded but some other, later check in the same overall attempt fails. Verified directly: insert a row, rdb_commit it (a real, immediate vfs_write), then have the outer, SQL-agnostic transaction roll back — the table correctly reverts to its pre-attempt row count, even though the SQL layer itself had already considered its own part done.
Putting it together: GOAP + transactions + fibers, live
Two worker fibers, each evaluating its own GOAP plan (mine ore, refine an ingot, craft gear) inside its own VFS transaction, interleaved by the main scheduler. Worker w1 starts with enough energy and commits; worker w2 starts with only 20 energy, runs out partway through (the refine step costs 30), and rolls back — with a diagnostic log written after the rollback, so it survives and actually says why the attempt failed, rather than being erased along with everything else the failed attempt touched.
PatLang source
# Self-contained version (no `include`s) for WASM compilation -- matches
# the existing portfolio-demo convention (self_hosting/examples/
# fiber_demo.patlang, robots_demo.patlang), which compiles a single file
# with no includes. Inlines the small helper functions the VFS
# transaction library and the GOAP worker depend on
# (contains_text/split_lines are pure-PatLang helpers normally pulled in
# via self_hosting/lib/test.patlang and synthesis_recursive.patlang;
# everything else here -- sb_new/sb_push/sb_str/vfs_*/fiber_*/str_intern/
# sc_len/sc_code/sc_char/to_num/list_len/list_push -- is a real host
# function needing no include at all).
make a function called gtd_contains_text takes hay, needle returns r
if needle.length > hay.length then
return false
end
let i = 0
while i <= hay.length - needle.length do
if substr(hay, i, needle.length) == needle then
return true
end
let i = i + 1
end
return false
end
make a function called gtd_split_lines takes text returns lines
let h = str_intern(text)
let n = sc_len(h)
let i = 0
let line = sb_new()
let lines = []
while i <= n do
let c = sc_code(h, i)
if (c == 10) or (c == -1) then
let raw = sb_str(line)
let line = sb_new()
if raw != "" then
let lines = list_push(lines, raw)
end
let i = i + 1
else
if c == 13 then
let i = i + 1
else
sb_push(line, sc_char(h, i))
let i = i + 1
end
end
end
return lines
end
# -------------------------------------------------------------
# VFS transaction library (see friendly_cli/lib/vfs_transaction.patlang
# for the full commented version and its own documented limitations --
# threading/fiber overlap is NOT concurrency-safe, facts/rules are not
# covered at all).
# -------------------------------------------------------------
make a function called txn_backup_prefix takes txn_id returns prefix
return "__vfs_txn__/" + txn_id + "/"
end
make a function called txn_manifest_path takes txn_id returns p
return txn_backup_prefix(txn_id) + "__manifest__"
end
make a function called txn_begin takes txn_id returns unused
let all_paths = vfs_list("")
let live_paths = []
let i = 0
while i < to_num(list_len(all_paths)) do
let p = all_paths[i]
if not gtd_contains_text(p, "__vfs_txn__/") then
let live_paths = list_push(live_paths, p)
let content = vfs_read(p)
vfs_write(txn_backup_prefix(txn_id) + p, content)
end
let i = i + 1
end
let manifest_sb = sb_new()
let j = 0
while j < to_num(list_len(live_paths)) do
sb_push(manifest_sb, live_paths[j])
sb_push(manifest_sb, "\n")
let j = j + 1
end
vfs_write(txn_manifest_path(txn_id), sb_str(manifest_sb))
return 0
end
make a function called txn_manifest_paths takes txn_id returns paths
let raw = vfs_read(txn_manifest_path(txn_id))
let lines = gtd_split_lines(raw)
let result = []
let i = 0
while i < to_num(list_len(lines)) do
if lines[i] != "" then
let result = list_push(result, lines[i])
end
let i = i + 1
end
return result
end
make a function called txn_rollback takes txn_id returns unused
let manifest = txn_manifest_paths(txn_id)
let current = vfs_list("")
let ci = 0
while ci < to_num(list_len(current)) do
let p = current[ci]
if not gtd_contains_text(p, "__vfs_txn__/") then
let was_present = false
let mi = 0
while mi < to_num(list_len(manifest)) do
if manifest[mi] == p then
let was_present = true
end
let mi = mi + 1
end
if not was_present then
vfs_delete(p)
end
end
let ci = ci + 1
end
let ri = 0
while ri < to_num(list_len(manifest)) do
let p = manifest[ri]
let backed_up = vfs_read(txn_backup_prefix(txn_id) + p)
vfs_write(p, backed_up)
let ri = ri + 1
end
txn_discard_backup(txn_id)
return 0
end
make a function called txn_commit takes txn_id returns unused
txn_discard_backup(txn_id)
return 0
end
make a function called txn_discard_backup takes txn_id returns unused
let prefix = txn_backup_prefix(txn_id)
let backup_paths = vfs_list(prefix)
let i = 0
while i < to_num(list_len(backup_paths)) do
vfs_delete(backup_paths[i])
let i = i + 1
end
vfs_delete(txn_manifest_path(txn_id))
return 0
end
# -------------------------------------------------------------
# 1. GOAP Planner & Action Definitions
# -------------------------------------------------------------
make a function called goap_evaluate_plan takes state, plan returns success
let i = 0
while i < to_num(list_len(plan)) do
let action = plan[i]
if action == "mine_ore" then
state.has_ore = true
state.energy = state.energy - 20
end
if action == "refine_ingot" then
if state.has_ore == true and state.energy >= 30 then
state.has_ore = false
state.has_ingot = true
state.energy = state.energy - 30
else
return false
end
end
if action == "craft_gear" then
if state.has_ingot == true then
state.has_ingot = false
state.has_gear = true
else
return false
end
end
let i = i + 1
end
return (state.has_gear == true) and (state.energy >= 0)
end
# -------------------------------------------------------------
# 2. Transactional Fiber Worker
# -------------------------------------------------------------
make a function called worker_fiber_body takes cfg returns status
let worker_id = cfg[0]
let state = cfg[1]
let plan = cfg[2]
let prefix = "/workers/" + worker_id + "/"
print("Worker " + worker_id + ": starting GOAP execution fiber...")
fiber_yield("READY")
txn_begin(worker_id)
vfs_write(prefix + "state.txt", "INITIAL_ENERGY:" + ("" + state.energy))
let plan_valid = goap_evaluate_plan(state, plan)
if plan_valid == false then
print("Worker " + worker_id + ": GOAP plan failed validation! Rolling back...")
fiber_yield("PLAN_FAILED")
txn_rollback(worker_id)
# Written AFTER txn_rollback returns -- OUTSIDE the transaction's
# scope, so it survives, unlike a log written inside the
# transaction (which rollback would otherwise erase along with
# everything else the failed attempt touched).
vfs_write(prefix + "log.txt", "FAIL: plan evaluation invalid -- final energy=" + ("" + state.energy) + ", has_ore=" + ("" + state.has_ore) + ", has_ingot=" + ("" + state.has_ingot) + ", has_gear=" + ("" + state.has_gear))
return "ROLLED_BACK"
end
vfs_write(prefix + "inventory/ore.txt", "QTY: 1")
fiber_yield("MINED_ORE")
vfs_write(prefix + "inventory/ingot.txt", "QTY: 1")
vfs_write(prefix + "inventory/ore.txt", "QTY: 0")
fiber_yield("REFINED_INGOT")
vfs_write(prefix + "inventory/gear.txt", "QTY: 1")
vfs_write(prefix + "inventory/ingot.txt", "QTY: 0")
txn_commit(worker_id)
print("Worker " + worker_id + ": transaction committed successfully!")
return "COMMITTED"
end
# -------------------------------------------------------------
# 3. Main Scheduler Interleaving Multiple Fibers
# -------------------------------------------------------------
let w1_state = new("WorkerState", "w1_state")
w1_state.has_ore = false
w1_state.has_ingot = false
w1_state.has_gear = false
w1_state.energy = 100
let w2_state = new("WorkerState", "w2_state")
w2_state.has_ore = false
w2_state.has_ingot = false
w2_state.has_gear = false
w2_state.energy = 20
let f1 = fiber_new("worker_fiber_body")
let f2 = fiber_new("worker_fiber_body")
print(fiber_resume(f1, ["w1", w1_state, ["mine_ore", "refine_ingot", "craft_gear"]]))
print(fiber_resume(f2, ["w2", w2_state, ["mine_ore", "refine_ingot", "craft_gear"]]))
print(fiber_resume(f1, 0))
print(fiber_resume(f2, 0))
print(fiber_resume(f1, 0))
print(fiber_resume(f2, 0))
print(fiber_resume(f1, 0))
print("=== Final check ===")
print("w1 gear.txt exists (expected 1): " + vfs_exists("/workers/w1/inventory/gear.txt"))
print("w2 log.txt exists (expected 1 -- written after rollback, so it survives): " + vfs_exists("/workers/w2/log.txt"))
print("w2 log.txt content: " + vfs_read("/workers/w2/log.txt"))
print("w2 inventory dir entries (expected 0 -- the candidate's own work is still fully undone): " + ("" + to_num(list_len(vfs_list("/workers/w2/inventory/")))))
Interpreted run on the build machine (pat --ir-run), byte-for-byte identical to the natively-compiled and threaded-WASM (wasmtime-verified) runs:
Worker w1: starting GOAP execution fiber... READY Worker w2: starting GOAP execution fiber... READY MINED_ORE Worker w2: GOAP plan failed validation! Rolling back... PLAN_FAILED REFINED_INGOT ROLLED_BACK Worker w1: transaction committed successfully! COMMITTED === Final check === w1 gear.txt exists (expected 1): 1 w2 log.txt exists (expected 1 -- written after rollback, so it survives): 1 w2 log.txt content: FAIL: plan evaluation invalid -- final energy=0, has_ore=true, has_ingot=false, has_gear=false w2 inventory dir entries (expected 0 -- the candidate's own work is still fully undone): 0
Two limitations, named deliberately — a prototype, not a finished feature
- Not concurrency-safe for overlapping paths, demonstrated above. Safe for the motivating case (try one candidate at a time), not for multiple candidates racing over the same files.
- Facts and rules aren't covered at all.
rule_addhas no retract (RULESis accumulate-only) — a candidate that registers facts or GOAP actions during a transaction that later rolls back leaves those registrations in place regardless. Worked around by scope for now, not solved: this pattern covers file state only.
Compiled to wasm32-wasip1-threads and verified running correctly there too (via the wasmtime CLI with threads/shared-memory enabled) — byte-for-byte identical output to the interpreted and native runs above. The live button below runs the actual compiled binary, in this browser, via the same hand-rolled WASI-threads implementation as the fiber demo page (real Workers + SharedArrayBuffer-backed shared memory, no wasm-bindgen, no mock).
Real "run in browser" button below: this compiles to wasm32-wasip1-threads and runs via a hand-rolled implementation of the WASI threads proposal (real Workers + SharedArrayBuffer-backed shared memory + a pre-warmed worker pool standing in for real thread creation) — no wasm-bindgen, no mock. Needs cross-origin isolation (Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy response headers); the button reports plainly if that's unavailable on this page load rather than silently doing nothing.
Live run: two GOAP worker fibers (one commits, one rolls back), compiled to threaded WASM:
See also
PatLang Fibers for the underlying cooperative-coroutine mechanism the concurrency tests above build on. the goal-oriented demo page and Inductive Synthesis for the GOAP/induction work that originally motivated wanting a transaction pattern in the first place. The Journey of Building PatLang, Continued Once More for the fuller narrative this pattern grew out of, including the real native-compiled-fiber dispatch bug found and fixed while verifying it (fibers run on their own OS thread; the dispatch fallback that lets a compiled program call an un-inlined host function is thread-local, and fiber threads weren't inheriting it from whichever thread spawned them).