Language architecture
The chain so far: philosophy.md explains why Flow exists, and foundations.md states the invariants. This document chooses the language mechanisms that satisfy them and draws the line between the language and the application that embeds it. Exact rules live in spec/, one surface per file; runtime/architecture.md maps the language onto an implementation and owns the ledger of what is not yet implemented.
Everything here is target normative design. Each section names the mechanism, says why it is the one chosen, and links to the specification that owns its exact rules. Where this overview and a specification seem to differ in detail, the specification states the rule; where they differ in substance, this document owns the decision until the specification is repaired.
Flow is a small, typed language for one bounded orchestration. A program computes deterministically from its input, and the only way it touches the world is a Tool call: a call to a function whose body is outside Flow. Every Tool call and every reply is recorded, together with the few runtime choices computation cannot derive, so a run can be paused, resumed in a new process, replayed without calling anything, and inspected step by step.
Closed evaluation
A run starts at an entry function with typed input and ends with a typed result. Between the two, the program's source is the whole control path: values, functions, branches, recursion, tasks and Tool calls. Nothing inside a run can reach a host-language library, native code, a foreign function interface, reflection, code loading, or an ambient environment read.
Time, randomness, files, network, configuration and people are outside influences like any other. Each arrives as entry input or through a Tool call; the standard library has no clock of its own, and reading the time or sleeping is a recorded Tool call. A program never reads its own history, its position, whether it is live or replaying, or which implementation answers its calls.
Values are immutable and there is no reassignment, mutable reference or global state. State moves forward by passing a new value to the next call, so at any tail call the function and its arguments are everything the future depends on. That fact is what makes checkpoints possible without snapshotting a process.
Evaluation order is fixed: left to right, in source order, everywhere. Every call in tail position is guaranteed not to grow the stack, so loops are recursion and a long-running program is a tail-recursive function. The meaning of functions, control forms, patterns and evaluation order is owned by semantics.md; source text by grammar.md.
Serves foundations: Flow owns a closed evaluation model, visible outside influence, and deterministic reconstruction.
Tools are functions whose bodies are outside Flow
A function has one of three kinds of body: Flow source, the compiler, or the outside world. The last is a Tool, declared with a full signature and no body:
@tool pub flow chat(prompt: Text) -> Result<Text, ChatError>
@tool pub type Page
@tool pub flow open(url: Text) -> Result<Page, BrowseError>
@tool pub flow click(page: Page, selector: Text) -> Result<Unit, PageError>Open in playground →The host supplies the implementation, the same way the compiler supplies the
bodies std marks @lang. Calling a @tool function is a Tool call; it is
otherwise an ordinary function value that can be passed, stored and mapped
over. Tools are declared in modules and imported like any other declaration,
so the compiler can list every Tool a program can reach before the run starts.
@tool type declares an opaque type only the host can make: a browser page, a
sandbox session, a model with its settings. A program holds, stores and passes
such a value but can neither build one nor look inside it, and history records
it as the host's token. This is how stateful things stay typed, and how a
program chooses among several implementations of one operation: the host hands
it a primary and a backup Model, and the program picks one by value.
A model choosing what to do next is an ordinary reply with an ordinary type: an enum of the actions the program allows, matched like any other value. There is no untyped invocation by name and no reflection over Tools. A host that offers tools the program cannot know in advance passes structured JSON values through one generic call.
Exactly what may cross a Tool boundary, how replies are decoded, generic Tools, and a Flow run as a Tool are owned by tools.md. How an implementation is linked, described and spoken to is owned by runtime/spec/abi.md.
Serves foundations: Tools are declarations, not implementations and typed, composable crossings.
One effect: !tool
Every expression answers two questions: what shape of value it produces, and
whether it may reach outside the run. The second has one answer. A function
either is pure or is marked !tool, which it is when it makes a Tool call or
calls something that does. No mark means pure, and the compiler checks it:
flow describe(item: Item) -> View = render(item)
flow read_batch(refs: List<Text>) -> Batch !tool =
refs.map(flow(r) = catalog.read(r))Open in playground →Every top-level function writes its parameter types, result type and effect in
full, so its contract reads without its body and a mistake is reported where it
is made. Local and anonymous functions and let bindings are inferred. A
callback's effect passes through to the call that receives it, so effect
variables are never written; a parameter that must not reach outside says
!pure.
There is no finer effect. Which Tools a function reaches is already known to the compiler and shown by tooling; permission, cost and budget belong to the host. Timing is not an effect: choosing the first of several finished tasks is recorded in history, so replay stays exact without a second mark.
The exact rules are owned by effects.md.
Serves foundations: visible outside influence and typed, composable crossings.
Data, types, and decoding
Types are nominal and compact: records, enums, tuples, generic parameters and
transparent aliases, with no subtyping. Everything is private by default;
pub exposes a record type or a field, a pub enum exposes all its
constructors, and an opaque type exposes none of its construction. Types are
owned by types.md; the built-in value types (one
mathematical Int, exact Decimal, code-point Text, Bytes, List, Dict,
Set, Option, Duration, Instant) by data.md.
One category does the work that traits, derivations and constraint syntax do
elsewhere. A type is data when it is built from data or declared as data in
std; functions, tasks and @tool type values are not. Every data type gets
equality, ordering, text and a schema automatically, and "is data" is the only
requirement a type variable ever has. It is inferred for generic code and
checked at the call.
The same category draws the boundary. Tool arguments, Tool replies, a run's
input and its result are data, plus host tokens going back to their host;
functions and tasks never cross. Values cross in one canonical encoding, and
every reply is checked against the type its call expects: admission certifies
that Flow can interpret the value, not that it is true. Decoding can never
build an opaque type, so a module's smart constructor cannot be bypassed by a
Tool reply, a decoded string or a run's input. There is no dynamic type;
runtime-shaped data is typed through a generic Tool or carried as an ordinary
std enum.
Serves foundations: typed, composable crossings and bounded trust and disclosure.
Failure is data
A Tool call returns Result<T, ToolProblem<E>> for a Tool declared to return
Result<T, E>. An error is an ordinary value: it does not leave the function,
stop a traversal, or propagate on its own. The problem says which of four
things happened: the Tool reported its declared error, a reply arrived that
did not match the declared type, the call definitely did not happen, or it may
or may not have happened. A program can tell a safe retry from a call that may
have reached the world. The host's reason is carried as text; the language
does not classify it.
? returns early from a function that returns a Result, visibly at each use,
and x ? f converts the error through an ordinary function. Discarding is
governed by three general rules that know nothing about Result: a statement
must be Unit and dropping a value is written let _ =, unused bindings are
errors, and matches are exhaustive. Using a result means facing its error; not
using it is visible in the source; and history keeps every outcome either way.
A program cannot raise a fault. There is no fail, panic or unwrap, so an
impossible state is an error variant like any other failure. Faults exist only
for the few operations no sensible answer exists for, and a fault is not
caught: it ends the run as faulted with its location recorded. A nested run
that faults returns to its caller as an ordinary value.
The four Tool problems, ?, and the fault sources are owned by
errors.md; the discard rules and exhaustiveness by
checking.md.
Serves foundations: honest uncertainty and a general core, bounded in scope.
Tasks belong to function calls
Concurrency is four ordinary std functions over a Task value: spawn,
await, stop and first. There is no concurrency grammar. A task belongs to
the function call that started it, and when that call returns anything it
started that is still running is stopped. A tail call continues the same
call, so a loop keeps its tasks alive from round to round; returning a value
that contains a task hands it to the caller. Reading a function tells you what
can still be running after it returns.
Stopping is per task, so one observation can stop two children while a third
continues. Waiting on different kinds of work tags each task's result with a
constructor and branches with match, which keeps match the one way to
branch on a value. Stopping is a request: it undoes nothing already
dispatched, and calls left without a reply are closed as unresolved rather
than as success or failure. Timeouts are Tool calls through std's clock
module, so a failed clock is never mistaken for a deadline.
Every ready task eventually runs; nothing more is promised about order. When
more than one finished task could answer first, the runtime picks one and
records the choice, and replay follows the record rather than reproducing an
interleaving. A losing child's Tool calls and replies are recorded like any
other; its pure work is recomputed.
A Task cannot receive anything: tasks share no state and exchange nothing
but their results. A long-lived loop is a program parked on an ordinary Tool
call, and anything it is sent is that call's reply. The operations, ownership,
task states and faults are owned by concurrency.md.
Serves foundations: concurrency can select an outcome and deterministic reconstruction.
History: only what cannot be computed
History is part of running, not logging beside it. It records what the program could not compute itself and nothing else: the run's start (program identity, versions, input), each Tool call before it is sent, each admitted reply before the program sees it, each choice among finished tasks, each stop and how its open calls were closed, checkpoints, and the end. Branches, bindings, spawns and return values are recomputed. Ids come from position, a task path and a per-task call counter, never from clocks or randomness, so re-running the same program over the same history gives every call the same id.
A pause is a call with no reply yet. Waiting for a person or a slow service is an ordinary Tool call; resuming delivers that call's reply, named by its id. There is no second channel through which anyone answers a run.
Checkpoints keep runs small. Because values are immutable and there is no global state, at a tail call to a top-level function in the root task the stack is empty and the called function and its arguments are the whole state of the run. The runtime may record that as a checkpoint whenever it chooses; replay and resume start from the latest one, and earlier facts can be archived. The arguments may hold data, host tokens, and tasks parked on their only Tool call. Where a checkpoint can be taken is visible to the author, because the checker reports it. A runtime's own stack snapshot is a cache with no meaning, rebuilt from history whenever needed.
Replay re-runs the program and serves recorded replies. It installs no implementation and dispatches nothing. It reports that the run matched, that history is incomplete (it ends early or reaches a hole a host left in place of a withheld value), or that it diverged at a named fact. Missing history never becomes a fresh call.
Uncertainty stays recorded. If a process dies while a call is out, the host decides that call; by default it records the call as unresolved, and it may re-send only under the same call id. Retries inside one call belong to the host and are invisible to history; a retry the program writes is a new call.
A run stays on its program. It always continues on the exact program it started with, whose module sources are stored by content hash. A new version of a program is used by new runs, and a long-lived run moves to one by forking from a checkpoint into a new run that records its parent. Facts are hash-chained, so alteration is detectable against a head hash held elsewhere, and nothing more is claimed.
What history records, ids, pauses, checkpoints, replay verdicts, forks, holes and integrity are owned by history.md; the encoding by runtime/spec/trace.md.
Serves foundations: history carries non-derivable meaning, replay and resume read one history, semantic execution history, recorded before its consequence, replay without dispatch, crash-resumable execution, honest uncertainty, and honest withholding.
Modules and versions
One authored file is a runnable program. Its imports carry its dependencies,
so there is no manifest and no per-program project setup, and a file means the
same run standalone, embedded in a host, or submitted as source by another
program. A file is a module. use "path" is the only import form, and every
imported name is used qualified, so each use shows where its name comes from.
Versions live on the repository, the unit that gets released:
use "github.com/acme/tools@v1.2/catalog". The first resolution pins the
commit a tag named, and a tag that later moves is refused rather than silently
followed. A program uses one version of each repository, because two versions
would give two incompatible copies of every type in it.
The standard library is the language version. std is Flow source in a
versioned repository, and the compiler-implemented parts are declared there,
so std@v1 means Flow v1 and a program's language version is read from its std
imports. The other version is the data version, shared by the value
encoding, the Tool protocol and the history format; it is recorded in every
history and exchanged with every Tool implementation. A small prelude exports
the built-in types, Result, Option and ToolProblem; everything else is
imported.
Program identity is a hash of the parsed modules, the resolved commits and the std version, so reformatting changes nothing and a change in meaning changes the identity.
Imports, resolution, pinning, the prelude, cycles and submitted bundles are owned by modules.md; the std module list and its rules by stdlib.md.
Serves foundations: stable meaning.
Few general rules; names live in std
The compiler knows mechanisms and categories, never particular names.
Everything it treats specially is declared in std source and marked
@lang(...), where it can be read and navigated:
- a type or function with no body is implemented outside Flow: by the compiler
where std marks it
@lang, by the host where a program marks it@tool; - a bodyless type is data only when std declares it so;
- built-in types are declared in their home module, which is where method syntax finds their functions;
- an operator is a function declared for its operand type, and each literal form builds the type std marks for it;
- a Tool call's result is wrapped in the types std marks as the result and the
Tool problem, and
?works on the marked result type; - a missing external field is allowed only for the type std marks optional;
- names the prelude exports cannot be declared again.
Adding, renaming or removing an entry in any of these lists is a std change,
not a new compiler rule. A rule that exists for one type's name is a defect.
The language has four annotations and no user-defined ones: @lang (std only),
@tool, @test and @fake.
The same principle shapes what is left out. Equality, ordering and text come from the data category rather than traits; custom behavior is passed as a function; a stream is repeated ordinary calls; a cleanup step is ordinary code after a stop. A new mechanism enters only with a program the existing ones cannot express.
Serves foundations: a general core, bounded in scope.
Diagnostics for the author who repairs
Agents and people both write Flow, at design time or at runtime, and the language is designed so either can write, repair and review it. An author repairing a program reads compiler output often, and strictness is worth its cost only if the errors lead somewhere.
- Errors only. Anything worth reporting blocks the program; there are no warnings to learn to ignore.
- Diagnostics are data, rendered to text, with stable named codes.
- Fixes are exact text edits, each marked safe to apply without asking or needing a choice.
- Every independent error is reported in one pass, and each names the offending type or value and the other place that matters.
A checked program has been proved well-typed, effect-correct, exhaustive and free of silent discards before it runs; a program that fails checking is refused and never starts. What checking proves is owned by checking.md; the diagnostic shape and codes by runtime/spec/diagnostics.md.
Serves the philosophy's rule: count the whole effort.
The application boundary
Flow evaluates one bounded orchestration. A run owns deterministic evaluation, each typed Tool call and the admission of its reply, scheduling and arbitration that steer meaning, semantic history, replay without dispatch, and pause and resume through ordinary calls. It does not become the surrounding application, and the application does not need to understand Flow's evaluator to embed it.
The host and the application own everything around a run:
- which implementation backs each Tool, and its credentials, startup, transport, isolation, lifecycle and retries;
- policy, approvals, budgets and sandboxing, enforced around dispatch;
- model loops, sessions, subagents, prompts, goals and product state;
- storage, retention, access, redaction and anchoring of history;
- recovery beyond the run, and system-wide telemetry.
The embedding shape is:
application or host
binds an implementation to every @tool declaration a run can reach
stores history; may intercept, refuse, or wrap any dispatch
|
v
checked Flow program -> deterministic evaluation -> semantic history
|
v
Tool implementations, reached only through typed callsEvery Tool a run can reach is bound before it starts, keyed by the declaration's identity; a missing binding refuses the run. Replay binds nothing. A resumed run may be bound to different implementations; that is the host's record, not history. A module repository may say how to run its own Tools, and the host may override any binding: a test binds a fake, a company routes a call through a proxy, an embedding registers implementations in code.
A value the application supplies becomes program data only by arriving as entry input or as a Tool reply, and there it carries no authority from the system that produced it. When a program must branch on a decision such as an approval, the decision is a Tool call, so its answer is typed and recorded. When a decision only governs whether the host dispatches at all, it stays in host policy, and a refusal reaches the program as an ordinary Tool problem. Progress beyond the live history feed is an ordinary Tool call.
Each claim is stated by its owner. Flow explains the orchestration it evaluated and observed. The host explains which implementation was bound, what it enforced, and how history is stored. The application explains people, sessions, policy and product actions. An application may link its records to a run and its call ids; that adds evidence beside the run, not meaning to it.
Serves foundations: applications remain applications and bounded trust and disclosure.
Language, runtime, and host
Three layers meet at the Tool boundary and are specified separately.
- The language owns evaluation, Tool-call meaning, the choices that steer a run, semantic history, replay and resume. The language specifications fix it.
- The runtime realizes the language: value encoding, the Tool protocol, the history format, running and its states, and diagnostics as data. The runtime architecture and its specifications own those; a different evaluator conforms if it preserves the same observable behavior.
- The host binds implementations, stores history and decides topology. A binding may be an in-process function, a subprocess, a shared service or a remote endpoint; Flow checks that its signature fits and prescribes nothing else.
Non-goals
These may matter to a product or deployment, but they are not Flow language mechanisms and no specification may introduce them:
- discovery of implementations and the choice of which one backs a Tool, with its credentials, transport and lifecycle; a program may still choose among the host values it was handed;
- filesystem, network, process, model or container policy, and any authority, capability or confinement vocabulary;
- approvals, budgets, revocation, automatic retries, schedules and operator controls;
- application sessions, durable application state and recovery outside the run;
- confidentiality labels, history retention, encryption and redaction guarantees;
- exactly-once effects, provider truthfulness or party authentication.
Applications, hosts, libraries and deployments may provide any of these. Their guarantees name the system that enforces them; a Tool signature or a Flow history does not turn them into language guarantees.
Considered and set aside
A framework over a general-purpose language. It cannot make the other routes to the world absent, and it gives an author many ways to write one program. A restricted surface that enforced the same closure would be another implementation of Flow.
Tool values and protocol slots. A first-class Tool<P> value installed
into slots by the host added a second kind of function and a binding identity
the program could not see. A function with an outside body, and opaque host
types for instances, cover the same programs with one mechanism.
Finer or written effects. Effects per Tool, a separate effect for timing, and written effect variables each added syntax that the programs Flow was designed against did not need.
Exceptions and raised faults. A second, non-local failure path would let
code skip the error a reply carries. Failure as data, ? visible at each use,
and faults only where no answer exists keep every failure in view.
Tracking that every result is used. Flow shares values rather than owning them, so tracking use across copies meant rebuilding ownership through alias analysis. Three local discard rules give visible handling, and history keeps every outcome regardless.
Joins without handles, addressable processes, or a select form. Joins
alone cannot stop one child while another continues; processes
and channels are excluded at the top of the chain; a select form duplicated
match. Function-owned tasks and tagged results express the same programs.
Logging every step, or snapshotting the process. Deterministic computation is cheaper and more stable to derive again, and a process image ties a run to one implementation. Checkpoints at tail calls give the same economy as plain data.
Upgrading a running program. Version branches inside code, or switching a run's program at a checkpoint, would let pending call ids shift under a recorded history. Forking into a new run keeps both runs honest.
Automatic retry. A runtime that re-sent a recorded call on its own would hide exactly the uncertainty history exists to keep.
The next layer is the language specification. It fixes exact types, grammar and semantics. A specification may refine this architecture; it may not silently contradict it.