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_add has no retract (RULES is 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).