Decision records
ADR 0086 — Runner-body reach analysis, and two rules that were wrong once they could fire
- Status: Accepted — implemented (2026-08-14)
- Date: 2026-08-14
- Builds on: runtime verification (ADR 0028), effect classes (ADR 0084), the step-identity rule (ADR 0061)
- KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged. A build-time analysis, two
FlowSpecfields, and one read-only accessor that is not a syscall.
Context
Three @loom/verify rules — capability-compat, effect-class, suspension-safety — answer a
question of the form “does everything this flow reaches satisfy X”. All three defined “reaches”
as the toolIds of the flow’s declared FlowDefinition.steps.
A step invoked through ctx.run without being listed there was invisible to all of them, and
FlowSpec.suspends had no producer at all — suspend points live in the runner body by
construction, so two of suspension-safety’s three checks could never fire for any app.
Both holes have the same shape and the same fix: the runner body is a source region, which is
exactly what analyzeNonDeterminism already operates on.
Decisions
D1 — Analyze the region, and report what you could not resolve
analyzeRunnerReach(source) returns { steps, unresolved, suspends }.
ctx.run(chargeCard, …) names a binding. A binding declared in the same region as
step$({ toolId: "…" }) resolves; one imported from another module does not. Matching is on
shape — a call whose first argument is an object literal with a string-literal toolId —
rather than on the name step$, so a project wrapper or a locally curried factory resolves too.
unresolved is the load-bearing half. An unresolvable target — an import, a minted step
(mcpInvoke$("hydrate#0"), whose toolId is runtime data by design), an element access — is
reported, not dropped. Dropping it is what would let a partial analysis read as a complete one.
ctx.sleep/sleepUntil are deliberately not recorded: the kernel authors their condition
(on: TIMER_FIRED, wakeAt always set), so treating them as user-authored suspend points would
manufacture findings about code the developer never wrote.
D2 — The bound is a finding, not a footnote
Each of the three rules emits a warn naming the unresolved targets. warn, not error: an
unresolved target is a limit of the analysis, not a defect in the app, and an app with no
compiler wired must stay usable. FlowSpec.reachAnalyzed distinguishes “analyzed and complete”
from “never analyzed”, which unresolvedReach: [] alone cannot — without it, an app that never
wired the compiler would look maximally verified.
This is the effect-class unclassified-effects warn, generalized. Silence about a hole in the
reachable set is what turns a green report into an overclaim.
D3 — Two rules were wrong, and populating them is how we found out
This is the part worth remembering. A check whose input nothing populates is also a check
nobody can see is wrong. Both of suspension-safety’s dead checks turned out to be defective,
and both would have fired on correct code the moment the input arrived.
The blanket correlationKey error is removed. It read “suspend() declares no
correlationKey; a resume event cannot be routed to it”. But WorkflowRuntime.resume(executionId, event) routes by executionId, and nothing in the runtime matches on correlationKey at all —
it is metadata for an external dispatcher that knows a business key instead of an execution id.
Every idiomatic ctx.suspend({ on: "Approved" }) — including the ones in this repo’s own tests —
would have become an error. A verifier that errors on the majority of correct code is a verifier
that gets switched off.
The wakeAt check is now conditional on the timer store. When a TimerStore is wired,
WorkflowRuntime derives wakeAt from a suspend’s timeout at suspend time
(WorkflowRuntime.ts:1336). So suspend({ on, timeout }) is correct there and silently broken
only without one. The rule now reads VerifyInput.timerStoreWired, derived in the adapter from
runtime.hasTimerStore() — read from the real wiring, never accepted from a caller, the same
discipline as resumeAuthenticatorWired.
That leaves suspension-safety with two checks, both of which describe real defects.
D4 — The adapter merges, and never double-counts
resolveVerifyInput({ runnerReachByFlow }) unions body-resolved steps with declared ones by
toolId. A step both declared and reached yields one effect, not two.
Consequences
- The
warncount goes up for apps that wire the compiler. That is the intended direction: they were previously getting silence about the same gap. suspension-safetyis live for the first time. Itssuspendsarray had no producer since the rule was written.- The analysis is opt-in. Nothing changes for an app that does not pass
runnerReachByFlow, except thatsuspension-safetyno longer errors on a missingcorrelationKey— a check that could not fire anyway. - What it still cannot see. A
ctx.runtarget imported from another module resolves only as unresolved. Closing that needs cross-module binding resolution, which is a type-checker job, not a single-region syntactic one. The warn says so rather than implying otherwise.
Amendment (2026-10-04, L31) — decider scopes are reach
ctx.decide(id, (scope) => scope.invokeTransient(toolId, …)) (L30) runs effects the
ctx.run walk never sees, so a flow whose effects all ran inside deciders reached nothing
and passed every reach-bounded rule vacuously. No new ADR: this extends D1’s analysis and
D2’s reporting convention without a new decision, and a new ADR trips four guards for no
added information.
- Counted. A literal-toolId
scope.invokeTransientinside a decider is a reached step (RunnerStepRef.viaDecider), merged intoFlowSpec.effectsso capability-compat, effect-class andreachesconsistency consume it unchanged. - Tracked by binding. The scope parameter may only be the receiver of a called
.invokeTransient; a destructuredinvokeTransient(renamed or not) may only be called. Any other use (passed on, aliased,scope["invokeTransient"], rest pattern), a non-literal toolId, and a decider passed by reference whose body is outside the region are unresolved — reported through the existingunresolvedReachpath and itswarn, not a new rule id (D2). A same-region named function is followed only when its name is unambiguous: declared once in the region,const/function, never reassigned; otherwise it is unresolved (names are matched textually, so a flat map would silently bind the last declaration). Shadowing of the scope’s own name is also textual, which over-reports. - Aliased
decideis unresolved. A non-call reference to a.decidemember (ctx.decide.bind(ctx),const f = ctx.decide), a destructureddecidebinding (const { decide } = ctx, a({ decide }) =>parameter), element-accessctx["decide"]calls and a spread-argumentdecide(...args)are reported unresolved, receiver-agnostic like the rest of the walk. Likewise a scope pattern element that could bindinvokeTransientthrough a computed key or nested pattern. This deliberately includes handingctx.decideto a library (kb.retrieve(q, { decide: ctx.decide }), the documented binding idiom): the library then decides with a decider in ITS code, whose reach is not followed, so the flow is reported unresolved (decide alias ctx.decide) rather than passing as if it reached nothing. The check is receiver-agnostic AND module-wide (the region is the flow module), so a.decidereference on any object in a flow module (policy.decidepassed as a value) reports on every flow in it; that over-reports, which is the safe direction. Likewise the unambiguous-name rule over-refuses a decider whose short name (d,fn) is reused by an unrelated parameter or binding in the module. Known gap, not fixed here:ctx.runhas the analogous pre-existing hole (const r = ctx.run; r(step)is not seen). - Journaled effects. The transient seam refuses a
journaledeffect (EffectModeViolationError).FlowSpec.deciderEffectscarries each scope-reached tool with its mode, read by the adapter from the real registry (WorkflowRuntime.effectModeOf, a read-only accessor, not a syscall), andreplay-safetyerrors on a journaled one. An effect not registered at verify time has no mode and is not flagged. - KERNEL_ABI and EVENT_SCHEMA unchanged.
Amendment (2026-10-04, L32) - declared reach for code handed ctx
L31 counts deciders written in the flow module. A flow that hands its whole ctx to a
library (planner.compose(input, ctx), a retriever, an agent loop) reaches whatever that
library’s deciders/steps reach, and the flow module shows none of it: all three reach-bounded
rules passed vacuously, silently. No new ADR (extends D1/D2; a new ADR trips four guards).
- The library declares; the verifier reads the real object. A declaration is
{ run?: string[]; transient?: string[] }underSymbol.for("loom.verify.reach")on the library object or function. It is structural, so a 3P library attaches it with no loom import (declareReachin@loom/verifyis a convenience).runtools are reached throughctx.run/ctx.execute;transientthroughscope.invokeTransientin a decider. - The join is name-based and explicit. Static analysis sees the identifier, not the value.
The compiler records
RunnerReach.escapes: { callee, root?, via }; the app maps the callee text ("planner.compose") or its leftmost identifier ("planner") to the real object inchecks.verify.reach. A plain{ run, transient }literal is also accepted as the value (an app-level declaration for a library that has not adopted the symbol). Lookup uses own keys only. - Declared tools join
FlowSpec.effectswithviaDeclared: "<callee>", so capability-compat and effect-class consume them unchanged;transientones also joindeciderEffects, soreplay-safetyerrors on ajournaledone and gives aninfofor an unregistered one (mode read from the real registry, as in L31). - Undeclared (no key, or a mapped value carrying no declaration) is
unresolvedReach:ctx handed to planner.compose (no declared reach; add verify.reach["planner"]), the existing ADR 0086 D2warn.ctx.decidehanded out ({ decide: ctx.decide }) is the same mechanism and replaces L31’s baredecide aliasfor that shape; other alias shapes (const f = ctx.decide, destructuring) remain unresolved. - What counts as an escape.
ctxas a call/newargument, directly or inside an object/array literal argument, to a callee that is not a same-module function.ctxstored, aliased or returned has no callee: unresolved (ctx stored as ...).ctx.<method>(...), comparisons,typeofandconst { x } = ctxare not escapes. - Which names are
ctx(round 1: not just the textctx). The FIRST parameter of every runner is ctx whatever it is called: arunner:property or method, the second argument ofregisterFlow(def, runner), a…FlowRunner-typed binding. Alsoctx, a parameter typed…ExecutionContext(or a local alias of it), and the receiver of.decide/.suspend/.sleepUntil. Names propagate to a fixpoint into same-module helpers (ctx as argument i adds that function’s i-th parameter, soh(ctx)withconst h = (x) => lib(x)is followed) and throughconst { ...rest } = ctx. A ctx that reaches a same-module callee whose parameter is not a plain identifier (pattern, rest, missing), or that is wrapped in a literal passed to one, is unresolved rather than skipped. Names are textual and module-wide, so an unrelated function in a flow module with actxparameter (an HTTP context) can over-report; measured on this repo, loom-examples and genUI, that never happened, and dropping the barectxname would risk a silent miss for any runner the identification does not see, so it stays. - A runner that is not a followable function (round 2). A
runner:value (also a classrunner = …field,registerFlowargument 2, aFlowRunner-typed initializer) is followed when it is an inline function, a local function/const, or a LOCAL factory whose every return is a function. A call (withRetry(async (c) => …),makeRunner(lib)) treats the first parameter of every function-literal argument as ctx and is itself reportedrunner not analysable, because the wrapper may do anything else with ctx; an imported or member runner (flow.runner) is the same refusal; so is aget runner()accessor unless every return is a function. Aconstalias of a ctx name is a ctx name; aletalias staysctx stored. Two accepted over-reports: an escape inside a factory-built runner names the factory’s PARAMETER (function mk(lib) { return async (c) => lib(c) }reportslib, whatever was passed tomk), so averify.reachkey of that name would match; and a wrapper call treats EVERY function-literal argument as a runner (compose(async (a) => a, …)adds actx storedentry for the first). A callee that is itself rooted at ctx (ctx.emit("x", ctx),setTimeout(ctx.run, 0),Reflect.apply(ctx.run, ctx, [step])) is unresolved, never an escape (there is no outside code to declare).(0, lib)(ctx)nameslib. Propagation is a worklist over indexes built in one walk (linear), not a re-walk per hop.ctx.parallel’s pair list: read since the 2026-10-10 amendment (L35). - ctx methods as values.
ctx.run,ctx.execute,ctx.parallel,ctx.suspend,ctx.sleep,ctx.sleepUntilhanded out (lib(ctx.run),lib(ctx["run"]),const { run } = ctx; lib(run),lib({ run: ctx.run.bind(ctx) })) are escapes withmemberset, resolved through the sameverify.reachkey (warn textctx.run handed to lib)..call/.apply/.bindon the callee is stripped, andReflect.apply(lib, …)/setTimeout(lib, …)unwrap tolib; a pass-through whose target is not a plain name,Promise.*, a stored method,ctx[k]andctx.run.applyare unresolved. A destructured or element-accessrun/suspendcall is analysed likectx.run.ctx.parallel’s pair list is read since 2026-10-10 (L35). - Empty and unregistered declarations.
{ run: [], transient: [] }is a valid declaration of “reaches nothing” (a pure helper, an emit-only fold);{}carries nothing and is undeclared. A declared tool (either kind) that is not registered is awarn(a declaration is explicit; a typo should not be quiet), joining the unresolved list; L31’s scope-reached unregistered effect stays aninfo(different provenance: the flow’s own source, not a claim). Declared tools carry noeffectClass, so effect-class reports them under its existing “N reachable effects carry no class” warn, which is intended (partially verified, not clean). - Join keys are module-blind. One
plannerkey covers every module’splanner; two modules with differentplannerobjects cannot be told apart. Use the full callee text key ("planner.compose") to separate methods, not modules. - What it cannot see. A declaration is a claim: the named tools are checked against grants
and the registry, not that the library calls only those. One declaration covers all methods
of the mapped object/key; call-chain roots (
planner("x").compose) map via the factory. Aliasing the library (const p = planner) maps under the alias name only. A library’s ownctx.suspendis not expressible. Claim vs reality is tested library-side:recordReach()/assertReachMatches()in@loom/verifyrun the library against a recording ctx and fail when it ran a tool it did not declare (library authors, e.g. the loom-ui planner conformance suite, should call it). - ADR 0083 second consumers (the declaration seam is not for one workload): the loom-ui
planner, the genUI planner flows (
composeDashboard,reviseDashboard), loom-examplesplanner-decide-scope, and in-repo@loom/knowledgekb.retrieve({ decide: ctx.decide }). - Known follow-up, L34: 1P libraries declare their reach. The real hand-offs in the corpus
that ship from this repo declare nothing yet and so warn:
fanout$andawaitChild(@loom/orchestration),decideWithPolicyandrecordOutcome(@loom/learning),runAgentLoop(agent),kb.retrieve(@loom/knowledge). Open question:fanout$’s reach is its CALLER’s step, so a fixed list is wrong; it may need a different shape (for example “reaches what its argument reaches”). Deliberately not in this change. - KERNEL_ABI and EVENT_SCHEMA unchanged; no syscall.
Amendment 2026-10-10: L34, L35, L37
Backlog L34 (1P libraries declare), L35 (ctx.parallel) and L37 (constant and factory-minted ids)
close the “Known follow-up” above. Nothing earlier is rewritten; this adds to it. KERNEL_ABI,
EVENT_SCHEMA and the syscall table are unchanged.
- L35:
ctx.parallelis read. Its signature isparallel(branches, options?), each branch[StepDefinition, input]or(branch) => Promise. An array-literal list (or an identifier bound once to one) is walked: each tuple’s first element resolves exactly as actx.runtarget; a callback branch needs no work (itsb.run(...)is found by the normal walk). A list that is not a literal (items.map(...)), a spread, a tuple with no resolvable first element, and a branch that is neither a tuple nor a function are each reported unresolved (parallel(items.map(…)),parallel branch x), never silently empty. The element-access, destructured and.callspellings are covered;.applyis unresolved likectx.run.apply. - L37: constant and factory-minted effect ids. What verify needs is the EFFECT id, not the step id.
The analysis now resolves, by shape and never by guess:
(a) a string constant: a literal, an all-constant template or
a + b, or an identifier bound once to one byconst, in the same module; also across a RELATIVE import (./x,../x, with.jsmapped to.tsand/index.tstried) whentransformFilesupplies its resolver, reading only top-levelexport const NAME = "literal"of the imported module. Not followed (stays unresolved): a bare or aliased specifier (@x/y, tsconfigpaths), a re-export, a namespace import, a constant defined by a call or another imported constant, andanalyzeRunnerReachon a bare string (no file, no resolver); (b) a step FACTORY call: a same-module or relative-imported function whose single returned expression isanything({ toolId })withtoolIda constant or one of its plain parameters (with its default when absent), sollmStep$(`reshape@a${attempt}`, LLM_RESHAPE)resolves with a templated step id; and two first-party factories that live in packages (so cannot be followed through an import):mcpInvoke$is always themcp.invokeeffect (the tool name rides the run-time input) andsandboxEval$(id, toolId = "sandbox.eval"). A test pins both ids to their package constants. Resolution rules, all of which fall back to UNRESOLVED (a wrong effect id with no warn is the worst failure, so a doubt is never a guess):- The table applies only to a BARE identifier call whose name is declared nowhere in the module
(parameter, local, overload, class, enum, destructuring, catch, for-of: all count) and is an import
of
mcpInvoke$from@loom/plugin-mcp/@loom/sdk, or ofsandboxEval$from@loom/plugin-sandbox/@loom/sdk/sandbox. An alias (import { mcpInvoke$ as m }) maps to the table entry. A property call (x.mcpInvoke$()), a relative or other-package import of that name, and any shadowed name are unresolved. - The relative-import resolver is consulted for a constant or factory only when the name is declared nowhere in the module except as the import binding; a same-named parameter or local anywhere makes it unresolved.
- A factory (same-module or imported) folds a constant only if no identifier it mentions is declared
anywhere inside the factory (parameters are read as parameters, body locals and nested scopes reject),
so
export function mk(K) { return step$({ toolId: K }) }is its argument, never the moduleK. - A
const NAME = step$({ toolId })binding is followed only whenNAMEis declared exactly once in the module and never reassigned; a shadowing parameter or a same-named binding in another function is unresolved (not last-wins). - A
ctx.parallellist bound to an identifier is read only if that identifier is used nowhere but its declaration andctx.parallel(...)arguments; apush, a pass to a function or any other use makes the list unresolved. - The two directions are both closed: a name that is imported (named, default or namespace) AND
declared anywhere else in the module is never resolved through the local declaration either — the
module-wide scan cannot tell which one a use refers to, so
import { mk } …; function w() { const mk = … }; ctx.run(mk("a"))is unresolved, not the localmk. - A step binding (
const s = step$({ toolId })orconst s = { toolId }) is followed only ifscannot change after its declaration: its only uses are the declaration, an argument of a ctx method call (ctx.run(s),ctx.parallel) or of a ctx hand-off (lib(ctx, s)), an array element (a parallel tuple), a spread copy and a property read. A property assignment /delete/++through it, a call that receives it (Object.assign(s, …),mutate(s)), a method call on it, an alias or any other use, anywhere in the module, leaves the target unresolved (s.toolId = "t.y"after{ toolId: "t.x" }used to resolve tot.x). Reuse (ctx.run(s, a)twice, inside a loop) stays resolved. An array element is an alias unless the OUTERMOST enclosing array literal (through nested ones,ctx.parallel([[s, input]])) is itself directly an argument of a ctx call:const arr = [s],[s].forEach(…),const [e] = [s],for (const e of [s])andmut(...[s])are escapes. One non-ctx containment is a DECLARATION, not an alias: thestepsarray of loom’s ownflow$(an unshadowed import from@loom/core/@loom/sdk; only thestepskey of its first argument), which keeps the list and is only read by the runtime. A later write through the flow definition (F.steps[0].toolId = …) is outside the analysis, the same trust class as the hand-off below. In an object literal atoolId/idkey that appears twice (JS takes the last) or is followed by a spread ({ toolId: "y", ...s }) is not authoritative and is unresolved; a spread before the key ({ ...s, toolId: "y" }) is fine. Accepted trust boundary: a ctx hand-off (lib(ctx, s)) is NOT proof the step stays unchanged. The callee is outside the analysis; a callee that rewrites a step it was handed (lib(ctx, s); await ctx.run(s)resolvingsto its declared id) breaks what its own L34 declaration claims. The library-side check isassertReachMatcheswithpassedSteps, not this analysis. - A step with NO
toolIddispatches its own id (the runtime resolvesstep.toolId ?? step.id), sostep$({ id: SCAN_ID, … })reaches the effectSCAN_ID, withSCAN_IDresolved by the constant / relative-import rules above. Only forstep$itself (an unshadowed import from@loom/coreor@loom/sdk, any alias) and an object with notoolIdkey, no spread and no computed key; a wrapper (mk$), a localstep$, or an unresolved id stays unresolved. A factory with several returns, a spread argument, a non-constant effect argument or a duplicated/reassigned constant stays unresolved. Consequence worth knowing: a flow that usedmcpInvoke$now reachesmcp.invokefor capability-compat, so an app whose grants omit it gets an ERROR where it used to get an unresolved warn. That is the check working.
- The table applies only to a BARE identifier call whose name is declared nowhere in the module
(parameter, local, overload, class, enum, destructuring, catch, for-of: all count) and is an import
of
- L34: 1P libraries declare their reach (
declareReach, which adds@loom/verify, a leaf package with no dependencies, toorchestration,learning,analyticsandknowledge):awaitChild,awaitChildren,awaitAll(and the threeboundOrchestrationwrappers),decideWithPolicy,decideWithRollout,recordOutcome, andKnowledgeBase(prototype andretrieve, soverify.reach.kband["kb.retrieve"]both resolve) each declare{ run: [], transient: [] }: onectx.decide/emitwith a pure decider and no tool. This silences the unresolved warn without claiming coverage beyond it;awaitChild’sctx.suspendonCHILD_DONEis still not expressible in a declaration (a library’s own suspend is invisible to suspension-safety). Each is tested library-side withrecordReach()/assertReachMatches(…, { exact: true }). The ADR’srunAgentLoopis not a symbol in this repo (it was an app wrapper in genUI); the real one isagentLoop$, see below.recordOutcomelives in@loom/analytics, not@loom/learning. fanout$: the shape isstepArgs.fanout$runs ITS CALLER’s step throughctx.parallel, so a fixed tool list is wrong for every caller.ReachDeclarationgains one optional field,stepArgs?: readonly number[]: zero-based argument positions holding step definitions the code runs with the ctx.fanout$declares{ run: [], transient: [], stepArgs: [1] }. The compiler records, on a whole-ctx hand-off, the arguments that RESOLVE to a step (ReachEscape.args: { index, step }[], at most the first 8); the adapter reads them only for a callee whose declaration names the position, and counts the step as a reached effect (viaDeclared: "fanout$"). A named position that did not resolve is an unresolved warn (fanout$ (argument 1) is not a step the analysis can resolve at the hand-off), never “reaches nothing”. Two hand-offs to one callee with different steps are both kept (the dedupe key includes the arguments).assertReachMatchestakespassedStepsso a library test can say which tools it handed in, and they only count under astepArgsdeclaration. It is additive and a plain{ stepArgs }literal works for an app-level declaration. Not built: a callee reached via a pass-through (setTimeout(fanout$, …)) has no arguments of its own to read, so it stays unresolved.agentLoop$stays undeclared, deliberately. Its reach is config-dependent and not a fixed set:modelToolId,reasoningToolId(a string or a per-route function),compaction.toolId,writeBack.toolId, an effect-backed skill selector’s tool, every tool the model can name fromdefinition.tools/toolSearch.catalog(toolId: toolCall.name). A static declaration on the function would be a claim that is wrong for every app but one, and a partial list would read as coverage. “Reaches what its config names” would need the compiler to evaluate an object-literal config across helpers and the tool menu, which is well past a small additive extension. So it remains an unresolved warn, and an app that wants it green declares its own:checks.verify.reach: { "agentLoop$": { run: ["model", "search_kb", …] } }(a plain literal is accepted), listing the effect ids it registered for the config it passes. If a second consumer needs the computed form, a helper that derives the declaration from a config (agentLoopReach(config)) is the next step, tested withrecordReach().- Evidence.
runnerReach.test.tsgains L35 (6), L37 (11 incl. the import follower and the two package pins) and L34 hand-off cases; every resolution was reverted once to see its test fail.packages/app/src/verify.test.tsdrivesstepArgs,ctx.parallelandmcpInvoke$throughdefineConfigend to end with a control. Measured read-only against genUI’s flows: 14 unresolved sites and 10 escapes before; 1 unresolved (ahandler$step with notoolId,ScanStep) after, and bothfanout$hand-offs carry their step (they resolve once genUI mapsfanout$).
Amendment 2026-10-12: an empty reach is a fact when the runner is here
The compiler dropped a flow’s runnerReach from the manifest when it was empty, so a pure
runner (async (_ctx, input) => reduce(input)) that the analysis READ and found reaching nothing
was indistinguishable from a runner it never read, and D2’s coverage note named it “no runner-body
analysis” (genUI’s apply-authoring-operation and presubmit). An empty reach is ambiguous on
its own: it is also what a module with no runner in it (a definition whose runner is registered
from another module) produces, and so is an unrelated runner elsewhere in the module (an object
property, a class field, a nested function, another flow’s registerFlow). The compiler now keeps
the empty reach only when a followed runner is BOUND to the flow (in the same object literal or
class as its flow$ call, or passed to registerFlow(def, …) where def is, or names, that
flow$ call) AND the module declares exactly one flow. That is sound because a
runner the analysis cannot follow is never silent (runner not analysable: … is unresolved, so
the reach is not empty), and because the reach is module-wide, a module with several flows keeps
the old, conservative answer. Manifest shape and the public analyzeReachNode are unchanged.
Evidence
tools/compiler/src/runnerReach.test.ts (30) covers resolution, non-resolution, the generic
ctx.suspend<T> form, nesting inside loops/branches/closures, and the deliberate sleep
exclusion. packages/verify/src/verify.test.ts covers the corrected rules — including the two
tests asserting the absence of the removed correlationKey finding and the TimerStore-wired
case — plus the coverage warns.
packages/app/src/verify.test.ts drives it through defineConfig end to end, and leads with the
control: a flow whose ungranted step is invoked via ctx.run but not declared reports
ok: true without reach analysis, and errors with it. Without that case the rest could be passing
for reasons unrelated to the analysis.
Source: docs/adr/0086-runner-body-reach-analysis.md in the loom repository.