Skip to main content
← Back to list
01Issue
BugShippedSwamp CLITeam
Assigneesstack72

Relationships

#1399 runModel breadth limit (MAX_INVOCATION_BREADTH=100) is never enforced — tracking object is never written back to the caller context; verified 150/150 calls succeeded

Opened by magistr · 7/24/2026· Shipped 7/24/2026

Summary

MAX_INVOCATION_BREADTH (100) is never enforced for a top-level model method. The invocation-tracking object is created with ?? { … count: 0 } and never written back to the caller's context, so every context.runModel() call from the same method starts from a fresh counter of zero. A model can make unlimited nested invocations.

Verified: a model making 150 runModel calls — against a documented cap of 100 — completed all 150 with zero failures.

Verified reproduction

Throwaway repo, two trivial extension models. The target:

export const model = {
  type: "@test/target",
  version: "2026.07.25.1",
  globalArguments: z.object({}),
  resources: {},
  methods: {
    noop: { arguments: z.object({}), execute: async () => ({ dataHandles: [] }) },
  },
};

The caller:

export const model = {
  type: "@test/caller",
  version: "2026.07.25.1",
  globalArguments: z.object({}),
  resources: {},
  methods: {
    fanout: {
      arguments: z.object({ count: z.number() }),
      execute: async (args, context) => {
        let ok = 0, failed = 0, lastErr = "";
        for (let i = 0; i < args.count; i++) {
          const r = await context.runModel({ definition: "tgt", method: "noop", arguments: {} });
          if (r && r.ok === false) { failed++; lastErr = JSON.stringify(r.error); }
          else ok++;
        }
        console.log("BREADTH_RESULT ok=" + ok + " failed=" + failed + " lastErr=" + lastErr);
        return { dataHandles: [] };
      },
    },
  },
};
swamp model create @test/target tgt
swamp model create @test/caller callr
swamp model method run callr fanout --input count=150

Output:

BREADTH_RESULT ok=150 failed=0 lastErr=
Method "fanout" completed on "callr"

Every call succeeded. Calls 101 through 150 should have been refused.

Cause

src/domain/models/model_invocation_service[dot]ts:

const MAX_INVOCATION_DEPTH = 10;
const MAX_INVOCATION_BREADTH = 100;const tracking = callerContext._invocationTracking ?? {
  depth: 0,
  ancestors: new Set<string>(),
  breadthCounter: { count: 0 },
};

The freshly-created object is assigned to a local and never stored on callerContext. The only production write to that field is on the child context (childContext._invocationTracking = childTracking), so a nested call inherits a counter but the caller never keeps one.

Consequence: for a top-level method, callerContext._invocationTracking is undefined on every single call, a new breadthCounter is minted each time, count is incremented to 1, and the guard if ([HOST-1] > MAX_INVOCATION_BREADTH) can never be true. The limit only constrains invocations made within an already-nested subtree.

MAX_INVOCATION_DEPTH is unaffected — depth is propagated to the child, so recursion is still bounded at 10.

Why the tests do not catch it

Every breadth test pre-seeds the field that production never sets, e.g. makeStubContext({ _invocationTracking: tracking }) — so they exercise a state the running system never reaches. More pointedly, one test asserts:

assertEquals(ctx._invocationTracking, undefined);

i.e. the suite explicitly pins "the caller's context is left untouched" as correct behaviour, which is exactly the defect. A test that calls invoke 101 times with a genuinely fresh context and expects a refusal would fail today.

Impact

The stated protection against runaway cross-model automation is absent. A buggy loop — or a hostile extension — can drive unbounded nested invocations from a single method call, each one a full model execution with its own data writes, reports and lock acquisitions. Since nested calls also take no per-model lock and produce no ModelOutput record, a runaway fan-out is both unthrottled and largely invisible in run history.

The failure mode is resource exhaustion rather than data loss or privilege escalation. Severity depends on how much the limit was being relied upon as a safety property of the runModel feature.

Suggested fix

One line — persist the tracking object on first use:

callerContext._invocationTracking ??= { depth: 0, ancestors: new Set<string>(), breadthCounter: { count: 0 } };
const tracking = callerContext._invocationTracking;

Note this alone still leaves the counter reachable by extension code, since _invocationTracking lives on the same mutable context object handed to execute — a model can reset its own limits. If the breadth cap is meant to be a real bound rather than a guardrail against accidents, the counter should live somewhere the executed code cannot reach (e.g. keyed off the execution in the service itself).

Environment

  • Source main @ 10943ef, run from source. macOS 14.
  • Feature shipped in 4b5be56 (#1810), 2026-07-10.
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED+ 1 MOREASSIGNED+ 2 MOREREVIEW+ 4 MOREPR_MERGED+ 1 MORECONTRIBUTOR_NOTIFIED

Shipped

7/24/2026, 11:44:40 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
stack72 assigned stack727/24/2026, 11:13:45 PM
Editable. Press Enter to edit.

magistr commented 7/24/2026, 11:09:41 PM

Redaction repair — one dotted expression was read as a hostname. The guard named in that sentence is the breadth check: it compares the count field of the tracking object's breadthCounter against MAX_INVOCATION_BREADTH, and refuses when the count exceeds it. Because a fresh counter is minted on every top-level call, that count is always 1 at the comparison, so the condition is never true.

stack72 commented 7/24/2026, 11:44:48 PM

Thanks @magistr for reporting this! The fix has been merged and a release is on its way. We appreciate your contribution to swamp.

Sign in to post a ripple.