Patrick Lidstone
Self-hosted

FlowBASIC

A BASIC-lineage language for workflow and back-office automation: exact numbers with no floating point anywhere, records that are durable without a database connection, and jobs that suspend at a WAIT and resume months later from the next instruction. It compiles to relocatable p-code and passes 238 of 241 conformance tests on that path. The machine it was written to run on is FlowMachine, which is a separate piece of work described separately.

What it is for

The target is the work that gets done in a spreadsheet, a shared drive and somebody's inbox: approvals, receipts, invoice chasing, a nightly report, a calendar feed, a queue of things waiting on a human. Not scripting, and not application development.

Six design goals, from the spec:

  1. Reads like BASIC. Line-oriented, keyword-heavy, DIM ... AS, FOR/NEXT, SUB, no braces, no semicolons, case-insensitive.
  2. Strongly typed, except strings. Numbers, money, dates and booleans are strict, with no silent lossy coercion between them. STRING is the deliberate weak-typed escape hatch, because clerical data arrives as text and pretending otherwise just moves the problem.
  3. Money and dates are first-class, exact, and never floats.
  4. Records are durable by default. You never "open a database".
  5. Programs are started by the world, not by main(). The top-level unit is a PROCESS, and processes are launched by TRIGGERs: a file lands, an email arrives, a webhook fires, a schedule ticks, a record changes.
  6. Auditable. LOG is a keyword, not a library call.

Under the BASIC surface the model is Erlang's. Values are immutable and names are rebindable, so x = x + 1 computes a new value and rebinds x rather than mutating anything. Processes are share-nothing and communicate by message. Failure is handled by supervision rather than by defensive code in every handler.

Exact numbers, no floats

There is no binary floating-point type in the language at all. INTEGER is 64-bit signed; DECIMAL is 128-bit fixed point with four decimal places, for quantities and rates; CURRENCY is fixed point with four guard digits and carries an ISO currency code, so adding GBP to EUR is a type error rather than a number. DATETIME stores the UTC instant plus the original zone, and the original zone is recoverable rather than display sugar.

INTEGER / INTEGER yields an exact DECIMAL. The reasoning is narrow, and it is most of the reason the language exists: a program whose job is to reconcile a ledger has no business with a type that cannot represent 0.1.

The FlowBASIC browser IDE

The cost is stated rather than hidden. A language with no floats cannot do signal processing, geometry or anything scientific, and it is not going to. BYTE/WORD/DWORD/QWORD exist with declared endianness for binary protocol work, which is the one place raw machine words are needed.

The editor in the screenshot is editor.html plus a 200-line server: syntax highlighting, autocomplete, and a switch between the tree-walking engine and the p-code VM so the same program can be run both ways and the output compared. No build step and no node_modules.

Durable records

A RECORD is a STRUCT with durable identity. There is no connection to open, no session, no migration step and no ORM. Persistence is defined logically - identity, uniqueness, references, atomic work - so the model is backend-independent: the host interpreter's reference backend is embedded SQLite, and on FlowMachine the same SAVE/FIND/UPDATE/DELETE land in that kernel's own object store.

The runtime model is shadow write-back. The in-VM store is a working copy; SAVE/UPDATE/DELETE mutate the shadow and mark the type dirty, FIND reads through, and dirty types flush at a run boundary or at an explicit transaction barrier. WITH TRANSACTION is a synchronisation point rather than a lock held across user code.

The thing this buys is not convenience. It is that a program's durable state and its live state are the same values, described once, which is what makes the next section possible.

Jobs that dehydrate

A job is p-code plus state plus a program counter. At a WAIT - for a task, an approval, another job, an event, a form or a timer - the runtime serialises the job's entire live state, including its call stack and program counter, and sets it aside. The worker is freed. When the awaited thing happens the job is rehydrated and continues from the instruction after the WAIT. There is no replay, so a job may sit dehydrated for milliseconds or for months at the same cost.

This is deliberately not the event-sourcing model. Replay rebuilds state by re-running deterministic code, which forces every line between activities to be pure and reproducible. Snapshotting stores the state directly, so user code carries no determinism obligation at all: x = x + 1, calling NOW(), reading a record are all fine and none of them has to replay the same. For a language whose point is approachability, that is the right trade.

Two things make it work, and they shape everything else:

  • The p-code and its saved state are fully relocatable. The program counter and every in-state code reference are symbolic or offset-based, never native addresses, so a snapshot taken on one worker rehydrates on another. Which bytecode a snapshot binds to is fixed by version pinning: a job rehydrates against the exact package it was born under.
  • The live heap is pointer-free. The only cross-reference in live state is a record key, so a snapshot is a copy of a flat value tree with nothing to chase. The same graph is what one process sends another as a message, so there is one serialisation for both.

The consequence is a hard rule: no un-serialisable handle may survive a WAIT. An open socket, a live cursor or an HTTP connection belongs to an activity that acquires and releases it within a single run step. That is why a query returns a snapshot rather than a live cursor, and why connections are pooled by the runtime instead of being user variables.

It also has a footgun which took a while to appreciate on real data. The snapshot captures the VM's live locals, so a handler that receives a 5 MB email and then loops back to its WAIT with that email still bound to a variable writes a 5 MB snapshot every time round. The fix is to drop the reference before the loop closes, and the fix has to be a plain = 0 rather than clearing a field, because clearing a field allocates a copy first. The comment explaining that is fourteen lines long in psionmail.flow, which is about right for the amount of grief it caused.

A worked example: expense claims

The clearest way to show what the language is for is a real one. apps/receipts.flow is an expense workflow: accounts raise a requirement against a member of staff, the member of staff submits the receipt, accounts either reconcile it or query it, and a queried receipt goes back round. It is 60 lines, and it runs on the live node.

The data. Two enumerations and a record. The status is an ENUM, so a typo in a state name is a compile error rather than a row that never matches anything. amount is CURRENCY, which is exact and carries its currency code.

ENUM ReceiptStatus draft, awaiting_submission, submitted, queried, reconciled
ENUM ReviewDecision reconciled, queried

RECORD receipt
  id AS KEY
  vendor AS STRING
  assignee AS STRING
  amount AS CURRENCY
  status AS ReceiptStatus
END RECORD

That is the whole persistence layer. No schema file, no migration, no connection string.

The endpoint. A process that waits on an HTTP route, validates its parameters, saves a record and spawns one workflow instance per receipt.

PROCESS receipts
  WHILE TRUE
    DIM req = WAIT FOR http WHERE "/receipts/new"
    IF NOT req.params.Has("vendor") OR NOT req.params.Has("who") THEN
      PRINT "usage: /receipts/new?vendor=<name>&who=<assignee>"
    ELSE
      DIM r AS receipt
      r.vendor = req.params["vendor"]
      r.assignee = req.params["who"]
      r.amount = @0.00
      r.status = ReceiptStatus.awaiting_submission
      SAVE r
      SPAWN receipt_flow(r.id)
    END IF
  WEND
END PROCESS

The workflow. The part that would normally be a state machine table, a scheduler, a set of reminder cron jobs and a fair amount of glue. Here it is a procedure that happens to take days:

PROCESS receipt_flow(rid AS INTEGER)
  DIM r = FIND FIRST receipt WHERE id = rid

  DIM sub = WAIT FOR TASK "Submit receipt: " & r.vendor _
      ASSIGN TO USER r.assignee _
      REGARDING rid _
      OUTCOMES (submitted)

  WHILE TRUE
    r = FIND FIRST receipt WHERE id = rid
    UPDATE receipt SET status = ReceiptStatus.submitted WHERE id = rid
    DIM dec = WAIT FOR TASK "Reconcile: " & r.vendor _
        ASSIGN TO ROLE accounts _
        REGARDING rid _
        OUTCOMES ReviewDecision

    UPDATE receipt SET status = dec.outcome WHERE id = rid
    IF dec.outcome = ReviewDecision.reconciled THEN
      EMIT documents WHERE "receipt-" & rid & ".txt" WITH "receipt " & rid & " reconciled by " & dec.by
      BREAK
    END IF

    r = FIND FIRST receipt WHERE id = rid
    DIM resub = WAIT FOR TASK "Resubmit (queried): " & r.vendor _
        ASSIGN TO USER r.assignee _
        REGARDING rid _
        OUTCOMES (submitted)
  WEND
END PROCESS

Reading it as a sequence is the point. Each WAIT FOR TASK mints a task assigned to a named user or a role, with a subject line, an optional due date, and a typed set of outcomes; the instance then dehydrates into the store. When somebody completes the task from the inbox, the API or the console, that instance rehydrates and carries on at the next line with dec.outcome and dec.by filled in. The WHILE loop is the query-and-resubmit cycle, and it is a loop rather than a diagram.

One discipline the example teaches. receipt_flow takes the record's key, not the record. It re-runs FIND FIRST receipt WHERE id = rid before every human step, because a human step takes days and the receipt may be edited or reassigned while it is waiting. A spawn-time copy would be stale by the time anyone looked at it. Passing keys rather than values across a WAIT is the general rule and this is where it bites first.

What you did not have to write. No task table, no reminder scheduler, no state column updated by hand from three places, no correlation id, no resume logic, no serialisation format for in-flight work, no crash recovery, and no cron. A receipt raised before a reboot is still waiting for its person afterwards, at the same line, because the instance is a record in the same store as the receipt itself.

What it does not do. There is no user interface here beyond the task list and the HTTP endpoint; no expense policy engine, no receipt OCR, no accounting integration and no currency conversion. The example is 60 lines because it is the workflow and nothing else.

Two engines, one source

interp/ contains a tree-walking interpreter for the full language and a stack-based p-code compiler and VM. Both are in one crate that builds for the host with std and for a bare-metal kernel without it, from the same source. The tree-walker is the reference for language behaviour; the p-code VM is what actually runs in production, so where they disagree the p-code VM is the bug.

Conformance, and three failures

The score that matters is the p-code run, because that is what executes on the machine: 241 tests selected from a 244-test suite, 238 pass, 3 fail. Measured on 3 August 2026 against commit 4df60ca, under both UTC and Europe/London.

The three are known and named. Two contradict the spec as written (op_010, abs_007) and one is a name-resolution case (negc_021). They are not fixed by weakening other tests, and the standing rule is that the number never goes backwards. A conformance suite that only ever reports green is not measuring anything.

A fourth, datetime_002, was on that list until recently and is not any more. The repository's own notes still say four failures, which is a reminder that a figure written down by hand goes stale the moment it is written down.

What is not built

  • A standard library of any breadth. Strings, collections, money, dates, JSON and the built-in connectors are covered. Anything else is not there.
  • A debugger. There is LOG, a p-code disassembler and an op-count profiler on the host build.
  • Any float, ever. Stated above, repeated here because people ask.
  • A second implementation. One interpreter, one VM, one author.

Where the code lives

The repository is basic_alike, private and unpublished. The language part of it:

  • SPEC.md - the specification, with grammar.ebnf alongside it.
  • interp/ - both engines, the record codec and the host CLI.
  • flowbasic-language-tests-ext/ - the 244-test conformance suite.
  • apps/ - working FlowBASIC applications, of which psionmail.flow is the largest at 959 lines.
  • editor.html, serve.js - the browser IDE.

The kernel it was built to run on is described separately, in FlowMachine.