Latency Analysis
Chapter 7 showed that processes carry the architecture: the arrows between modules are derived from steps, not drawn by hand. Processes also carry time — every step is a call, and a call takes some amount of it. Give the interfaces on the critical path a latency budget and the compiler folds those budgets along the process graph into a per-process worst-case estimate, queryable the same way any other fact about the model is queryable.
This is a requirement model, not a measurement. latency: 200ms is the provider’s promise — what a reviewer signs up for when they publish the interface — not a number scraped from tracing. The analyzer answers “if every provider keeps its promise, how slow can this flow get?” That’s a design-time question, and it’s exactly the kind of question a static model can answer honestly.
The latency field
Section titled “The latency field”An interface takes a latency budget as a bare duration — no quotes, no new syntax:
module Orders { interface getOrder { latency: 200ms }}A range states best- and worst-case separately:
grpc_unary charge { latency: 50ms-150ms}The value lexes as a plain identifier (a digit-led, letter-bearing run already does, Chapter 15) and is read by the time-analysis tool layer, not given a new value type — latency is a well-known field (Chapter 9): ordinary structured data that a named tool happens to interpret. The unit is drawn from the calc time family — ms s min h d wk mo y, plus spelled-out and plural aliases (200ms, 2 hours, 3d) — and the convention is a sync interface: async and streaming kinds carry no latency, because delivery time isn’t the producer’s promise to keep.
An off-family or unparseable value — a bare number, a unit outside the family (2w) — warns:
LATENCY_MALFORMED latency value '2w' is not a valid duration —use a time unit (ms/s/min/h/d/wk/mo/y), e.g. '200ms' or '50ms-500ms'A missing latency is silent. Absence isn’t debt the compiler nags about on its own — an interface with no stated budget just contributes a gap to any estimate that calls it (below), and if your team wants every sync interface to declare one, that’s a coverage policy you opt into, not a built-in requirement.
A note step can carry a latency too, in a { … } body, pricing it as human/business time:
process Onboard { Ops "verify docs" { latency: 1d-3d }}An unannotated note step costs nothing.
The estimation fold
Section titled “The estimation fold”Given latency annotations, the compiler walks each process and folds a per-process estimate — interval arithmetic over the same IR that produces the flow, sequence, and BPMN views. Every leaf prices as a (system, business) pair of intervals; composition follows the shape of the construct that contains it: a sequence sums, an if/else spans its widest arm, a parallel join N takes the N-th smallest branch, a select fans out to [0, …], a retry’s bound multiplies out the attempt cost. A call’s own contribution is its callee’s declared latency — no latency, no contribution beyond a gap (below).
Two things make the fold trustworthy rather than merely mechanical:
System and business are separate axes, never summed. System time is compute/response — what a caller actually waits on inline. Business time is human or scheduled waiting — an approval queue, a settlement window. A fraud gate that resolves in 300ms of automated scoring or falls back to a one-day analyst review is system 300ms · business ≤1d, not “300ms to 1 day”: the day is a human backstop, a fundamentally different kind of time than the response SLA, and folding them into one number would let a slow human step quietly blow a response budget it never touched. A live badge prints both dimensions side by side, omitting whichever is zero.
A declared promise caps its own internals, but only on the system side. When a step calls an interface with a latency, that number stands in for everything the callee does underneath — its own calls, its own gaps — on the system axis. respond in 200ms, settle in days is a valid, common model, not a contradiction: the response promise absorbs the callee’s internal system time, while any business waiting inside the callee (an approval, a scheduled job) passes through untouched into the caller’s business total. If a callee’s own modeled internals are already known to exceed its stated promise, that’s a NESTING_CONTRADICTION warning, and the wider, truthful value is what shows.
The line glyphs fold as what they resolve to. A \ snake run is a chain, so it folds as one — the head’s declared promise caps the tail’s system fold (nesting, not sum). A | fan step folds at the level of the step it fans from, because that is where it hangs.
The upper bound is finite or OPEN, and an OPEN result carries a reason — one of two kinds:
- Structural — the flow is genuinely unbounded until you model a timeout: a
gore-entry, a recursive subprocess splice, anawaitor inbound wait raced against nothing. - Gap — the model just lacks data: a missing or malformed
latency, aneachover a collection of unknown size, an unparsed cron expression.
process ManualReview { Ops > Review.start as checkpoint Ops "escalate to senior" go checkpoint // loop back — structurally OPEN, not a data gap}That distinction matters because a gap is analysis data, never a diagnostic — the compiler doesn’t warn you for leaving an interface’s latency unannotated, the same way it doesn’t warn you for an unfulfilled optional field. Only a malformed value (LATENCY_MALFORMED, above) is a real problem, because it’s not missing information, it’s wrong information. If you want gaps to fail your build — “every sync interface on this path must declare a budget” — that’s a coverage policy you write, not compiler behavior forced on every model.
Reading the estimate: @estimatedLatency
Section titled “Reading the estimate: @estimatedLatency”The fold’s result is queryable through @estimatedLatency, a builtin on a process or subprocess subject (Selectors, Chapter 31). It’s the first dotted builtin — a reserved subtree, not a single getter:
| Getter | Meaning |
|---|---|
@estimatedLatency | the system worst-case — the response-time SLA, the natural budget target |
@estimatedLatency.min | the system best-case |
@estimatedLatency.system / .business | each axis’s worst-case, budgeted separately |
@estimatedLatency.bounded | false iff a structural OPEN reason survives — a data gap alone never trips it |
@estimatedLatency.unestimated | count of unpriced (gap) leaves feeding the estimate |
Each value getter resolves to the finite upper bound when the fold closes, the priced worst it could still prove under a data gap, or ∞ when a structural reason keeps it genuinely open. ∞ is a real value here — it compares greater than every finite budget, so an unbounded flow always fails a budget check rather than silently passing one. On a non-process subject the value getters are neutral (undefined) and .bounded reads true — vacuously bounded, so a stray where (not @estimatedLatency.bounded) never flags the rest of the model by accident.
Comparisons are duration-aware: either side of = != < <= > >= may be a bare duration literal or a latency-shaped field getter, and the evaluator coerces it to seconds for the comparison:
view SlowFlows { show process and where (@estimatedLatency > 500ms)}Budgets are ordinary policies
Section titled “Budgets are ordinary policies”There’s no budget construct in the language — a budget is a duration field you declare, and a policy (Chapter 32) that reads it. That keeps “what counts as too slow” a project decision instead of a compiler opinion, and it means a budget composes with every other policy idiom you already know: severity, except waivers, module scoping.
A process’s own header carries the budget as a plain field, the same header zone that already holds a description or an aspect team:
process CheckOrder { latencyBudget: 500ms Customer > Orders.getOrder}latencyBudget has no meaning to the language — it’s just a duration field the process happens to carry. The policy is what gives it teeth:
policy LatencyBudgets { severity: warning forbid process and where (@latencyBudget) and where (@estimatedLatency > @latencyBudget)}forbid matches every process that both declares a latencyBudget and whose estimated system worst-case exceeds it — a process with no budget field is simply outside the rule’s scope, not a violation. Because @estimatedLatency and @latencyBudget compare on the same duration-aware axis, the right-hand side of > can be a literal (@estimatedLatency > 500ms) or another field getter (> @latencyBudget) with no special-casing.
The same pattern covers the two questions teams usually want next to a budget rule — coverage (did every sync interface on this path even declare a promise?) and boundedness (is anything on this path genuinely unbounded?):
policy LatencyCoverage { severity: warning require rest_create or rest_read or grpc_unary: this and where (@latency) except Legacy.Mainframe.submit "vendor gives no SLA; tracked in JIRA-1234"}
policy BoundedFlows { forbid process and where (not @estimatedLatency.bounded)}LatencyCoverage is a shape rule over interfaces (every sync one should carry a latency field, with a named, reasoned waiver where it genuinely can’t); LatencyBudgets and BoundedFlows are shape rules over processes, reading the fold’s output. All three are ordinary forbid/require/except policies — nothing about time analysis needed a new policy shape.
Surfaces
Section titled “Surfaces”The estimate isn’t only a query target — the process/flow view renders it directly: a badge on the process header, a ruler along the critical path, per-hop labels on the steps that consume the most time. Headless export (server, CLI, MCP) carries the same badge and labels into the exported SVG/PNG, and a diff between two revisions of a process shows the estimate’s Δ — a call whose declared latency widened or an interface that lost its budget shows up as a time regression, not just a text change. All of that rides the same estimation fold this chapter describes; the language surface — the field, the builtin, budgets-as-policies — is what makes those views possible.
Putting it together
Section titled “Putting it together”A minimal but complete slice — a budgeted interface, a process that calls it and states its own target, and a policy that enforces the target:
module Orders { interface getOrder { latency: 200ms }}
process CheckOrder { latencyBudget: 500ms Customer > Orders.getOrder}
policy LatencyBudgets { severity: warning forbid process and where (@latencyBudget) and where (@estimatedLatency > @latencyBudget)}CheckOrder’s single call is priced at 200ms system, well inside its 500ms budget — the policy has nothing to flag. Add a second call whose interface has no latency, or one whose declared cost alone exceeds 500ms, and LatencyBudgets starts firing.
Summary
Section titled “Summary”latency: 200ms(or a range,50ms-500ms) is a bare-duration field on a sync interface — the provider’s worst-case promise, read by the calc time family (ms s min h d wk mo y+ aliases). An off-family value warnsLATENCY_MALFORMED; a missing one is silent.- A note step’s
{ latency: … }body prices it as business time; unannotated notes cost nothing. - The compiler folds
latencyalong each process’s IR into a(system, business)estimate — two axes, never summed. A call’s declared promise caps its callee’s modeled system internals only; business waits pass through. - The upper bound is finite or OPEN, and OPEN carries a reason: structural (
go, recursive splice, a timeout-less wait — genuinely unbounded) or a gap (missing/malformed data). Gaps are analysis data, never diagnostics. @estimatedLatency(with.min/.system/.business/.bounded/.unestimated) is the first dotted builtin, queryable on a process/subprocess subject, duration-aware in comparisons.- Budgets are ordinary policies over ordinary fields —
latencyBudgeton a process, aforbidrule comparing it against@estimatedLatency. The language has no dedicated budget construct. - The same estimate drives the flow/BPMN view’s badge, ruler, and per-hop labels — live and in headless export — and shows as a Δ in diffs.
What’s next
Section titled “What’s next”The worked designs starting with Chapter 25: A SaaS Backend put processes, views, and policies together on a full system — latency budgets slot into that same toolkit wherever a flow has a response-time contract worth enforcing.