Cross-model expression validator fails on lazy-loaded types — modelRegistry.get() bypasses ensureTypeLoaded
Opened by keeb · 4/11/2026· Shipped 4/11/2026
Summary
When a model definition contains a CEL expression that references another model's definition (e.g. ${{ model.llm.definition.globalArguments.ollamaUrl }}), the validator can fail with Unknown model type "<type>" for model "<name>" even though that type is fully registered and works at execution time. The cause is that validateModelPathReference calls modelRegistry.get(result.type) directly without first awaiting modelRegistry.ensureTypeLoaded(result.type). If the referenced type is currently in the lazy registry (catalog-known but not yet imported into the in-process map), get() returns undefined and the validator emits the misleading "Unknown model type" error. Meanwhile swamp model type search finds the type (because types() includes lazy entries) and any actual method invocation against the same type succeeds (because the execution path triggers the lazy load).
Root Cause
src/domain/models/model.ts:943 — ModelRegistry.get() only consults this.models, never this.lazyTypes:
get(type: string | ModelType): ModelDefinition | undefined {
const modelType = typeof type === "string" ? ModelType.create(type) : type;
return this.models.get(modelType.normalized);
}But has() at model.ts:954 checks both maps. So has(type) === true while get(type) === undefined is a perfectly normal state for any lazy-registered type that hasn't been imported yet.
src/domain/models/validation_service.ts:685 — validateModelPathReference is async, but bypasses the lazy loader:
const targetDefinition = modelRegistry.get(result.type);
if (!targetDefinition) {
return {
expression: ref.rawExpression,
error: `Unknown model type "${result.type.normalized}" for model "${ref.modelRef}"`,
};
}Result: every model whose definition references model.<otherInstance>.definition.<...> for an instance of a lazy type is unvalidatable until something else in the same process incidentally loads that type first.
Steps to Reproduce
In a swamp repo, create or have two model instances where one references the other via cross-model CEL:
# llm instance — type @keeb/ollama type: "@keeb/ollama" name: "llm" globalArguments: ollamaUrl: "http://localhost:11434" model: "qwen3:14b"
# organizer instance — references model.llm.definition type: "@keeb/mms/organizer" name: "organizer" globalArguments: ollamaUrl: ${{ model.llm.definition.globalArguments.ollamaUrl }} ollamaModel: ${{ model.llm.definition.globalArguments.model }}
Make sure
@keeb/ollamais lazy-registered (e.g. it's in the bundle catalog but hasn't been touched in this CLI invocation yet).Run a workflow whose first step calls
organizer.<anyMethod>directly — without any preceding step that touches thellminstance.Observe: validation fails with
Model validation failed for "organizer": Expression paths: - model.llm.definition.globalArguments.model Unknown model type "@keeb/ollama" for model "llm" - model.llm.definition.globalArguments.ollamaUrl Unknown model type "@keeb/ollama" for model "llm"Run a separate step that touches the
llmmodel first (e.g.llm.unload), then run the original step. It now succeeds — because the lazy load fired as a side effect, populatingthis.models.
I hit this in a workflow where organize-and-clean.organize.process-queue (which calls organizer.process) failed with the above error, while organize-and-clean.unload-llm.unload-model (which calls llm.unload) ran successfully ~3 ms later in the same workflow run. The type is identical in both cases.
Expected Behavior
validateModelPathReference should ensure the referenced type is fully loaded before calling get(). It is already async, and ensureTypeLoaded() already exists for exactly this purpose.
Suggested Fix
src/domain/models/validation_service.ts:703 — one line, no refactor needed:
// Ensure the referenced type is fully loaded — without this,
// lazy-registered types (catalog-known but not yet imported) cause
// get() to return undefined even though has() reports them as registered.
await modelRegistry.ensureTypeLoaded(result.type);
const targetDefinition = modelRegistry.get(result.type);I patched this locally, recompiled (deno task compile), and the failing workflow now runs end-to-end with no other changes.
A secondary hardening worth considering: any other call site in swamp that does modelRegistry.get(...) after only checking has(...) (or without checking at all) has the same latent bug. A grep for modelRegistry.get( is the easy audit. Alternatively, get() could itself call ensureTypeLoadedSync or change its contract to only return loaded entries and document ensureTypeLoaded as a precondition.
Environment
- swamp from
~/git/swamp(locally built) - Affected file:
src/domain/models/validation_service.ts:685-711 - Related:
src/domain/models/model.ts:692-758(ModelRegistry,ensureTypeLoaded,get,has) - Reproduction repo:
~/git/swamp-media, workfloworganize-and-clean, modelsorganizer(@keeb/mms/organizer) andllm(@keeb/ollama)
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.