Skip to main content
← Back to list
01Issue
BugShippedSwamp CLI
AssigneesNone

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:943ModelRegistry.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:685validateModelPathReference 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

  1. 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 }}
  2. Make sure @keeb/ollama is lazy-registered (e.g. it's in the bundle catalog but hasn't been touched in this CLI invocation yet).

  3. Run a workflow whose first step calls organizer.<anyMethod> directly — without any preceding step that touches the llm instance.

  4. 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"
  5. Run a separate step that touches the llm model first (e.g. llm.unload), then run the original step. It now succeeds — because the lazy load fired as a side effect, populating this.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, workflow organize-and-clean, models organizer (@keeb/mms/organizer) and llm (@keeb/ollama)
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPEDTRIAGECLASSIFICATION+ 3 MOREPR_MERGEDSHIPPED

Shipped

4/11/2026, 11:52:10 PM

Click a lifecycle step above to view its details.

03Sludge Pulse

Sign in to post a ripple.