Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

loom-ui

@loom-ui/planner

The Planner SPI — compose/revise: intent + semantic catalogue + capabilities → Operation[] — plus a reference planner, a conformance kit any planner must pass, and the machinery to re-author bindings when hydration reports drift. It is the layer that turns a request into edits of the Experience Model @loom-ui/core defines: a planner never touches a Blueprint or a projection directly, only Operations that apply/replay fold onto an Experience — which @loom-ui/core then compiles and @loom-ui/hydration hydrates.

Install / import

// package.json of a consumer checked out beside this repo (the packages are not published)
"@loom-ui/planner": "link:../loom-ui/packages/planner"
import { ReferencePlanner, interpretAction, checkPlannerConformance } from "@loom-ui/planner";
import type { Planner, ComposeInput, ReviseInput, PlanResult } from "@loom-ui/planner";

Key concepts

  • A planner returns operations, never an Experience. Planner.compose/.revise both resolve to a PlanResult { ops, findings, report, summary? }; the caller folds ops onto its own lineage with replay. @loom-ui/planner imports no invoker, effect runtime, projection runtime or LLM provider (enforced by noLookups.test.ts) — capabilities and context arrive resolved from the caller.
  • Every model call is one ctx.decide. A plan is recorded as a single decision keyed on every input that shapes the prompt (intent, catalogue content, surface, the offered capabilities’ identity and tool-authored text, and the caller’s context) — so a replay serves the recorded ops with no model call. CapabilityRef.sampleOutput is deliberately NOT part of that key: a new sample is a new REVISION, never a silent re-plan.
  • Capabilities are an input, not a lookup. A CapabilityRef is a tool the planner MAY bind a node to: handle, toolName, toolVersion, a pinned toolSchemaHash, and an optional sampleOutput the planner reshapes to pin outputShapeHash — no sample means not bindable.
  • The summary is stamped with the class of everything the model read, and fails closed. PlanResult.summaryClass is the strictest of: the base experience, the APPLIED result, the caller’s contextClass, and CapabilityRef.sensitivity of EVERY offered capability (its sample reaches the prompt whether or not the plan binds it). A capability that carries a sampleOutput but no sensitivity, with no contextClass declared, is content of unknown class: the summary is OMITTED (warn finding summary-withheld; the ops are unaffected) rather than stamped with a guess. Declare sensitivity on the capability, or contextClass to vouch for the lot. The conformance kit reports summary-class-unsound for a planner that does otherwise.
  • A capability’s declared class classes every node bound to it (tool-level, fail-closed). CapabilityRef.sensitivity is not only the summary’s stamp: materialize gives each bound node strictest(the model's proposal, the capability's class). A proposal (admitted only on a sensitivity: "model-may-set" type) can raise the class, never lower it, and a node on a caller-only type still takes the capability’s class although the model could not set one. On a revise it raises an existing node (a bindProvider carrying the class). The conformance kit reports binding-class-below-capability for a planner whose bound nodes carry less than their capability declares. The class is the strictest declared over EVERY step of the node’s call graph and every capability of the same tool (requiredClass; declaredToolClassesFrom(capabilities) is the matching read-time table). The same declaration is the READ-time floor a host passes to core as declaredToolClasses (see @loom-ui/core), which covers nodes this planner did not write. declaredToolClassesFrom(capabilities) is THE way a host builds that table, from the same CapabilityRefs it gives the planner, so host and planner derive it identically: new Lineage({ events, content, readPolicy: { declaredToolClasses: declaredToolClassesFrom(capabilities) } }) (or createServeSurface({ declaredToolClasses: ... }) in hydration). Add undeclared: "deny" to make a tool the capabilities do not list unreadable; pass the same readPolicy to checkPlannerConformance and it warns (handle-tool-undeclared) when a composed handle’s tool is neither offered nor in the table, i.e. would be unreadable.
  • Untrusted content stays fenced. Anything tool- or caller-authored that reaches a model (samples, the current experience’s node list, a capability’s description/schema) is wrapped as DATA in the user message, never system; ReferencePlanner fences its own prompts rather than going through @loom/llm’s buildRequest/guardRequest.
  • Two authorisation domains. interpretAction is the one seam between a widget’s click and the Experience: an experience.op action is admitted only if it parses as an Operation, is not prompt/setRole/a provider op, does not touch the root, and names a node that exists; everything else passes through to loom’s ordinary business-action path untouched.
  • Drift re-authoring. reviseForDrift takes the DriftRecords collected from one or more @loom-ui/hydration DriftReports (their .drift arrays) and turns them into a revision request any Planner can serve, composing the same helpers (driftRequest, driftContext, observedSamples, driftCapabilities, unsampledDrift) the reference planner itself calls.

API

The SPI

  • Planner — { compose(input: ComposeInput, ctx, opts?: PlanStreamOptions): Promise<PlanResult>; revise(input: ReviseInput, ctx, opts?: PlanStreamOptions): Promise<PlanResult> }.
  • ComposeInput, ReviseInput — { intent, catalog, capabilities, context?, contextClass?, surface?, containers?, unit? } and, for a revision, { experience, request: { text, scopeNodeIds? }, catalog, capabilities, context?, contextClass?, surface?, observed?, containers?, unit? }.
  • PlanResult, PlanReport, AttemptTrace — { ops, findings, report, summary?, summaryClass? } (summary is the model’s own description of the plan, kept for change delivery: UNTRUSTED text, bounded and stripped by core’s boundSummary, never an instruction); the report’s decisionId, attempts, firstAttemptValid, modelId and summed usage are what a caller stamps provenance from.
  • CapabilityRef — one bindable tool: handle, optional kind, toolName, toolVersion, toolSchemaHash, optional description/inputSchema/outputSchema/sampleOutput/sensitivity. kind (absent = MCP) names the data-provider kind (see hydration): the planner treats it as opaque, copies it onto the step it emits, and keys handles, decision ids and observation matching by it (toolKey), so the same name under two kinds is two providers. An MCP capability keeps every id it had.
  • DecideContext, DecideOptionsLike, DecideScope — the structural subset of loom’s ExecutionContext.decide a planner needs; any loom ctx satisfies it. The decider is (scope: DecideScope) => T | Promise<T>: scope.invokeTransient(toolId, input, { signal? }) runs the call AS the deciding execution (its workflowId/executionId, full principal including delegation, composed cancellation, budget snapshot), still transient. A zero-arg decider remains valid. A planner that makes its model / MCP / sandbox calls through the scope is authorised and metered as the flow that asked, not as an anonymous runtime-level caller.
  • scopeInvoker(scope): TransientInvoker — adapts a DecideScope to the TransientInvoker interface @loom-ui/hydration is written against, so its wrappers compose on top: memoInvoker(limitInvoker(scopeInvoker(scope), 4)). Only signal and onDelta are forwarded; principal, as and resources are refused (the scope IS the identity; a silent drop would run a call as someone it did not ask to be), label is ignored. memoInvoker keys on every option except signal, and the scope fixes the identity, so a memo built inside one decider is safe — never share one memo across scopes (executions). A refused option is a REJECTED promise, never a synchronous throw. Do not configure principal on hydration / reshape when their invoker is a scopeInvoker — the scope already carries the execution’s principal, and the call is refused otherwise (refusing beats running as someone else).
  • PlannerModel, PlannerMessage, PlannerUsage — the structural subset of @loom/llm’s LlmProvider.complete a model must satisfy, and its token-usage shape. PlannerMessage.content is text OR parts (PlannerMessageContent, mirroring loom R5’s LlmMessageContent); the messages the planner itself writes are always text. A model that reads message content uses plannerMessageText(content) (the local textOf: text and tool-result text, never [object Object]; a tainted() wrapper throws). Type change: code that read message.content as a string must narrow or call plannerMessageText.
  • planDecisionId(kind, input): string — the ctx.decide key compose/revise use: every input that shapes the prompt (intent/request, catalogue CONTENT, the offered capabilities including their tool-authored text, caller context, and surface), deliberately excluding a capability’s sampleOutput (a new sample is a new revision, never a silent re-plan). Overloaded per kind: "compose" takes { intent, catalog, capabilities, context?, surface? }; "revise" takes { versionHash, request, catalog?, capabilities?, context?, surface?, observedHandles? }. ReferencePlanner calls it to key its own ctx.decide; a custom planner can reuse it for the same replay-stability.

Reference planner

  • ReferencePlanner — implements Planner. Constructor: { model: PlannerModel | ((scope: DecideScope) => PlannerModel), modelId: string, maxAttempts?: number, reach?, decisionKey?, orderSalt?: string, variants? } (default 2 attempts, parse/validation retries included). orderSalt is an author id (a replica, agent or session) mixed into the order keys of the nodes the planner inserts, so planners writing concurrently into one experience get distinct keys in the same gap; unset keeps the unsalted keys. Pass a factory to make the model calls AS the deciding execution, e.g. model: (scope) => { const inv = scopeInvoker(scope); return { complete: (req, o) => inv.invokeTransient("llm.complete", req, o) }; } (forward o: it carries the signal and, for a streamed turn, the delta sink); a plain model ignores the scope.
  • reach?: { run?, transient? } — the planner’s declared reach for @loom/verify (loom L32). A flow that calls planner.compose(input, ctx) hands its ctx to code the verifier cannot see into; the planner declares what that code runs, and the verifier counts those tools against the real grants and registry. The planner cannot know the model tool id (the model factory is yours), so you declare it: new ReferencePlanner({ model, modelId, reach: { transient: ["llm.complete"] } }) — transient = toolIds called through scope.invokeTransient (the model tool, plus any hydration / MCP / sandbox tool your model factory or wrappers call), run = toolIds run through ctx.run / ctx.execute. No reach option means NO declaration: verify then reports the hand-off as unresolved, which is honest; a partial guess would not be. The declaration is attached structurally under Symbol.for("loom.verify.reach") (non-enumerable, a copy), with no dependency on @loom/verify.
  • decisionKey?: Uint8Array | string — a host secret of 32 or more RANDOM bytes (for example randomBytes(32), stored base64 and decoded to a Uint8Array) that keys every plan decision id with HMAC-SHA-256: plan:<kind>:k:<32 hex>. The length check (refused at construction below 32 bytes) is a floor and does not measure entropy; a passphrase is not a key. Prefer the Uint8Array. A string is UTF-8 encoded without normalisation: an NFC and an NFD spelling of the same key are DIFFERENT keys (a silent rotation, below), and lone surrogates collapse to U+FFFD. The planner copies the bytes once at construction, so zeroing your buffer afterwards changes nothing, and keeps them (and variants.rollout.bucketSecret) in private fields that JSON.stringify and util.inspect do not show. Why: the id is recorded in PLAINTEXT as DECISION_RECORDED.decisionId and survives a crypto-shred of the owner’s key; the unkeyed default hashes the intent, context, revise request and catalogue with a fast non-secret hash, so after erasure anyone can hash a guessed (low-entropy) intent and compare it with the log. A revise also records context.base; with a key it is a keyed k:<32 hex> HMAC of the version hash instead of the raw (unkeyed SHA-256) hash. Unset = the unkeyed id and raw base, byte-for-byte as before (existing logs replay). Keep the key as durable as the log, and change it only with no execution in flight. The same key gives the same id, which is how a replay serves the recorded decision. Changing, losing or adding the key is safe ONLY after you drain or finish every in-flight execution. Otherwise a resumed execution’s planner computes a new id, misses the recorded decision and re-plans LIVE: it may compose a DIFFERENT plan, so an approval or later step served from the log then applies to a plan nobody saw, and a later step whose id derives from the plan can fail replay with FlowReplayDivergenceError under divergence: "fail". Never reuse the KeyStore’s per-subject keys; erasure destroys those. planDecisionId(kind, input, { key }) takes the same option. Still plaintext with a key (this option does not cover them): the lineage’s experienceId and its versionHash, parents, revertedTo, carriedFrom and from (versionHash is a plain SHA-256 of the artifact, so a guessed artifact can be confirmed against it; see the erasure section of @loom-ui/lineage); the variant unit and the catalogue id in a variant decision’s recorded context; the planner modelId, the decision’s source and policy labels; and any other event field the host or loom records outside the planner. Keep personal data out of those.
  • declarePlannerReach(target, reach) / readPlannerReach(value) / REACH_DECLARATION — the same attachment for a custom Planner (or any object a flow hands ctx to).
  • REFERENCE_POLICY, REFERENCE_SOURCE — the fixed { id, version } policy stamp and decision source this planner records.
  • scratchGenesis(intent) — a scratch-lineage Experience for validating a draft plan before it is applied to a real one (every genesis shares the same root, so ops are id-based and portable).

Conformance

  • checkPlannerConformance(planner: Planner, fx: ConformanceFixture): Promise<Finding[]> — runs a custom Planner through the contract a caller depends on: at least one attempt, a named model, firstAttemptValid agreeing with the attempts, replay-stable usage, and — with fx.ctx a recording context — that a second compose with the same inputs replays rather than re-calls the model. With fx.scopeProbe ({ identity, calls() }, observing the scope behind fx.ctx) it also requires compose to decide through ctx.decide and its decider to call through the scope (scope-unused either way), every call to be stamped as the deciding execution (scope-identity-mismatch), and a replay to make none (replay-recalled-scope); calls made during revise must be stamped as the deciding execution too, reported separately from a compose mismatch (whether revise must call the scope at all is not checked). Omitting scopeProbe means the scope contract is NOT checked. fx.ctx must be fresh: a ctx that already holds the compose decision serves it without running the decider, reported as fixture-ctx-not-fresh (not scope-unused). calls() may return a live array. With fx.denialProbe ({ ctx, error, decisions() }: a second fresh recording context whose scope throws error) it also requires compose to REJECT with that error and record nothing (denial-swallowed / denial-replaced / denial-recorded). Omitting it means the control-plane contract is NOT checked. With fx.checkReach: true (needs scopeProbe whose calls carry toolId) it also requires the planner’s declaration to list every toolId called through the scope during compose and revise under transient (reach-undeclared; reach-unprobed when there is no probe). Omitting it means reach is NOT checked.
  • ConformanceFixture — the fixture shape: intent, catalog, capabilities, meta, a revision request, a DecideContext, and optional modelCalls()/surface/scopeProbe/denialProbe.

Interaction (widget actions vs. experience ops)

  • interpretAction(exp, action: WidgetAction): Interpretation — { kind: "experience", op } | { kind: "business", action } | { kind: "refused", finding }.
  • EXPERIENCE_OP_ACTION — the one WidgetAction.type this function interprets as an operation.
  • EXPERIENCE_PROMOTED, ExperiencePromotedPayload — the event name/payload for promoting a scratch or draft Experience to the lineage a viewer sees.

Materialising a draft into operations

  • materialize(draft, ...) — turns a parsed model draft (PlanDraft) into Operation[] against a base Experience, building every rebuilt ProviderSpec along the way.
  • buildProviderSpec(cap, binding, nodeId?, graph?) — one capability + a draft binding → { ok: true, spec: ProviderSpec } or a typed refusal (capability-unsampled, reshape-invalid, unhashable-sample, dangling-step-reference).
  • providerHandle, callGraphOf, CallGraph, danglingStepReference, orphanedProviders, DEFAULT_TTL_SECONDS — supporting helpers: derive a provider’s handle, read a multi-step call’s dependency graph, check a graph schedules without a cycle or forward reference, and find providers a plan’s own removals/rebinds orphaned.
  • DRAFT_ID, MAX_DRAFT_NODES, entryFor, parseModelJson, planSchema, DraftBinding, DraftNode, DraftContainer, DraftEntry, Parsed, PlanDraft — the model-facing draft contract: its zod schema, entry cap, and the parser that turns a raw model reply into a PlanDraft. MAX_DRAFT_DEPTH, MAX_DRAFT_CHILDREN, PLAIN_CONTAINERS, draftStructureIssues, draftEntrySchema — the nesting bounds and their check (see “Nested structure” below).

Prompts

  • composeMessages, reviseMessages, describeCatalog, describeCapabilities, describeExperience, capabilityDetailBlocks, fenceData, COMPOSE_SYSTEM, REVISE_SYSTEM, MAX_FENCE_BODY, PreviousError — the prompt-assembly building blocks ReferencePlanner uses; exported so a custom planner can reuse the same fencing discipline rather than re-deriving it.

Wiring reach into verify

reviseForDrift(planner, input, ctx) hands ctx to planner.revise, so the reach is the PLANNER’s. Map whichever callee text a flow uses to the planner instance (the key is the callee text, or its leftmost identifier):

defineConfig({
  checks: { verify: { reach: { planner, reviseForDrift: planner } } },
});

reviseForDrift itself carries no declaration (it reaches only what the planner it is given reaches), so map it to the planner rather than to the function.

The reach option declares on the planner INSTANCE. A flow that calls a planner FACTORY (planner("x").compose(input, ctx)) is keyed by the factory’s name, so the declaration must be on the factory function too:

const planner = declarePlannerReach((flowId: string) => makePlanner(flowId), {
  transient: ["llm.complete"],
});
defineConfig({ checks: { verify: { reach: { planner } } } });

A declaration is an upper bound: listing a tool that is never called is allowed (as @loom/verify’s assertReachMatches without exact); only an UNDER-declaration is a finding. { run: [], transient: [] } declares “reaches nothing”; {} (or a malformed declaration) is undeclared — exactly as @loom/verify reads it. checkReach also reports reach-unprobed when the probe’s calls carry no toolId, so it cannot pass without observing a tool.

Drift repair

  • reviseForDrift<R extends PlanResult>(planner: DriftReviser<R>, input, ctx): Promise<R> — resolves to the planner’s OWN revise result (extra fields preserved; only findings may grow).
  • driftRequest, driftContext, observedSamples, driftCapabilities, unsampledDrift — the exported helpers reviseForDrift composes, for a planner that must reproduce the flow by hand.
  • DriftReviser<R>, DriftContextEntry — the minimal { revise(...) } shape reviseForDrift needs, and one fenced drift-context entry.

Usage accounting

  • readReportedUsage(reply), addUsage(a, b), totalUsage(attempts) — read a model reply’s usage, fold it into a running total (refusing rather than overflowing Number.MAX_SAFE_INTEGER), and sum an attempt list. Exactly what ReferencePlanner itself calls — a custom planner need not re-derive any of it.
  • isPlannerUsage(u) — validate that a value has the PlannerUsage shape; exported for a caller’s own checks, not called by ReferencePlanner itself.

Provenance (U12)

plannerProvenance(result, { agentId?, ref? }) is the Provenance to stamp on the ops of a plan: source: "agent", the model, and the loom decision (PlanReport.decisionId) that recorded the turn. Hand it to Lineage.compose({ by }) / RecordedOperation.by, with PlanResult.summary and summaryClass, and Lineage.explain can answer “which model call put this widget here?” without the model’s text ever entering the op log through the provenance. It copies nothing else.

Variants, propensity and a learning loop (U11)

new ReferencePlanner({ variants: { policy, variants } }) makes each turn two decisions. First a variant choice through decideWithPolicy (plan-variant:<hash>): recorded with its context ({ kind, unit, key, catalogId }), the alternatives (the sorted variant ids) and the policy’s propensity, so @loom/learning can evaluate it off-policy from the log. Then the plan, keyed additionally on the variant (a plan made under one variant is not a plan for another). A variant is { containers?, guidance? }: the container roles offered for the turn, and a bounded, control-free line of application-authored layout guidance (at most MAX_GUIDANCE, 400, characters) appended to the system prompt. Variant ids are [A-Za-z0-9_.:-]{1,48}.

The turn needs ComposeInput.unit / ReviseInput.unit, an opaque [A-Za-z0-9_.:-]{1,128} id of the user, tenant or cohort (an email is refused). The host derives it SERVER-SIDE as a keyed HMAC alias (unitAlias(key, userId) in @loom-ui/learning), never from a client: it is a pseudonym recorded in the decision log, so destroying the key severs it from the person (the log still links one alias’s decisions). The variant decision id is a SHA-256 of the plan key and unit (it keys what a reward attaches to). Without a valid unit the turn returns a variant-unit-invalid finding and calls nothing. Policies shipped: stableBucketPolicy (A/B by a hash of the unit, propensity = weight share), epsilonGreedyPolicy (propensity eps/k plus 1-eps for the greedy arm — exact) and ucbPolicy (deterministic, propensity 1, over a stats snapshot). All are pure — no clock, no RNG — so a replay and a re-run agree; and a replay serves the RECORDED choice even if the policy has since changed (report.variant.propensity is then absent: it is only claimed when this run’s policy made the recorded choice). A recorded variant the planner no longer offers fails closed (variant-unknown, no model call). PlanResult.report.variant.decisionId is the learnable decision; plannerProvenance stamps it as Provenance.policyDecisionId.

Through a rollout registry (loom R18). Pass variants: { rollout: { source, policyId, resolve, bucketSecret? }, variants } INSTEAD of policy (exactly one; both or neither throws at construction) and the choice is made with decideWithRollout, with unit as the traffic key. The decision records the version that ACTED, its propensity and the rollout stamp (mode, bucket, shadow arm, fellBack), so a shadow candidate never serves, a canary unit gets the candidate’s variant stamped with the candidate’s version, and linkFromLedger / U11 rewards attribute to the right version. report.variant.policy is then the acting { id, version } and report.variant.rollout the stamp (on a replay version is omitted: the log’s recorded stamp is the truth). Like propensity, both are claimed only for a choice this run made; a replay serves the recorded choice and the log keeps its stamp. A registry with no live version is a variant-policy-failed finding, no model call.

Streaming a turn (U1)

Optional and generic, and ONE governed call: there is no second model path. Pass { onPartial, signal } as the third argument of compose/revise and the planner hands model.complete(req, { signal, onDelta }) a sink. A scope-routed model forwards it — scope.invokeTransient("llm.complete", req, { signal, onDelta }) (loom ADR 0119) — so the streamed turn is authorised, metered, taint-fenced, kill-switched and cancelled exactly as a non-streamed one (a denied streamed turn rejects, records nothing and emits no partial). A model that cannot stream, or a runtime that refuses the sink (an output validator with no delta form), never calls onDelta: the turn simply completes without partials.

  • Partials are a live side channel, the decision is the final. ctx.decide still records ONE value: the plan built from what complete RETURNS — identical to the non-streaming result for the same model output, usage included. A replay serves it with no model call and emits no partial.
  • Deltas are tainted (PlannerDelta.delta is a TaintedValue<string>, ADR 0085); the planner unwraps them only to scan for completed nodes. A malformed delta is a model-error attempt, not a silent skip. A runtime reset chunk (the call retried) clears the scanner and emits an empty-ops partial.
  • Each partial is a whole pending op list (the nodes the reply has completed so far, through the same contract, materialisation, replay and validateExperience as the final; a partial that would not stand as a final plan is not emitted). Fold them with core’s OpStream (unsealed working copy, never a lineage commit); a later partial supersedes the earlier; a retry starts with an empty-ops reset. Partials carry no summary (U7): the summary and its summaryClass come only with the final result.
  • Bounded, linear. NodeScanner scans each chunk once with carried state (only the current element’s text is kept), capped by reply size, node size and node count; past a cap you simply get no more partials and the final still arrives. It stops for good once nodes has closed.
  • Cancellation. signal aborts the turn: it rejects with AbortError and records nothing. The planner races the call against the abort, so a model that ignores the signal cannot hold the turn.
  • Conformance. checkPlannerConformance gains streamProbe (opt-in): partials arrive and apply, final equals whole-reply, replay recalls nothing, a cancelled turn rejects unrecorded. With denialProbe too, the refusal is replayed with onPartial set (stream-denial-*).

Examples

Composing and revising with the reference planner (illustrative — not a compiling snippet):

import { ReferencePlanner, interpretAction } from "@loom-ui/planner";
import { AnthropicProvider } from "@loom/llm";
import { genesis, replay } from "@loom-ui/core";

const planner = new ReferencePlanner({ model: new AnthropicProvider({ /* ... */ }), modelId: "claude-…" });

// Inside a loom flow: the model call is a decision — replay serves the recorded ops.
const { ops, findings, report } = await planner.compose({ intent, catalog, capabilities }, ctx);
const experience = replay(genesis({ experienceId, intent, meta }), ops);

// A revision is one prompt op; its materialized primitives replay to the same artifact.
const revision = await planner.revise({ experience: current, request: { text }, catalog, capabilities }, ctx);

Repairing drift a hydration onDrift reported (illustrative):

import { reviseForDrift } from "@loom-ui/planner";
import type { DriftReport } from "@loom-ui/hydration";

// Collected from one or more DriftReports' `.drift` (e.g. every report for this experience
// since the last repair).
const drift = reports.flatMap((report: DriftReport) => report.drift);

const repaired = await reviseForDrift(planner, {
  experience: current,
  drift,
  catalog,
  capabilities,
}, ctx);

Notable behaviours and guarantees

  • Chat and inputs: the model shapes, never says (U8). describeCatalog adds chat guidance to an entry ONLY when it resolves to a chat kind (a catalogue without the chat entries says nothing about it, and a draft naming Chat is unknown-type). A chat node’s props are checked against the framework’s allow-list, whatever the catalogue declares: transcript content (entries, messages, text), an entered value (value, defaultValue, a prefill), a submit or any markup prop is chat-prop-forbidden; a form’s fields / a select’s options are a closed spec (no regular expressions, no defaults) and a bad one is chat-prop-shape; an unbound Chat is chat-unbound; an input with a binding is unexpected-binding. checkPlannerConformance reports a planner that gets one past validation as compose-invalid:<code>. A planner decides where a conversation goes and what its inputs are called; what people and the assistant say, and what a person enters, are data and never its to author.

  • Media comes from bindings, never from the model (U9). describeCatalog adds media guidance to an entry ONLY when it resolves to a media kind (a catalogue without the media entries says nothing about media, and a draft naming Image is unknown-type). A media node’s props are checked against the FRAMEWORK allow-list in validateExperience (text props only): a src/address/autoplay prop is media-prop-forbidden, a URL or content address as a prop’s whole value is media-literal-ref, an unbound display node is media-unbound — even for a catalogue that DECLARES such a prop. The reference planner feeds those findings back like any validation finding, and the conformance kit reports them as compose-invalid:<code> for any planner. Text props that merely EMBED a URL or address are media-embedded-ref (media nodes) / embedded-ref warnings (other nodes): a hint for review, not a boundary. The prompt’s raw capability samples are redacted of address-shaped strings (redactAddresses). Content references as tool ARGUMENTS (U9b) are guarded too: mark the field "x-content-ref": true in the capability’s inputSchema (any depth; grammar, marker choice and residuals in core’s README, “Content references as tool arguments”), and mark a producer’s outputSchema the same way to declare that its output is a content reference. contentRefArgsFrom(capabilities) turns the offered schemas into the table ({ args, produces }, plain JSON, persistable); the reference planner and the conformance kit hand it to validateExperience, so a literal at a marked path is media-arg-literal, and a reference to an output its tool does not declare is media-arg-unproven-ref (both fed back to the model; compose-invalid:… in the kit). A schema that mentions the marker but cannot be read is a media-arg-schema-unknown ERROR. The model is told never to write one (leave it out, or keep an existing {{steps.<id>.output…}} that points at a producesContentRefs output): the compose rules say so and each capability’s DATA block lists its contentRefArguments and producesContentRefs. The capability details (description, inputSchema, outputSchema, so their examples / default / enum too) are redacted of address-shaped strings like the samples: a string that is wholly a URL / locator / address goes whole, and the algo:hex class matches looksLikeLiteralRef (content_ref:, blake3-256:). The revise prompt’s CURRENT EXPERIENCE keeps a step’s arguments verbatim on purpose: the model must echo them byte-identical, so redacting them would silently rebind the call; they passed validation, which refuses a literal at a marked path and warns on address-shaped text elsewhere. An UNMARKED string argument that looks like an address warns (media-arg-suspect); a hint. Hand the SAME table to hydration’s required contentRefArgs (its README has the run-time re-check, the cache rule and the param gate). Residuals: an unmarked schema is unguarded, and a declared producer is trusted as a whole, including what it does with its own unmarked inputs (the host’s invoker authorises each call as the viewer; a producer step with an address-shaped literal argument gets a media-arg-suspect hint).

  • Error semantics: refusals propagate, model failures become findings. A plain model failure (provider error, unparseable or invalid output, TypedFailure) becomes a model-error / model-output-invalid finding, is retried up to maxAttempts, and ends as a recorded failed plan. A CONTROL-PLANE refusal is different: CapabilityDeniedError (the deciding execution has no grant for the effect), QuotaExceededError (its budget refuses the call) and AbortError (cancellation) are rethrown out of the decider at once — from the model call or the model factory — with no retry. A throwing decider records nothing, so the flow fails with the real error and, once the grant is fixed, a re-run decides afresh instead of replaying a failed plan. They are matched by error name, so a second copy of loom’s classes is still recognised. The set also names loom’s ExecutionCancelledError / DecideScopeClosedError (its cancellation errors) and the refusals ExecutionDeniedError, CapabilityInvokeDeniedError, EffectModeViolationError, EgressDeniedError, SafetyViolationError, UntrustedContentError and loom’s control-plane limits RateLimitExceededError, KillSwitchEngagedError and SpawnLimitExceededError. An AbortError thrown by the provider itself (say its own request timeout) is therefore ALSO propagated and fails the flow: an AbortError is the platform’s name for a cancelled call and cannot be told from the execution’s cancellation by name, retrying a cancelled call is the caller’s policy (not a draft to re-ask), and previously it was masked as a recorded failed plan. The classifier is exported as isControlPlaneError(e), so a host planner that wraps its own effect calls (a sandbox proof, an MCP sample) can apply the same rule at its own catch sites. plan() receives no cancellation signal, so classification rests on names alone — which means a host that cancels with its OWN Error (loom keeps a caller-supplied reason as-is, e.g. new Error("user left")) is not recognised and is retried and recorded like a model failure. Cancel with the default reason (loom’s ExecutionCancelledError) or an error named AbortError.

  • Replay serves the recorded plan. Because every model call is one ctx.decide, a replayed execution never calls the model again — checkPlannerConformance’s recording-context assertion is exactly this property, tested.

  • A revision describes the region the planner owns. That is the root’s children and, below them, the children of every container a planner authored (owner: "planner"). An application-owned container comes back as a locked placeholder ({ id, kind: "container", locked: true, descendants }); a draft naming a node inside one is refused (nested-node), and one that tries to remove a planner container holding one is refused (locked-descendant).

Nested structure (U15)

The reply stays a FLAT list; a group is an entry with "container" (section, row, grid, or a role the host offered through ComposeInput.containers — REFERENCE_CONTAINER_ROLES for the tabs / stepper that @loom-ui/renderer-lit’s referenceContainers() draws), and its members name it with "parent", the id of an EARLIER container. Flat keeps streaming (U1) and the diff entry-by-entry; the bounds are counts the contract checks: at most MAX_DRAFT_DEPTH (4) levels, MAX_DRAFT_CHILDREN (24) entries per container, MAX_DRAFT_NODES (64) in all, no empty container, and a role container’s children must satisfy its role (tabs / stepper: 2+ titled containers, the title being the label). Anything else is a schema issue naming the entry, so the retry carries the exact complaint, and past the bounds the plan fails closed. A direct materialize caller gets the same bar (no empty container, at most 64 entries; it fails closed with no ops); pass { roles } as its last argument for the role child rules, counting the application panels locked inside a role container, and { partial: true } for a streamed prefix. ReferencePlanner passes the roles it was offered plus those already on planner containers in the base, so a revise can echo tabs/stepper without containers.

Viewer-redaction note: the planner always reads the FULL experience (describeExperience takes no viewer; compose and revise run on the trusted side), so nothing there is collapsed. A container whose children are all restricted for a viewer collapses to a bare placeholder only in compile(..., { viewer }) and the viewer-facing readers (see @loom-ui/core, “A container is classified by what it would show”); a tabs / stepper panel that collapses is drawn like any restricted node, so the set keeps its panel count.

Behaviour notes for this release: the prompt gained a CONTAINERS block and a grouping rule; MAX_DRAFT_NODES went 24 → 64 (the top level, and each container, stays 24), and decision ids are unchanged when containers is absent. Under a revise, the first-listed NEW entry is keyed before its existing siblings, and the reply’s order is honoured (a reorder costs the fewest moveNodes). owner: "planner" is an EDIT BOUNDARY, not access control: any op author can stamp it on an insert (it is immutable afterwards), and it never reaches the Blueprint.

A revise diffs entry by entry against the owned region: a retitle is one setTitle, a role change one setRole, a widget added to a panel one insertNode under it, a widget moved between panels one moveNode (never a remove plus an insert), a removed container one removeNode (its contents go with it), and a reorder costs the fewest moveNodes (the longest run already in order stays put). A container’s kind (section ↔ row) cannot change — no op does that — so it is a warning. checkPlannerConformance judges only what the plan INSERTED (an application’s own containers are not the planner’s to bound or stamp) and checks the bounds, that every container the planner inserts is owner: "planner", that a revise does not recompose, and (given fx.containers) the role rules.

  • setRole is never interactive. interpretAction refuses setRole from a widget action (role-op-not-interactive) — a role picks which container renderer draws a section, and changing it discards that renderer’s chrome state; only a planner or the app sets one.
  • Op refusals are typed and total. Every operation materialize or a caller applies can fail with one of a fixed set of codes (invalid-operation, tree-too-deep, placement-outside-grid, placement-out-of-range, dangling-step-reference, …) — see the composition reference (loom-ui/docs/reference/composition.md) for the complete per-op list.
  • A sample pins a call, never a tool. Samples are keyed by HANDLE (one call: tool + arguments), not by tool@version — two nodes calling the same tool with different arguments drift to two different outputs, and pinning both from one sample would record a wrong repair as proven.
  • A sample never leaves the server. observedSample is raw tool output; an app’s onDrift hook must never forward it (or a DriftReport carrying it) to a client.
  • reviseForDrift does not loop. It calls revise once; a failed repair ends in error findings rather than retrying.

See also

  • ../core/README.md — the Experience Model, operations and catalogue a planner composes against.
  • ../hydration/README.md — the DriftReport reviseForDrift repairs.
  • ../../docs/reference/planning.md (loom-ui/docs/reference/planning.md) — the reference planner and drift repair in depth.
  • ../../docs/reference/composition.md (loom-ui/docs/reference/composition.md) — the operations a planner emits and every refusal code.
  • ../../README.md — root overview.

Source: packages/planner/README.md in the loom-ui repository.