Decision records
ADR 0114 — ctx.ui leaves the kernel
- Status: Accepted — implemented (2026-10-07) — design approved after 4 review rounds; see “Implementation notes” for the deviations
- Date: 2026-10-05
- Closes: R14 (agentic-readiness audit, kernel appendix G1)
- Builds on: ADR 0043 (the layering guard),
ADR 0069 (the A2UI egress over
agent-surface), ADR 0102 (ctx.uiis a flow-level emit and stamps like one). - Placement: kernel removes;
@loom/agent-surfaceand@loom/appown. KERNEL_ABI 1.2.0 → 1.3.0 (shared Wave 2 bump):uiis markeddeprecated, not removed; removal is a MAJOR. EVENT_SCHEMA: no change from this ADR.
Context
ctx.ui is a syscall in KERNEL_SYSCALLS (kind: "accessor"), and @loom/workflow-runtime depends
on @loom/agent-surface, which pulls @loom/classify and @loom/projection-runtime.
WorkflowContext builds it as createSurfaceEmitter((eventType, payload) => this.emit(eventType, payload)): a typed patch-builder over emit. It needs no kernel slot. What it costs is real: the
kernel’s build graph includes a UI package, workflow-runtime/src/types.ts imports a UI type into the
kernel’s own ExecutionContext, the layering guard (ADR 0043) forbids kernel → plugins/* but is
blind to kernel → application vocabulary, and the ABI’s own minimality standard (P10) is violated by
the one entry that is mere sugar.
Decision
D1 — The kernel stops importing @loom/agent-surface
ExecutionContext.ui is typed by declaration merging, owned by agent-surface:
// @loom/agent-surface
declare module "@loom/workflow-runtime" {
interface ExecutionContextExtensions { readonly ui: SurfaceEmitter }
}
ExecutionContext extends the (empty-by-default) ExecutionContextExtensions interface, so
ctx.ui type-checks wherever agent-surface is in the program and nowhere else. At runtime the
accessor is supplied by an injected factory, WorkflowRuntimeDeps.surface?: (emit) => unknown;
defineConfig / @loom/app, @loom/testing and @loom/scaffold pass createSurfaceEmitter, so
every composed app is unaffected. workflow-runtime/package.json drops @loom/agent-surface.
A bare new WorkflowRuntime(...) with no surface dep gets an accessor that throws a message naming
the dep. That is a behaviour break for direct-runtime users of a 1.x accessor, which is why the
ABI entry is marked deprecated: true in 1.3.0 rather than deleted, and why the injected-factory
choice was an open question (design document §7 question 2). Decided (user, 2026-10-05, “Break now”):
accept the break now. The monorepo is lockstep and unpublished, so nothing outside it
holds a 1.x bare runtime; the implementation PR runs the loom-examples / loom-plugins suites against
the built dist to measure the blast radius. The alternative (keep the dependency until the 2.0.0 that
removes ctx.ui) stays available if a consumer turns up.
D2 — The sugar moves: surface(ctx)
@loom/agent-surface exports surface(ctx) = createSurfaceEmitter(ctx.emit), the documented spelling
for code that has a context and no injected factory. ctx.ui.set(...) keeps working under D1; new
code and docs use surface(ctx). Docs and decks that show ctx.ui (about 25 files) are updated in the
implementation PR, not here.
D3 — negotiateBudget joins the table
The audit’s R12 drift (negotiateBudget is on ExecutionContext but not in KERNEL_SYSCALLS) is
fixed in the same bump: the table entry is added with since: "1.3.0" and kind: "call", or the
method is declared derived (decide) and moved to a helper; the implementation PR picks, the ADR
requires that interface and table agree and a test asserting it.
D4 — The layering guard learns the inverse rule
layering.ts gains a second invariant: a KERNEL_PACKAGES member depends only on other
KERNEL_PACKAGES members (a closed set, today satisfied by everything except this one edge, so the
test fails before D1 and passes after). A kernel package that needs an external type must take it
structurally. The first test of the new rule is written to fail against main and to be the PR’s
acceptance.
Replay and migration
No event or fold change: ctx.ui was always emit, and the emitted events keep their kinds and
flowEmit stamps. The only migration is the dependency edge and the docs.
Tests
The new layering invariant (red on main, green after); ctx.ui works for defineConfig apps and for
a bare runtime given surface; throws a clear error for a bare runtime without it; the ABI table and
ExecutionContext agree (abi.test.ts extended); surface(ctx) produces byte-identical events to
the old accessor (a fold-equality test over a recorded surface log).
Rejected
- Deleting
ctx.uinow: a MAJOR; deprecation first. - A generic “context extension registry” for third parties: a seam with one user. Declaration merging plus one injected factory is enough, and a second extension would be the evidence to revisit.
Implementation notes (2026-10-07)
- D1. As designed.
ctx.uiis a lazy, memoised getter onWorkflowContextImpl: with no factory it throws (ctx.ui is not available: ... pass surface: createSurfaceEmitter ... or use surface(ctx)), so a bare runtime breaks only when a flow touchesctx.ui, not at construction.@loom/agent-surfacenow has an OPTIONAL PEER on@loom/workflow-runtime(types only, for the merge; a dev dependency for its own tests); the inverse edge is gone, so there is no cycle. The kernel’s own tests inject a tiny local stub (testSurface.ts) instead of importing agent-surface. @loom/testingand@loom/scaffoldneed nosurfaceargument. Neither constructs aWorkflowRuntime: both go throughdefineConfig, which now passescreateSurfaceEmitter. What the scaffold/appgen DID need was the type: a generated flow that wrotectx.ui.set(...)no longer type-checks unless agent-surface is in the program, so@loom/appgenemitssurface(ctx).set(...)plus the import (ensureUireuses an existing import of any form, aliasing around a colliding localsurface), and the scaffold’s package.json depends on@loom/agent-surface.@loom/testingis covered by a test thatctx.uiwrites aSURFACE_PATCHthroughcreateTestApp.- Type reach (review round 1).
@loom/appre-exportstype SurfaceEmitterfrom@loom/agent-surface, which pulls agent-surface’sdeclare module "@loom/workflow-runtime"into every program that imports@loom/app: composed apps keepctx.uityped with no agent-surface import. Bare-runtime users import@loom/agent-surface(or@loom/sdk, which re-exports it). Proven bytools/testing/src/uiTypes.test.ts, which runs tsc over an@loom/app-only consumer (and a negative control). The peer is optional: underskipLibCheck: truean agent-surface consumer WITHOUT workflow-runtime type-checks; underskipLibCheck: falseit errors TS2307 on agent-surface’scontext.d.ts, so install@loom/workflow-runtime(documented in the agent-surface README). - D3.
negotiateBudgetis added to the table as acall(since: "1.3.0"), not declared derived. The test (abi.test.ts) readsExecutionContextthrough the TypeScript compiler API (own members, anyextends/formatting) and asserts its members equal the table minusui. - D4. The closed set needed ONE addition the ADR did not foresee:
effect-runtimehas always imported@loom/classify(a vocabulary leaf with onlyzodbehind it). It joinsKERNEL_PACKAGESrather than being carved out as an exception;workflow-runtime->agent-surfaceis the only edge that was red onmainonce it did. - Fold-equality test lives in
@loom/agent-surface(context.test.ts): the injectedctx.ui,surface(ctx)and the pre-0114 in-kernel accessor give a byte-identical event log and an equal surface fold.
Source: docs/adr/0114-ctx-ui-leaves-the-kernel.md in the loom repository.