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/.reviseboth resolve to aPlanResult { ops, findings, report, summary? }; the caller foldsopsonto its own lineage withreplay.@loom-ui/plannerimports no invoker, effect runtime, projection runtime or LLM provider (enforced bynoLookups.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 recordedopswith no model call.CapabilityRef.sampleOutputis 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
CapabilityRefis a tool the planner MAY bind a node to:handle,toolName,toolVersion, a pinnedtoolSchemaHash, and an optionalsampleOutputthe planner reshapes to pinoutputShapeHash— no sample means not bindable. - The summary is stamped with the class of everything the model read, and fails closed.
PlanResult.summaryClassis the strictest of: the base experience, the APPLIED result, the caller’scontextClass, andCapabilityRef.sensitivityof EVERY offered capability (its sample reaches the prompt whether or not the plan binds it). A capability that carries asampleOutputbut nosensitivity, with nocontextClassdeclared, is content of unknown class: the summary is OMITTED (warn findingsummary-withheld; the ops are unaffected) rather than stamped with a guess. Declaresensitivityon the capability, orcontextClassto vouch for the lot. The conformance kit reportssummary-class-unsoundfor a planner that does otherwise. - A capability’s declared class classes every node bound to it (tool-level, fail-closed).
CapabilityRef.sensitivityis not only the summary’s stamp:materializegives each bound nodestrictest(the model's proposal, the capability's class). A proposal (admitted only on asensitivity: "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 (abindProvidercarrying the class). The conformance kit reportsbinding-class-below-capabilityfor 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 asdeclaredToolClasses(see@loom-ui/core), which covers nodes this planner did not write.declaredToolClassesFrom(capabilities)is THE way a host builds that table, from the sameCapabilityRefs it gives the planner, so host and planner derive it identically:new Lineage({ events, content, readPolicy: { declaredToolClasses: declaredToolClassesFrom(capabilities) } })(orcreateServeSurface({ declaredToolClasses: ... })in hydration). Addundeclared: "deny"to make a tool the capabilities do not list unreadable; pass the samereadPolicytocheckPlannerConformanceand 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;ReferencePlannerfences its own prompts rather than going through@loom/llm’sbuildRequest/guardRequest. - Two authorisation domains.
interpretActionis the one seam between a widget’s click and the Experience: anexperience.opaction is admitted only if it parses as anOperation, is notprompt/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.
reviseForDrifttakes theDriftRecords collected from one or more@loom-ui/hydrationDriftReports (their.driftarrays) and turns them into a revision request anyPlannercan 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? }(summaryis the model’s own description of the plan, kept for change delivery: UNTRUSTED text, bounded and stripped by core’sboundSummary, never an instruction); the report’sdecisionId,attempts,firstAttemptValid,modelIdand summedusageare what a caller stamps provenance from.CapabilityRef— one bindable tool:handle, optionalkind,toolName,toolVersion,toolSchemaHash, optionaldescription/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’sExecutionContext.decidea planner needs; any loomctxsatisfies 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 aDecideScopeto theTransientInvokerinterface@loom-ui/hydrationis written against, so its wrappers compose on top:memoInvoker(limitInvoker(scopeInvoker(scope), 4)). OnlysignalandonDeltaare forwarded;principal,asandresourcesare refused (the scope IS the identity; a silent drop would run a call as someone it did not ask to be),labelis ignored.memoInvokerkeys on every option exceptsignal, 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 configureprincipalon hydration / reshape when their invoker is ascopeInvoker— 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’sLlmProvider.completea model must satisfy, and its token-usage shape.PlannerMessage.contentis text OR parts (PlannerMessageContent, mirroring loom R5’sLlmMessageContent); the messages the planner itself writes are always text. A model that reads message content usesplannerMessageText(content)(the localtextOf: text and tool-result text, never[object Object]; atainted()wrapper throws). Type change: code that readmessage.contentas astringmust narrow or callplannerMessageText.planDecisionId(kind, input): string— thectx.decidekeycompose/reviseuse: every input that shapes the prompt (intent/request, catalogue CONTENT, the offered capabilities including their tool-authored text, callercontext, andsurface), deliberately excluding a capability’ssampleOutput(a new sample is a new revision, never a silent re-plan). Overloaded perkind:"compose"takes{ intent, catalog, capabilities, context?, surface? };"revise"takes{ versionHash, request, catalog?, capabilities?, context?, surface?, observedHandles? }.ReferencePlannercalls it to key its ownctx.decide; a custom planner can reuse it for the same replay-stability.
Reference planner
ReferencePlanner— implementsPlanner. Constructor:{ model: PlannerModel | ((scope: DecideScope) => PlannerModel), modelId: string, maxAttempts?: number, reach?, decisionKey?, orderSalt?: string, variants? }(default 2 attempts, parse/validation retries included).orderSaltis 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) }; }(forwardo: 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 callsplanner.compose(input, ctx)hands itsctxto 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 (themodelfactory is yours), so you declare it:new ReferencePlanner({ model, modelId, reach: { transient: ["llm.complete"] } })—transient= toolIds called throughscope.invokeTransient(the model tool, plus any hydration / MCP / sandbox tool your model factory or wrappers call),run= toolIds run throughctx.run/ctx.execute. Noreachoption 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 underSymbol.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 examplerandomBytes(32), stored base64 and decoded to aUint8Array) 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 theUint8Array. 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 (andvariants.rollout.bucketSecret) in private fields thatJSON.stringifyandutil.inspectdo not show. Why: the id is recorded in PLAINTEXT asDECISION_RECORDED.decisionIdand 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 recordscontext.base; with a key it is a keyedk:<32 hex>HMAC of the version hash instead of the raw (unkeyed SHA-256) hash. Unset = the unkeyed id and rawbase, 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 withFlowReplayDivergenceErrorunderdivergence: "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’sexperienceIdand itsversionHash,parents,revertedTo,carriedFromandfrom(versionHashis 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 variantunitand the catalogue id in a variant decision’s recorded context; the plannermodelId, 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 customPlanner(or any object a flow handsctxto).REFERENCE_POLICY,REFERENCE_SOURCE— the fixed{ id, version }policy stamp and decision source this planner records.scratchGenesis(intent)— a scratch-lineageExperiencefor 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 customPlannerthrough the contract a caller depends on: at least one attempt, a named model,firstAttemptValidagreeing with the attempts, replay-stableusage, and — withfx.ctxa recording context — that a secondcomposewith the same inputs replays rather than re-calls the model. Withfx.scopeProbe({ identity, calls() }, observing the scope behindfx.ctx) it also requires compose to decide throughctx.decideand its decider to call through the scope (scope-unusedeither way), every call to be stamped as the deciding execution (scope-identity-mismatch), and a replay to make none (replay-recalled-scope); calls made duringrevisemust be stamped as the deciding execution too, reported separately from a compose mismatch (whetherrevisemust call the scope at all is not checked). OmittingscopeProbemeans the scope contract is NOT checked.fx.ctxmust be fresh: a ctx that already holds the compose decision serves it without running the decider, reported asfixture-ctx-not-fresh(notscope-unused).calls()may return a live array. Withfx.denialProbe({ ctx, error, decisions() }: a second fresh recording context whose scope throwserror) it also requirescomposeto REJECT with that error and record nothing (denial-swallowed/denial-replaced/denial-recorded). Omitting it means the control-plane contract is NOT checked. Withfx.checkReach: true(needsscopeProbewhose calls carrytoolId) it also requires the planner’s declaration to list every toolId called through the scope during compose and revise undertransient(reach-undeclared;reach-unprobedwhen there is no probe). Omitting it means reach is NOT checked.ConformanceFixture— the fixture shape:intent,catalog,capabilities,meta, arevisionrequest, aDecideContext, and optionalmodelCalls()/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 oneWidgetAction.typethis 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) intoOperation[]against a base Experience, building every rebuiltProviderSpecalong 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 aPlanDraft.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 blocksReferencePlanneruses; 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 OWNreviseresult (extra fields preserved; onlyfindingsmay grow).driftRequest,driftContext,observedSamples,driftCapabilities,unsampledDrift— the exported helpersreviseForDriftcomposes, for a planner that must reproduce the flow by hand.DriftReviser<R>,DriftContextEntry— the minimal{ revise(...) }shapereviseForDriftneeds, and one fenced drift-context entry.
Usage accounting
readReportedUsage(reply),addUsage(a, b),totalUsage(attempts)— read a model reply’susage, fold it into a running total (refusing rather than overflowingNumber.MAX_SAFE_INTEGER), and sum an attempt list. Exactly whatReferencePlanneritself calls — a custom planner need not re-derive any of it.isPlannerUsage(u)— validate that a value has thePlannerUsageshape; exported for a caller’s own checks, not called byReferencePlanneritself.
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.decidestill records ONE value: the plan built from whatcompleteRETURNS — 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.deltais aTaintedValue<string>, ADR 0085); the planner unwraps them only to scan for completed nodes. A malformed delta is amodel-errorattempt, not a silent skip. A runtimeresetchunk (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
validateExperienceas the final; a partial that would not stand as a final plan is not emitted). Fold them with core’sOpStream(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 itssummaryClasscome only with the final result. - Bounded, linear.
NodeScannerscans 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 oncenodeshas closed. - Cancellation.
signalaborts the turn: it rejects withAbortErrorand records nothing. The planner races the call against the abort, so a model that ignores the signal cannot hold the turn. - Conformance.
checkPlannerConformancegainsstreamProbe(opt-in): partials arrive and apply, final equals whole-reply, replay recalls nothing, a cancelled turn rejects unrecorded. WithdenialProbetoo, the refusal is replayed withonPartialset (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).
describeCatalogadds 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 namingChatisunknown-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 ischat-prop-forbidden; a form’sfields/ a select’soptionsare a closed spec (no regular expressions, no defaults) and a bad one ischat-prop-shape; an unboundChatischat-unbound; an input with a binding isunexpected-binding.checkPlannerConformancereports a planner that gets one past validation ascompose-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).
describeCatalogadds 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 namingImageisunknown-type). A media node’s props are checked against the FRAMEWORK allow-list invalidateExperience(text props only): asrc/address/autoplayprop ismedia-prop-forbidden, a URL or content address as a prop’s whole value ismedia-literal-ref, an unbound display node ismedia-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 ascompose-invalid:<code>for any planner. Text props that merely EMBED a URL or address aremedia-embedded-ref(media nodes) /embedded-refwarnings (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": truein the capability’sinputSchema(any depth; grammar, marker choice and residuals in core’s README, “Content references as tool arguments”), and mark a producer’soutputSchemathe 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 tovalidateExperience, so a literal at a marked path ismedia-arg-literal, and a reference to an output its tool does not declare ismedia-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 amedia-arg-schema-unknownERROR. The model is told never to write one (leave it out, or keep an existing{{steps.<id>.output…}}that points at aproducesContentRefsoutput): the compose rules say so and each capability’s DATA block lists itscontentRefArgumentsandproducesContentRefs. The capability details (description,inputSchema,outputSchema, so theirexamples/default/enumtoo) are redacted of address-shaped strings like the samples: a string that is wholly a URL / locator / address goes whole, and thealgo:hexclass matcheslooksLikeLiteralRef(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 requiredcontentRefArgs(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 amedia-arg-suspecthint). -
Error semantics: refusals propagate, model failures become findings. A plain model failure (provider error, unparseable or invalid output,
TypedFailure) becomes amodel-error/model-output-invalidfinding, is retried up tomaxAttempts, 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) andAbortError(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 errorname, so a second copy of loom’s classes is still recognised. The set also names loom’sExecutionCancelledError/DecideScopeClosedError(its cancellation errors) and the refusalsExecutionDeniedError,CapabilityInvokeDeniedError,EffectModeViolationError,EgressDeniedError,SafetyViolationError,UntrustedContentErrorand loom’s control-plane limitsRateLimitExceededError,KillSwitchEngagedErrorandSpawnLimitExceededError. AnAbortErrorthrown by the provider itself (say its own request timeout) is therefore ALSO propagated and fails the flow: anAbortErroris 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 asisControlPlaneError(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 OWNError(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’sExecutionCancelledError) or an error namedAbortError. -
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.
setRoleis never interactive.interpretActionrefusessetRolefrom 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
materializeor 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.
observedSampleis raw tool output; an app’sonDrifthook must never forward it (or aDriftReportcarrying it) to a client. reviseForDriftdoes not loop. It callsreviseonce; 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
DriftReportreviseForDriftrepairs. - ../../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.