7. Processes
A process is an ordered, possibly branched sequence of interface invocations. It captures business and technical flows: a checkout, a webhook handler, a daily batch job, a user onboarding sequence.
Processes are the primary source of dependency information in ArchLang. The arrows between modules in your diagrams are derived from process steps. Delete a process, the arrows it implied disappear. There is no separate “draw a dependency” operation.
process Checkout { Customer > Orders.createOrder Orders > Inventory.reserve Orders > Payments.authorize Payments > Ledger.record Orders > Notifications.sendEmail}Five steps; six modules involved; five dependency arrows derived (Customer → Orders, Orders → Inventory, Orders → Payments, Payments → Ledger, Orders → Notifications).
Processes carry the architecture
Section titled “Processes carry the architecture”Modules are the cast; processes are the story — and the story is what the business actually is. The process is arguably the more important of the two. Model as many as you can; ideally, model all of them.
Coverage is what proves your modules aren’t dead. A module that appears in no process is, by definition, cooperating with nothing — connected to nothing. That’s the shape of dead code.
Rule. A module in no process reads as dead code. Almost always that means a process is missing, not that the module is idle — go find the flow it participates in and model it.
Detail, on the other hand, is optional. You don’t need branching, looping, or error paths in every process — treat all of that control-flow machinery as sugar. A flat, shallow process that just lists its steps still counts, and still documents real connections.
Step shape
Section titled “Step shape”Every step has the form:
Caller > Callee.Interface- Caller — a module or actor (the entity issuing the call). Left of
>. Inside a module body it may be omitted — it then defaults to the enclosing module (see Anonymity: the draft floor). - Callee — in the mature form, an interface (the handler that runs). Right of
>.
The arrow > reads “calls.” Whether the underlying transport is synchronous, async, or anything else is determined by the interface type on the callee. With the bare interface type, the language treats every call uniformly; stdlib types (command, event, …, Chapter 11) add sync/async distinctions and edge styling. An event is nothing more than an async-kind callee reached by a > step — there is no subscribes: or separate event wiring.
Draft, not error. Putting a module on the right side —
Payments > Orders— is allowed. The resolver synthesizes an anonymous interface onOrdersand tracks it as aTODO. That’s the draft floor (below); a finished model names the interface, and the zero-TODO merge gate enforces it.
Placement — keep processes local
Section titled “Placement — keep processes local”Processes can live in three places, all semantically equivalent:
Inside a module body. Owned by that module; the fully-qualified name becomes Owner.Process:
module Orders { process Checkout { User > Orders.ordersResource.add }}Top-level with in. Declared flat, attached to a module’s body. Mirrors module in syntax:
in Orders process Fulfillment { Orders > Shipping.schedule}The in form lets each team declare processes against another team’s module without editing that team’s file.
Top-level. A free-standing orchestration that belongs to no single module:
process CheckoutFlow { Customer > Orders.createOrder Orders > Payments.authorize}Placement is semantically equivalent, but it isn’t arbitrary — declare a process at the smallest scope that contains the span it covers:
- Entirely within one service → a local process on that service.
- Spans several services, and you have a system module that contains them → declare it on the system.
- Spans several services in a single-system architecture with no umbrella module → a top-level (root) process is fine. The root is a legitimate owner.
Rule. A process lives with whatever owns the span it covers. Don’t hoist a service-local flow to the root, and don’t invent a system module just to host a process.
Draft process-first
Section titled “Draft process-first”You don’t have to define the cast before you write the story. Because every step names a caller module and a callee interface, a process implies the modules and interfaces it touches — so you can write only processes and let the structural skeleton appear underneath.
Reference modules and interfaces that don’t exist yet. Customer > Orders.createOrder when there’s no Orders module and no createOrder interface is not an error: the compiler synthesizes them as stubs (drawn dashed) and records each gap as a TODO — “missing detail,” not “wrong” (Specification §4.13, §10). Your flow draws immediately; the modules fall out of it.
Then promote each stub: open the dashed module, give it a real description, type, fields, and nesting. As you do, its TODO clears. “What’s left to define” becomes a measurable count, not a pile of suppressed errors — tooling can gate “ready to propose” on zero TODOs.
Rule. Within a package, an undefined local reference is a
TODOstub, not an error. (A cross-package reference is different — packages are opaque, so an undefined foreign type stays a hard error.)
This is the mirror image of the module-first, two-pass flow: module-first suits documenting a system you already know; process-first suits sketching a new or proposed one. Both converge on the same model.
Anonymity: the draft floor
Section titled “Anonymity: the draft floor”Process-first drafting makes undefined references a TODO. The same principle covers anonymous ones: anything anonymous, unspecified, or undefined is a compiler TODO — one draft-debt class. “Complete” means nothing anonymous and nothing undefined, and the merge gate (archlang validate --complete, Studio’s accept-gate) holds the line. That lets the lightest possible draft be legal while keeping it out of a finished model.
Bare steps — no process wrapper. A step written at file root or directly in a module body is a declaration — it’s sugar for an anonymous single-step process:
module Orders { > Payments // anonymous process, implicit caller = Orders > Cart.add // name the interface; caller still implicit}Inside a module body the caller may be omitted — it defaults to the enclosing module, so > Payments.charge means Orders > Payments.charge. At file root there is no enclosing module, so the caller stays explicit.
Rule. Each bare step is its own anonymous process, and bare steps imply no order between them. Two bare steps are two independent edges, not a sequence. Order and grouping come only from an explicit
process { … }wrapper (even an anonymous one). An anonymous process is itself aTODO; promoting it means giving it a name.
Trailing . — an unspecified interface. A trailing dot marks the terminal interface as anonymous — a TODO, drawn dashed:
Auth > Billing.charge // mature: the charge interface on BillingAuth > Billing. // Billing module, interface unspecified (TODO)Auth > Billing.Ledger. // submodule Ledger, interface unspecified (TODO)A path self-types by position: an internal segment (one with children further along) is a module; the terminal segment is an interface (a declaration always wins over this guess). The trailing . is only needed to force an otherwise-ambiguous terminal to be a module with an anonymous interface; > Payments and > Payments. mean the same thing, so the canonical form drops the dot and the formatter strips a redundant one once a declaration confirms module-ness.
Chaining — A > B > C. A chain is the inline, ordered form of process { } for a straight-line run:
Storefront > Orders.create > Payments.charge// desugars to: Storefront > Orders.create ; Orders > Payments.chargeThe carried-forward caller of each hop is the top module of the previous hop’s callee (the service is the actor; nested detail stays encapsulated). A chain is one ordered anonymous process; the implicit caller applies to the head only; binding (x = A > B > C) captures the final hop. Chains are linear only — fan-out can’t chain (two callees from one module are two steps) — and are meant for short, stable runs.
The gate is load-bearing. All of the above lets a draft reproduce exactly the box-and-arrow fan-out the language exists to escape (a module with ten
> Xlines). What makes it safe: every such arrow is an anonymous process plus an anonymous interface — allTODOs — so a finished model physically can’t keep them. Permissive on entry, forbidden on exit.
Every action has an owner
Section titled “Every action has an owner”Before the control-flow constructs, one idea that runs through all of them: every action is owned. A node is [owner] <action> — the module or actor that performs it. Ownership is uniform across calls and control flow, so “who does this / who decides this branch / who drives this saga” always has an answer.
A step’s owner is its caller (left of >). Control-flow nodes take an owner prefix the same way: Orders if …, Booking each 3 try …, Orders parallel join …. The owner otherwise resolves down a fixed chain:
explicit prefix → | (fan: previous sibling's head) → nearest enclosing owner → TODO \ (snake: previous sibling's tail) — stops here, never falls through-
|fan repeats the previous sibling’s head — the subject that opened that line, so after a chain it is the chain’s head, not an intermediate hop. A flat way to group a run by one performer:process Fulfill {Orders > Inventory.reserve| > Payments.charge // owner: Orders (fan)| if "vip": Orders > Notifications.send} -
\snake is the mirror glyph: it takes the previous sibling’s tail — the top module of its last hop — and continues the run from there, so each participant is named once and the baton keeps moving.process Refund {Customer > CustomerPortal.webUI\ > APIGateway.publicAPI // owner: CustomerPortal (snake)\ > Orders.getOrder // owner: APIGateway (snake)\ > Payments.refund // owner: Orders (snake)| > Notifications.sendEmail // owner: Orders (fan off the snake)}Mnemonic.
|stays with the head,\moves to the tail.A snake run is the chain
A > B.x > C.ywrites — except every line stays its own step, so each hop keeps its own comment,asbinding, and diff identity.\is call-only: write\ > Callee; it cannot prefix a note, block, control construct,do,go, or anunwindaction (parse.snakeRequiresCall). And it never falls through — with no previous sibling to take a tail from, the step gets aTODOcaller plus aSNAKE_NO_TAILfinding rather than inheriting the enclosing owner. -
An owner block —
Owner { … }— sets the owner for its whole body. -
Inherited — a container’s owner is the default for its subtree. The nearest enclosing owner wins.
-
distmarks distributed control: no single coordinator; the structure is realised by its participants (choreography). It is a resolved state, not aTODO—dist reversible { … }is a choreographed saga.distdoes not propagate: a bare child inside adistspan inherits the nearest non-distowner, else becomes aTODO.
Rule. Bare always means inherit, never emergent. A node’s meaning never depends on where it sits — emergence is only ever the explicit
dist. If nothing resolves an owner, the node is aTODO.
do and go are ownerless — they’re structural operations, not actions, so they carry no owner (a prefix on do is a template default-caller, below).
This is what makes orchestration vs choreography fall out of the model with no mode flag: an orchestrated flow leans on one inherited owner (a coordinator); a choreographed one shifts ownership across steps and is marked dist.
Branches: if / select
Section titled “Branches: if / select”Everything from here down is control-flow sugar. It makes a process more precise, but a process is already complete without it — reach for these only when the extra detail earns its keep.
if is the exclusive choice: Owner if cond Body (else if cond Body)* (else Body)?. A condition with no body is a draft branch. The condition is an identifier or quoted string; the body is a brace block or a single :-prefixed step.
process Checkout { Customer > Orders.createOrder Orders > Payments.authorize
Payments if "stripe-customer" { Payments > Stripe.charge } else { Payments > PayPal.createOrder PayPal > PayPal.captureOrder }
Payments > Ledger.record}select is the multi-way choice and is inclusive — every matching case runs:
process Checkout { Orders select "channels" { email: Orders > Notifications.email sms: Orders > Notifications.sms // both run if both match }}Owner select [count] [head] { case-label Body ... }. No case keyword — each block starts with its label (identifier or quoted string), then a body. The count narrows how many cases fire:
select(bare) — all matching cases run.select one— the first matching case (one=1).select N— the first N matching, in order.parallel select { … }— the matching cases run concurrently (see Parallel).
The dependency graph includes every interface mentioned in any case; heads and labels are free-form annotations (rendered, not evaluated).
Iteration and retry: each
Section titled “Iteration and retry: each”Owner each <head> { ... } iterates. The head is free-form: each item in xs, each "until ready", or a bare count each 3. Used for fan-out over an unknown-cardinality set.
process NotifyAll { Notifications each subscriber in NotificationList { Notifications > subscriber.sendUpdate }}Retry is the each <bound> try / catch / else form — attempt, restore between attempts, retry, and give up after the bound:
process WriteWithRetry { Orders each 3 try { snapshot = Orders > Store.read(id) Orders > Store.write(id, update, snapshot) } catch "conflict" { Orders "reload + reapply" // restoration before the next attempt } else { fail "write conflict" // 3 attempts failed }}On failure catch runs and the loop retries; on success it exits; when the bound is exhausted without success else runs. A try nested in a plain each body (each: try { … }) is the per-iteration guard instead — success does not exit, and there is no else.
Error paths: try / catch
Section titled “Error paths: try / catch”Plain Owner try Body (catch [Label] Body)* attempts a step or block; on failure the matching catch runs and the flow continues forward (no retry). Multiple catches allowed; each label optional.
process Checkout { Customer > Orders.createOrder Orders try { Orders > Payments.authorize } catch "declined" { Orders > Notifications.sendEmail }}Parallel and merges
Section titled “Parallel and merges”Owner parallel { branch { … } branch: step } runs its branches concurrently. A merge clause prefixes the block and decides when the flow proceeds:
parallel join N { … }— proceed when N branches finish, keep the rest running (N omitted = all → the AND-join).parallel race N { … }— proceed when N finish, cancel the rest (N omitted = 1 → first-wins / the deferred choice).parallel out { … }— detach; do not wait.
process OrderFulfillment { Orders > Shipping.createShipment
Shipping parallel { branch: Shipping > Notifications.sendEmail branch: Shipping > Notifications.sendSms }
Shipping > Orders.updateOrder}Each branch is branch: + a one-liner, branch { … }, or a nested control node (branch if …). Branches may be named, and the merge may be a boolean over branch names (and / or and parentheses, case-insensitive — no not); the count forms are sugar for symmetric thresholds:
process FraudCheck { Orders parallel join (fraud and credit) or override { branch fraud: Orders > Fraud.check branch credit: Orders > Credit.check branch override: Manager "manual override" }}Fan-out combines parallel with each — one loop body, run concurrently:
process FanOut { Orders parallel join 3 each item in items { Orders > Worker.process(item) }}Waiting: inbound steps and await
Section titled “Waiting: inbound steps and await”An inbound step — Them > Us.x, an external party calling into us — is a wait: the flow proceeds when they call. There’s no separate event primitive; inbound vs outbound is just arrow direction.
process Pay { Customer > Booking.payOnline // Booking waits for the customer to pay}Owner await <free-form> is a timed wait, owned by the waiter:
process HoldSeat { Booking await 1 day}A wait-or-timeout is a race of an inbound step against an await:
process PayOrTimeout { Booking parallel race { branch: Customer > Booking.payOnline // inbound: wait for the customer branch { Booking await 1 day fail "payment timeout" } }}Reversible flows (sagas): reversible / unwind
Section titled “Reversible flows (sagas): reversible / unwind”A reversible { … } block is a forward flow whose completed steps roll back when it can’t finish. The trigger defaults to on error (a failure inside the block) and may be stated (reversible on error { … }).
A forward step pairs with a reverse action via unwind. A > B.x unwind Y is exactly try { A > B.x } catch: { Y; rethrow }: if the step fails — its own failure, or a failure propagated from later — Y runs and the failure keeps propagating, so earlier unwinds fire too, in reverse order.
process PlaceOrder { Order reversible { Order > Inventory.reserve unwind Order > Inventory.release // compensate the reserve Order > Payments.charge unwind Order > Payments.refund // compensate the charge Order > Shipping.ship unwind Order > Shipping.cancel // compensate the shipment }}Each step’s unwind is its own compensating transaction — never an undo of the previous step. The failed step itself never completed, so it has nothing of its own to undo. When a later step fails, the engine’s rethrow fires the completed earlier steps’ unwinds in reverse: on a ship failure, refund then release run; on a charge failure, only release runs. A terminal commit step needs no unwind — nothing after it can fail to trigger one.
Rules to know:
Ymust be a complete statement — a call with a resolvable owner (unwind Order > Payments.refund), a terminator (unwind fail), or a block (unwind { … }). A bare interface with no>(unwind refund) is an error — the caller is unknown.- An
unwindmay attach to a step or a block ({ … } unwind Y); one per block; a block with nounwindis skipped on reversal. - An
unwindmay not nest inside control (e.g. inside anif). Wrap the control in a block and put theunwindon the block. - A line may be reversal-only —
unwind Xwith no forward action.
Orchestrated vs choreographed is, again, just the ownership pattern. An owned reversible has one coordinator driving forward and reverse. A dist reversible is choreographed: each participant owns its step and the unwind that undoes its own work, with no coordinator. The compensation still targets the same callee the forward step called (reserve → release) — never the previous participant:
process PlaceOrderSaga { dist reversible on error { Customer > Order.create unwind Customer > Order.cancel Order > TicketInventory.reserve unwind Order > TicketInventory.release TicketInventory > Payment.charge unwind TicketInventory > Payment.refund Payment > Badge.issue // terminal grant — nothing after to undo }}Compensation lives in the process, never on the interface. The correct rollback depends on flow context — the same operation compensates differently in different sagas — and the backward routing is process structure.
Re-entry: as / go
Section titled “Re-entry: as / go”> Identity.verify as checkpoint names a step. go <name> re-enters that named step and resumes forward from it — the structured loop-back. There is no free arrow to a label; go always targets a real, named action.
process Onboarding { Identity > Identity.verify as checkpoint Identity "manual review" go checkpoint // resume from the checkpoint step (go is ownerless)}Terminators: fail / finish
Section titled “Terminators: fail / finish”fail "…"ends a branch in failure — and inside areversibletriggers roll-back.finish "…"ends a branch in success.
Omit the obvious — but only when it’s encapsulated
Section titled “Omit the obvious — but only when it’s encapsulated”A process is a story, and a good story skips the trivial. A microservice saving to its own encapsulated database doesn’t need a “service saves to DB” step — treat the service as the actor and let the nested database be the obvious detail it carries. The DB still earns its place in the model (it documents structure); it just needn’t appear in the process.
The test is encapsulation, not “feels obvious.” You may omit a thing only when it’s completely nested inside an actor you do mention. Never omit a high-level peer:
- ✅ Omit a service’s own nested database — talk to the service.
- ❌ Don’t omit a gateway. “Everything goes through the gateway” makes it feel skippable, but a gateway is a high-level peer, not an encapsulated detail — and it’s exactly where policy lives. Skipping it in a process can hide a policy violation.
The same encapsulation rule enables fast, high-level drafts: you can write a process where large systems talk to each other without naming the services inside them. That stays a draft — lower-level description is usually truer — and even in a draft, don’t drop a gateway.
Subprocesses and do
Section titled “Subprocesses and do”A recurring sequence of steps belongs in a subprocess — a reusable helper invoked via do:
subprocess RecordEvent(name) { Orders > Ledger.record}
process Checkout { Customer > Orders.createOrder do RecordEvent(checkout_started) Orders > Payments.authorize do RecordEvent(payment_authorized)}Four things to know about do:
- Arguments are intent-only. They are bare identifiers — not type-checked, bound, or consumed by the engine. Passing
checkout_starteddocuments “this subprocess probably needs this” and makes the call site readable — nothing more. - Forward references are fine. You may
doa subprocess that doesn’t exist yet; the gap is aTODOstub, not an error — the same incomplete-by-design rule as process-first drafting. Define it later. dois ownerless. It splices a fragment into the flow rather than performing work. An optional prefix ondois not an owner — it’s the template default-caller (next point).- Exporting a subprocess makes it a cross-space entry point. An
exported subprocess is a sanctioned way for another space to invoke behavior without seeing its internal steps — pair it with a gateway for controlled cross-space calls (Chapter 12).
Re-using a subprocess from a different executor — on Caller. A subprocess may declare a caller parameter, so the same fragment reads from a different performer at each insertion site. The prefix on do binds it:
subprocess chargePayment on Caller { Caller try: Caller > Payment.charge catch: fail "payment failed" try: Payment > Invoice.issue catch: Invoice > Support.newTask; finish}
process SellTicket { TicketInventory do chargePayment // Caller = TicketInventory}Caller is referenced explicitly in the subprocess’s steps and also serves as the default owner for its owner-omitted steps. The optional prefix at the do site (TicketInventory do chargePayment) binds it.
Subprocesses can be top-level (visible everywhere), declared inside a module body (scoped to that module’s processes), or declared inside a type body (stamped onto every instance — see Chapter 18).
Lookup precedence when do X(...) runs:
- Subprocesses declared lexically inside the invoking process.
- Subprocesses on the owning module (closest ancestor wins).
- Free-standing top-level subprocesses.
Stable IDs
Section titled “Stable IDs”Processes carry stable IDs the same way modules do:
process #t9p2mx Checkout { Customer > Orders.createOrder}The formatter mints #t9p2mx on save. The ID lets diffs recognize a renamed process. See Chapter 13.
Validation
Section titled “Validation”The validator enforces process shape, while leaving incompleteness alone:
- The caller (left of
>) resolves to a module or actor (including stdlibusermodules once the stdlib is imported — see Chapter 11), or to a name bound earlier in the process — or, inside a module body, is omitted and defaults to the enclosing module. - The callee (right of
>) resolves to an interface, a surface (which routes to its interfaces), or a module. A module callee isn’t an error — it synthesizes an anonymous interface tracked as aTODO(the draft floor above). - A name used in incompatible roles across steps — as an interface leaf in one place and a module-with-children in another — is an error. Those can’t both be true.
What is not an error: a step that names a local module, interface, or subprocess that doesn’t exist yet, or one that leaves the interface anonymous. Within the package, that reference synthesizes a stub and is tracked as a TODO (see Draft process-first) — missing detail, not a contradiction. Cross-package references are the exception: a package is opaque, so an undefined foreign reference stays a hard error.
Errors appear as LSP diagnostics in your editor and as exit-1 results from archlang validate; TODOs surface as draft debt you can count, not as failures.
What processes give you
Section titled “What processes give you”Once you have processes, several things fall out automatically:
- Service call graph. Modules connected by the
Caller > Callee.Interfaceedges across all your processes. - Critical paths. A request flow that takes ten steps is visible at a glance.
- Blast radius. When one service goes down, which processes break? Trivially derivable.
- Change impact. When you remove an interface, the validator tells you every process step that depended on it.
None of this requires you to maintain a separate dependency catalog. The catalog is the union of every process step in the workspace.
Summary
Section titled “Summary”- A process is a sequence of
Caller > Callee.Interfacesteps. - Callers are modules or actors (or omitted in a module body — defaults to the enclosing module); callees are interface leaves in the mature form.
- Every action is owned (
[owner] <action>); the owner resolves explicit →|fan (previous sibling’s head) → nearest enclosing →TODO(a\snake takes the previous sibling’s tail instead and never falls through — unanswered, it is aTODOcaller plusSNAKE_NO_TAIL), withdistmarking emergent/choreographed control. - Processes carry the architecture — model as many as you can; a module in no process reads as dead code. Detail (
if/select/parallel/each/try/await/reversible/as/go) is sugar; a shallow process still counts. - Declare each process at the smallest scope that contains its span; the root is a legitimate owner in a single-system model.
- You can draft process-first: undefined local modules and interfaces become dashed stubs tracked as
TODOs, not errors. - The draft floor: a bare step (no
processwrapper), a chainedA > B > C, a trailing-.unspecified interface, and a module callee are all valid — each anonymous piece is aTODO, and the zero-TODO merge gate keeps them out of a finished model. - Omit only encapsulated detail (a service’s own DB); never omit a high-level peer like a gateway.
- Subprocesses are reusable helpers invoked with
do; their arguments are intent-only, forward references are allowed, and an exported subprocess is a cross-space entry point. - The dependency graph is derived from processes; you don’t draw arrows separately.
Beyond this chapter
Section titled “Beyond this chapter”An interface may carry a latency: 200ms field (or a range, 50ms-500ms); the analyzer folds these along the process graph to estimate a critical path, queryable as @estimatedLatency in views and policies. See Chapter 34: Latency Analysis.
A process doesn’t only render as the flow walkthrough shown throughout this chapter — the same process can render as a sequence diagram or a bpmn collaboration (flow <ProcessRef> sequence / flow <ProcessRef> bpmn). See Chapter 33: Process Representations.
What’s next
Section titled “What’s next”Chapter 8: Views → — curated projections of the model for specific audiences.