Limits and diagnostics

RuntimeLimits and fixed bounds for untrusted input, how to set them, and the optional diagnostics feature for redacted error reports.

Resolution is bounded: a hostile or accidental document set ends in DtcgError::LimitExceeded, never in unbounded memory, time or stack use. This page lists the bounds, the expression limits, how to change them and the optional diagnostics feature.

#Runtime limits

RuntimeLimits is #[non_exhaustive]: start from RuntimeLimits::default() and use the chainable with_* setters. Limits are fixed when the runtime is created.

LimitDefaultSetterBoundsError kind
max_documents256with_max_documentsPer discovery, counted separately: the source occurrences collected (loaded and inline sources, and missing-document requests) and the expansion steps (each set reference, local reference, document load and nested resolver, even one yielding no source)"documents"
max_reference_depth64with_max_reference_depthNesting of resolver and source references"reference depth"
max_alias_depth64with_max_alias_depthReference hops in one chain: aliases, token pointers and document pointers. Literal values cost nothing, so a limit of 0 still resolves tokens without references"alias depth"
max_value_nodes4,000,000with_max_value_nodesJSON nodes that references copy into resolved values in one resolution, checked before each copy"value nodes"
max_tokens100,000with_max_tokensFlattened tokens, $extends copies included, and separately the JSON nodes $extends copies"tokens"

Every source counts toward max_documents, inline ones included, so one set with 300 inline sources exceeds the default. A small resolver whose sets reference each other many times also fails even when every set is empty. Inspection and preview calls apply the same limits; preview occurrences count together with the root’s sources.

#Fixed bounds

Two bounds are not configurable. They match serde_json’s parse recursion limit, so no authored document reaches them except through references or $extends:

BoundValueError kind
Nesting depth of a resolved value, an expression’s result included128"value depth"
Segments of a flattened token or group path128"path depth"

Together with the limits they keep every algorithm iterative or bounded: a chain of 100,000 pointers fails with alias depth instead of overflowing the stack.

#Expression limits

With the expressions feature and RuntimeOptions::with_expressions, ExpressionOptions bounds the JavaScript engine of each resolution (the sandbox). It is #[non_exhaustive]: start from ExpressionOptions::default() and use the with_* setters. Expression code cannot catch any of these limits, and the same runtime resolves normally afterwards.

OptionDefaultSetterBoundsError
step_budget1,000with_step_budgetQuickJS interrupt checks (about every 10,000 function calls and loop iterations) of one evaluation: an expression call, a module’s top-level code, or an import cycle’s as a whole. Counted, not timed, so it is deterministic; below 1 counts as 1LimitExceeded { kind: "expression steps" }
memory_limit64 MiBwith_memory_limitBytes the JavaScript engine of one resolution allocatesLimitExceeded { kind: "expression memory" }
stack_limit256 KiBwith_stack_limitNative stack JavaScript code uses below the resolving call; at least 16 KiBExpression (stack overflow)
precisionSome(6)with_precisionDecimal places every non-integral number of a result is rounded to; None keeps results unroundednone

One bound is fixed. The JavaScript and TypeScript compiler works recursively, so the text of every expression and module may nest about 120 brackets, 40 function bodies or 300 chained operators deep, and regular expression literals 20 groups; deeper text fails with Expression (the source nests too deeply) before the compiler sees it. Patterns built at run time nest at most 20 groups too; a deeper one throws a SyntaxError the code can catch, whatever stack is left.

Expressions also charge the runtime limits: input references count toward max_alias_depth, inputs and results toward max_value_nodes, results toward the 128-level value depth. Module documents are a separate count against max_documents: one resolution loads at most that many, besides its JSON documents.

Unoptimized QuickJS uses several times more stack per call (and an MSVC build more than a Clang or GCC one), so in a debug build the same stack_limit allows much shallower recursion. Build the engine optimized in every profile, in your workspace’s Cargo.toml:

[profile.dev.package.quickjs-jit-sys]
opt-level = 3

#Setting limits

Pass limits through DtcgRuntime::with_limits, or together with value extensions through RuntimeOptions. DtcgRuntime::limits() returns what the runtime uses.

use dxn_dtcg::{DtcgError, DtcgRuntime, RuntimeLimits, RuntimeOptions, ValueExtensions};

fn main() -> Result<(), DtcgError> {
    let mut runtime = DtcgRuntime::with_limits(RuntimeLimits::default().with_max_documents(4));
    assert_eq!(runtime.limits().max_documents, 4);
    runtime.provide_json(
        "r.json",
        r##"{
          "version": "2025.10",
          "sets": { "empty": { "sources": [] } },
          "resolutionOrder": [{ "type": "set", "name": "fan", "sources": [
            { "$ref": "#/sets/empty" }, { "$ref": "#/sets/empty" }, { "$ref": "#/sets/empty" },
            { "$ref": "#/sets/empty" }, { "$ref": "#/sets/empty" }
          ] }]
        }"##,
    )?;
    runtime.set_root("r.json")?;
    // The resolver and five set references are six expansion steps.
    assert!(matches!(
        runtime.resolve(&[]),
        Err(DtcgError::LimitExceeded { kind: "documents", limit: 4 })
    ));

    // Limits and value extensions together.
    let options = RuntimeOptions::default()
        .with_limits(RuntimeLimits::default().with_max_alias_depth(16))
        .with_extensions(ValueExtensions::all());
    let runtime = DtcgRuntime::with_options(options);
    assert_eq!(runtime.limits().max_alias_depth, 16);
    assert!(runtime.options().extensions.blur);
    Ok(())
}

from_json uses the default limits; from_json_with_options takes RuntimeOptions. Expression limits travel inside RuntimeOptions::with_expressions(ExpressionOptions::default().with_step_budget(..)).

#The diagnostics feature

[dependencies]
dxn_dtcg = { git = "https://github.com/dXnSix/dXn.Dtcg", tag = "v1.1.0", features = ["diagnostics"] }

The feature is off by default and adds two things for test harnesses, telemetry and crash reports.

#DtcgDiagnostic

DtcgDiagnostic::from_error(&error) turns an error into a stable code plus the logical location. It keeps the resource id, path and message and never copies document text or JSON values.

use dxn_dtcg::{DtcgDiagnostic, DtcgDiagnosticCode, DtcgRuntime};

fn main() {
    let runtime = DtcgRuntime::from_json(
        "tokens.json",
        r#"{ "a": { "$type": "number", "$value": "{b}" }, "b": { "$type": "number", "$value": "{a}" } }"#,
    )
    .unwrap();
    let diagnostic = DtcgDiagnostic::from_error(&runtime.resolve(&[]).unwrap_err());

    assert_eq!(diagnostic.code(), DtcgDiagnosticCode::Token);
    assert_eq!(diagnostic.resource().unwrap().as_str(), "tokens.json");
    assert_eq!(diagnostic.path(), Some("a"));
    assert_eq!(diagnostic.message(), "alias cycle: a -> b -> a");
}

DtcgDiagnosticCode is #[non_exhaustive] and maps from DtcgError like this:

CodeFromresource()path()message()
InvalidResourceIdInvalidResourceIdnonenonethe rejected id
InvalidPointerInvalidPointernonenonepointer or description
InvalidJson, InvalidDocumentsame-named variantsresourcenonemessage
UnknownDocument, DuplicateDocumentsame-named variantsresourcenoneempty
LimitExceededLimitExceedednonethe limit kindthe limit value
DocumentCycleDocumentCyclenonethe resource idempty
TokenToken and TokenTyperesourcetoken pathmessage
UnresolvedReferenceUnresolvedReferenceresourcereferring pathunresolved reference `<target>`: <message>
ProjectionProjectionnonetoken pathmessage
ReferenceInvalidReferenceresourcethe referencemessage
SelectionUnknownModifier, UnknownContextnonethe modifierthe full Display text
InvalidInputInvalidInputnonenonemessage
ExpressionExpressionresourcetoken path, or module specifieronly the error’s kind and authored position, such as TypeError at line 2, column 9

Messages the runtime writes contain ids, paths and short reasons, never document bodies. An expression error’s JavaScript message may quote document text or thrown values, so its diagnostic keeps only the kind and position. A message from your own conversion function passes through unchanged.

#AffectedResolution::nodes_visited

Returns how many tokens an affected re-resolution resolved, including unaffected tokens that affected ones depend on. Tests use it to check that an edit stayed local.

Next: API reference, or Conformance.