Skip to content

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.

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.

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 go re-entry, a recursive subprocess splice, an await or inbound wait raced against nothing.
  • Gap — the model just lacks data: a missing or malformed latency, an each over 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.

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:

GetterMeaning
@estimatedLatencythe system worst-case — the response-time SLA, the natural budget target
@estimatedLatency.minthe system best-case
@estimatedLatency.system / .businesseach axis’s worst-case, budgeted separately
@estimatedLatency.boundedfalse iff a structural OPEN reason survives — a data gap alone never trips it
@estimatedLatency.unestimatedcount 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)
}

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.

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.

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.

  • 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 warns LATENCY_MALFORMED; a missing one is silent.
  • A note step’s { latency: … } body prices it as business time; unannotated notes cost nothing.
  • The compiler folds latency along 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 — latencyBudget on a process, a forbid rule 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.

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.