Workflow runtime contracts
Language behavior
- Pure code is JavaScript. Loops, records, arrays, closures, template literals, destructuring,
try/catch, arithmetic,switch, compound assignment, optional chaining, spread and rest: what you would write anyway, with the parts that hide effects or make meaning depend on the host removed (class,this,new,for...in,==, labels, regex literals,Math/Date/JSON, promises, generators). Every refusal names its code and the edit that fixes it. The builtins are a short list (keys,map,sort,json.stringify,now(),random()), and arrays, strings and numbers answer their usual methods (xs.map,s.trim(),n.toFixed()) and nothing outside that table. Records and arrays you build are yours to change until they cross an effect boundary; a member you do not own, a host prototype, or a value another branch built is refused with a code, never a surprise. - Every effect is journalled and hashed. A step is keyed
(scope path, kind, name, occurrence)and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's deadline) and the resume stops with a divergence naming the step, rather than replaying an answer to a question the program no longer asks. - Concurrency is visible.
parallel,race,fanOutandconclaveare the only ways to do two things at once, each branch gets its own journal namespace, and the scope writes its own entry saying how it settled: which arm won a race is a recorded fact, decided by the arms' recorded clocks and declaration order, never by a scheduler. A failure the program itself caused inside the scope settles the entry under its own catalog code (fanOutwithout a stable key isL3021, kindruntime), so a resume reads the same code the live run threw; a plain failure from the handler records the genericL4000scope-fault. A value the program throws inside a branch is recorded with that fault, socatchreceives the same value on resume, and in a migration's dry walk, as it did live. A branch may not write to anything declared outside it; return the value and read it out of the scope's result. - Time and randomness are tamed.
now()is the branch's run clock, the end of the last effect it awaited;random()is a seeded stream derived per scope, each draw a number from 0 up to but not including 1.randomInt(n)isfloor(random() * n), an integer from 0 ton - 1for a positive integern, andpick(xs)draws one element. They replay identically. A builtin ignores arguments past the ones it takes, as JavaScript does, so there is no range form:randomInt(1, 1000)israndomInt(1), which is always 0, and1 + randomInt(1000)draws from 1 to 1000. - Values freeze at the boundary. What crossed into or out of an effect is what the journal recorded, and it cannot change afterwards; build a new value.
- The journal is the debugger. Every entry carries its key, its inputs' hash, its outcome and
its timing, and every error is in the program's own coordinates. A run can be simulated with a
scripted handler and dry-run to a plan before it touches an agent. The simulator is
discrete-event: timed effects park at their wake times and are delivered in wake order on one
virtual clock, so concurrent branches accumulate the durations they wrote and a simulated
raceis decided by the same rule a live handler produces (least recorded clock, ties by declaration order). Asleep("1m")arm beats asleep("1h")arm whatever their declaration order. Behind the worker bridge the simulator delivers its next wake only after the thread reports it has reacted to the last one, so a bridged simulation settles a race the same way, also when the dry run's recorder wraps the simulator.
waitUntil observations and ordinary effects use the same dispatch-failure recorder. A handler
refusal records refused and holds the run for a capable host. Cancellation records cancelled.
Other handler failures record their language code, or L4000 when none is supplied, with their
message, recordable detail and stack when available. A journal append rejection during dispatch
leaves the entry pending and exits without recording a handler failure.
Full rules, with every code: spec/cotal-lang.md.
Reusable helpers
A function call does not create a journal namespace. Repeated calls to a helper share the
occurrence counters of its named steps, so inserting an earlier call renumbers the later ones.
Wrap each instance in a distinctly named, single-branch parallel, with a record branch such as
{ call: () => helper() }. Keep the helper's required step names literal, and read the returned
value from .call. Each wrapper adds one journal entry; repeating its name still increments its
own occurrence. The worked example shows the resulting
keys and the migration limits. No new primitive or computed required name is needed.
Continuing a run
Resume is re-execution: the driver replays the journal, the program runs from the top, recorded steps return instantly, and the first unrecorded step is performed live. It refuses a journal that belongs to another run, a pin that differs from the recorded ones, and a different language version.
A failed journal entry replays its error, while a pending entry lets the handler recover the external work it bound before the interruption.
A deadline that passes during authorized arming, before its pause is minted, ends the step as its
expiry would. A relative sleep completes, and a checkpoint expires for its onExpiry to act on.
A wait on a channel resolves null at its timeout and returns the event at the end of an idle
window, after it opens the channel under the run's read admission. A waitUntil observes again
while its deadline allows. None of them creates a past-due checkpoint. An existing pause keeps its
recorded deadline and settlement, and cancellation still rejects the step.
A step that writes to a far side that honors no idempotency key (posting a comment, sending a mail)
belongs in once. A resume that finds a step inside once begun and never settled does not
dispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded
request id, whose prompt names that id. Answer it with
cotal run answer <run> <step-key> --value <json>, and the value becomes the step's result. An
expired hold fails the step with the catchable L4027. Only ask runs inside once, so wrap just
the step that writes: a host restart while it is in flight costs a settle. run validate refuses
another effect in the body, or in a function the body calls by name, with L2033, and the run
refuses one it reaches any other way with L4028.
const publisher = await spawn("publisher")
const res = await once(async () => {
return await ask(publisher, { name: "publish", schema: { commentId: "number" } })
}, { name: "publish-360" })Migrate moves a run onto edited source. A dry walk of the new program over the recorded journal
finds every recorded step the edit changed (a divergence) and every one it no longer reaches (an
orphan), and the orphan table says what each means: a removed sleep is nothing, a removed turn
already happened, a removed spawn is a live agent you must adopt or release, a removed resolved
checkpoint is a human decision you must explicitly discard. The decision is filed as a
migration record with the actor's name on it. An adopted seat (--adopt <name>#<uid>) goes to
the edited program's next spawn of that persona, which returns the recorded handle and mints
nothing, so the agent keeps its identity, its worktree and its turn history across the edit. A
released seat (--release <name>#<uid>) is despawned when the migration commits, through the same
discharge a cancelled branch's seat leaves by, so the record never claims a release nothing did.
The spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step
reads the same seat back and a cancellation of it despawns the seat it holds.
Fork starts a new run from a named step of an old one, copying the prefix under the parent's
pins (seed included, so the copied history's pure draws are the same draws). The child is a new run
under a new id whose record names the parent and the cut step (forkedFrom); the parent is
untouched. A spawn inside the copied prefix is honoured by its onFork: "adopt" copies it, and
the child shares the parent's agent (the manager shows that seat one turn at a time across both
runs); "respawn", the default, would mint a fresh identity the copied turns do not address, so
this host refuses that cut (L5019) rather than rewriting the parent's history.
Fork planning and migration inspection use the recorded language version. Version-1 history uses the interpreter. Version-2 history is inspected inside a locked-down worker with a read-only journal, without a live effect handler or durable store. Inspection stops before the fork's cut step or any effect that needs new work. Program catch and finally blocks cannot extend the cut. The recorded pins are preserved.
Restore brings a run back from a full backup (cotal backup create) of a static-auth or open
mesh, which holds each run's journal, records and admission. A run that waited at a checkpoint or a
timer when the backup was made is back after a full restore, and the manager takes it over as after
a restart. What the run did after the backup is not in its record. A user-mode backup, and one made
before full backups held workflow runs, holds none (CLI reference). After a
restore from one of them, or a registry-only restore, the store the restore set aside holds the only
copy of the runs, and cotal backup cleanup-fallback deletes it only with --discard-history.
After a full restore of a backup made before the space last ran, that store holds the only copy of
what the runs did after the backup, under the same rule. The restore names each run it rolled back
and each checkpoint answered after the backup that is open again, and that checkpoint accepts an
answer again.
Wire records
The run's wire footprint is SPEC §14:
| Thing | Where | What it is |
|---|---|---|
| the run | run.<endpoint>.<runId> record | the resolved pins (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and journalHigh on the status half |
| the program | program.<endpoint>.<runId> record | the source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against |
| the step journal | WFJ_<space> stream, one subject per run | append-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate |
| a checkpoint answer | answer.<endpoint>.<token>.<answerId> | the payload beside the one-use settle fact; the settle names the answer it accepted |
| a notice | notice.<endpoint>.<runId>.<addresseeId>.<noticeId> | one bounded decision told to one agent, rendered ahead of its next turn |
| a migration | migration.<endpoint>.<runId>.<migrationId> | the report and who applied it, keyed by the report's own digest |
| the admission | admission.v1.<endpoint>.<runId> in cotal_admission_<space> | the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key |
| a revocation | revoked.v1.<endpoint>.<runId> in the same store | who revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect |
The implementation writes the program record, and SPEC §14 refers to it, but the
record-kind table in SPEC §13.7 does not list it. That table omission remains a
specification gap; the table above describes the implemented storage layout.
The driver writes journalHigh at activation and again after each journal append, before the
program acts on the entry. A successor whose replay ends below it refuses the run with
RunJournalTailTruncated. That holds for records appended since the last activation too.
Host authority
A run's driver connects on a run-driver credential minted for one run and takeover
attempt. It can append to its journal, use its replay durable, and write its own run, program,
notice and migration records. It has no store point reads, checkpoint writes, chat consumers,
or channel and membership registry grants.
The hosting process keeps a separate run-mediator connection for effects and reads. The driver
receives methods and data from that host; it never receives the mediator credential or connection.
The host checks the journal's current activation and step identity before dispatch, and checks
pause and wait authority again at each operation. Cancellation cleanup also admits losing steps
named by a settled parent whose cancel.issued is still false. That permission allows cleanup;
it cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery
receipts. A recorded match can be reread only at its bound sequence and channel. A conclave's
recorded ownership flag must match its step-derived channel before registry writes or cleanup.
The mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted
host authority. Record reads exposed to the driver are restricted to its own run's keys. Reads
that decide writes remain leader-served. A read of the journal uses the run's filtered replay
durable, including the diagnostic for a journal with no run record. That durable is named after
the takeover, and an attempt reads it many times, so reads under one takeover run one at a time in
the hosting process and a replay removes a durable of its own name that an interrupted earlier read
left behind. A create failure permits this recovery only for a structured JetStream API error
with code 10148. Other errors propagate without deleting the durable, even if they carry that
numeric or string code. A durable that survives a replay's own delete belongs to a reader the
process cannot account for, and reading its tail is refused. A drive handles that refusal as a
takeover does: the reads behind its steps, and the diagnostic for a journal with no run record,
replay up to three times before the refusal is raised. An operator read runs under a takeover
minted for that read and reports the refusal on its first read.
A served read uses a one-shot run-operator credential. An answer uses a read to find the open
pause, then a second credential pinned to that token for the answer and settlement.
cotal run --local uses the same driver/mediator split. On an authenticated mesh it needs the
locally recorded space signer to mint both credentials; a single --creds file is refused.
Direct library users supplying broker clients to MeshHandler are constructing a trusted effect
host. A hosted driver receives its closed effect interface instead.
This split confines broker credentials; it is not process isolation for injected host code.
The runtime and its effect host share the manager process. A run's channel reach is the admitted
ceiling (SPEC §14.8): the starting caller's issued channel scope,
recorded once in cotal_admission_<space> under a per-run run-admitter credential the driver
never holds, and re-read by the host before every channel effect. Spawn and turn keep their own
delegated checks; notify writes agent-addressed notices and is not channel publication. Treat
run as program-execution authority bounded by that ceiling, not as sandboxing of the program.
A version-1 fork can replay its settled parent history through the host. Inherited checkpoint identifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use child-derived identifiers. A fork is a new run and takes a new admission under the caller who forks it; the parent's ceiling is not inherited.
Available implementations
The language, its validator, interpreter, simulator and dry run are @cotal-ai/lang
(packages/lang), usable in-process with your own effect handler and with no broker: validate(src),
then run(src, { runId, handler }), and resume(src, journal, { runId, pins, handler }) to pick a
run up from its journal (the package README has the snippet, with SimHandler as the handler). That
is the in-process route, yours to drive with your own handler; a run the driver starts executes on
the compiled engine, as the engine paragraph below says. The wire
substrate of §14 (the WFJ_<space> stream, the five record kinds, the activation barrier, the
per-run grants) is in @cotal-ai/core, and the run driver, journal store, migrate and fork are
@cotal-ai/runtime (implementations/runtime). The migrate check is reachable as
cotal run check <runId> <program>; committing a migration it judged admissible
is not reachable from any surface yet, and neither is a fork: planFork and commitFork are
library exports of @cotal-ai/runtime with no CLI or MCP verb. On the mesh handler, sleep,
checkpoint, wait(message(...)), wait(idle(...)), wait(down(...)), wait(replied(...)),
waitUntil, notify, spawn, conclave, ask, monitor and turn are durable.
Mesh effect support
Every effect the language defines performs on the mesh handler; nothing is refused as
not-yet-durable any more. The operator surface over the driver is cotal run; the section above has the verbs.
Engine limits
Two engines, and which one runs your program. The tree-walker is language version 1 and the
compiled engine is version 2, two languages rather than two speeds of one (spec/cotal-lang.md
§8.4 lists what differs). The driver hosts both: every run a driver starts is stamped 2 and
executed by the compiled engine. The program runs in its own locked-down worker thread with
nothing in its global scope, while the effects and the durable journal stay in the driver's process,
bridged over a message port. No socket or credential enters the isolate holding the program,
and every version-1 record keeps replaying on the walker, which is the walker's job. On either
engine the driver bounds an effect's ok result at the broker's max_payload less 4096 bytes: a
larger result is refused ahead of the settling append (L5006), the step stays pending, and the
run is released. The driver serves a declared set of versions, and a record whose version it does
not serve is refused by name (L5023) with the run left untouched, instead of being replayed by
whichever engine happens to be present. Records do not cross between versions in either
direction; the repair is to resume on the recorded version, or to fork.
The engine needs node 22 or newer and refuses below it as EngineUnavailable, which is an
implementation limit and not a language error: it carries no L code, so there is nothing to look
up in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on
AsyncLocalStorage, and 22 is the lowest node it has been measured on. The walker has no such floor.
Foreign error text
Worker execution, read-only inspection and the host effect bridge use the same name and
message projection for plain failures. A string name or message is retained, including
an empty string; this also applies to writable fields on native Error objects. Other names
become Error. A value without a readable string message uses its normal string conversion.
If that conversion also throws, the message is <unprintable thrown value>, not a successful
result or a replacement exception that strands the answer. Native typed failure classes
keep their existing classification. This contains exceptions during name/message formatting;
it is not a time limit for arbitrary accessor or conversion code.