{
  "dsl": "minigraph-commands",
  "version": "1.0",
  "description": "Machine-readable command catalog for the MiniGraph Playground DSL (Rust port of mercury-composable). Mirrors command-reference.md. An AI agent should ingest this to generate and validate commands deterministically before dispatching them via the companion endpoint (POST /api/graph/{id} for execution; POST /api/companion/{session-id} for live Playground commands, fire-and-forget; POST /api/companion/{session-id}/sync for a SYNCHRONOUS response returning the command outcome in-band — preferred for AI agents).",
  "source_of_truth": "docs/guides/knowledge-graph/command-reference.md (the engine serves the same content in-band via the 'help {topic}' and 'describe skill {route}' commands)",
  "sync_envelope": {
    "shape": "{ ok: bool, id: string, command: string, output: [console lines], error: string?, result: [structured]? }",
    "notes": [
      "null fields are OMITTED from the wire (serializer null-omission): a success carries no 'error' key and, unless the command yields data, no 'result' key — treat absent as null",
      "the ok flag is derived from the console lines with whole-output context: import's benign 'Graph model not found in /tmp/... Found deployed graph model' fallback reports ok:true; a genuine miss stays ok:false",
  "a malformed command answered with a 'Syntax: ...' usage hint is a FAILURE: ok:false with the hint as the error (the command did nothing)",
  "sync commands are never dedup-dropped: repeating an identical command back-to-back executes every time (the 1-second duplicate guard protects only the WebSocket UI path)",
      "status codes: 200 executed (read ok/error in the body); 400 missing/empty/non-text body; 404 no active session for that id",
      "session-topology commands (session subscribe/unsubscribe/reset) are rejected on both companion endpoints: sync returns ok:false with the reason, the fire-and-forget endpoint returns 400"
    ]
  },
  "lexical": {
    "node_name": "lowercase letters, digits and hyphen (e.g. fetcher-1, person-name); hyphenated names are communicative and safe everywhere - the {node-name.key} substitution syntax in graph.math expressions is robust to hyphens (never parsed as a minus)",
    "node_type": "a descriptive label; examples capitalize structural types (Root, End, Provider, Dictionary, Fetcher, Island)",
    "reserved_names": { "root": "the root/entry node must be named 'root'", "end": "the end/exit node must be named 'end'" },
    "property": "key=value; keys may be composite using dot-bracket notation",
    "composite_keys": "every mapping source and target addresses nested data with dot-bracket keys, INCLUDING numeric list indices on the target side ('fetch-one.result.profile -> output.body.profile[0]' sets list slot 0 - the deterministic post-join assembly idiom); a non-leaf (interior) path maps the WHOLE subtree ('response.profile -> result.profile' captures the entire object, 'response.accounts' an entire array)",
    "array_append": "an EMPTY index in a target appends one element to the list, creating the list with the first element when absent ('model.fetcher-one -> output.body.profile[]'); data mapping is thread-safe, so concurrent appends from parallel branches carry no racing risk - but element order follows completion order (undetermined across branches); use [] when order does not matter, numeric indices when it must be deterministic",
    "list_property": "a 'key[]=entry' line appends one entry to the list 'key'; repeat to add more",
    "multiline_value": "wrap a value in triple single quotes ''' ... ''' to span lines",
    "constant": "type(value) — the CLOSED set in the 'constants' object below; no other constant/coercion form exists (the legacy ':type' suffix is deprecated — never generate it)",
    "mapping_operator": "source -> target (left is source, right is target); an UNRESOLVABLE source skips the entry (target left untouched, not nulled) - use f:defaultValue(...) or default-then-overlay for defaults",
    "variable_substitution": "{namespace.key} inside COMPUTE/IF expressions, e.g. {book.price}"
  },
  "constants": {
    "text(hello world)": "string (verbatim - spaces preserved exactly, incl. leading/trailing inside the parentheses: text(Hello ) keeps its trailing space)",
    "int(100) | long(10000000000)": "integer (non-numeric -> -1; decimal part dropped)",
    "float(1.5) | double(1.5)": "floating-point number",
    "boolean(true)": "boolean — true only for case-insensitive 'true'; anything else is false",
    "map(k1=v1, k2=v2)": "inline map literal (string values)",
    "map(config.key)": "the value of an application-configuration key",
    "file(text:/tmp/f.txt) | file(json:...) | file(binary:...)": "file content as text / parsed JSON / bytes - read at MAPPING-EVALUATION time (each execution), so a changed file is picked up by the next run",
    "classpath(text:/data/f.txt)": "like file(), resolved against the app's resource roots",
    "_valid_where": "any mapping SOURCE: mapping[]/input[]/output[] left-hand side, instantiate seed lines",
    "_deprecated": "the ':type' suffix (simple type matching) is deprecated and must not be generated; a ':' in a Dictionary input[] entry is a DEFAULT VALUE only",
    "_non_constant_sources": "two further source forms are valid in mappings (shared Event Script mapping engine): 'f:plugin(args...)' simple-plugin invocations (ARGS accept any mapping source: constants and any state-machine path incl. node namespaces like {node}.result.{key} - engine-verified; the modern :type replacement - e.g. generators f:uuid() and f:now(text(local)) for the current date-time at execution (iso/local/ms), arithmetic f:add(...) and f:round(value, int(2)) for half-up decimal rounding, logic f:ternary(...), list/map reshapers f:removeKey(list, text(key)) to strip fields from every map in a list and f:listOfMap(...) for maps-of-lists -> list-of-maps (ORDER-PRESERVING: row i is built from index i of every array) and f:length(...) for element counts; FULL catalog in guides/event-script/syntax.md#simple-plugins) and '$....' JSONPath (prefer plain dot-bracket keys; a wildcard search like $.input.body.items[*].price yields a LIST of the matched values)"
  },
  "traversal": {
    "fork": "a node with multiple outgoing connections forks traversal into PARALLEL branches (one per connection, executed concurrently)",
    "join": "synchronize parallel branches with a graph.join barrier node; without one, traversal proceeds as each branch completes",
    "state_safety": "parallel branches must write to DISJOINT state keys (e.g. per-branch model.* variables) to avoid concurrent-write collisions"
  },
  "namespaces": [
    { "name": "input.body", "read": true, "write": "seeded at instantiate", "meaning": "incoming request body" },
    { "name": "input.header", "read": true, "write": "seeded at instantiate", "meaning": "incoming request headers" },
    { "name": "model.*", "read": true, "write": true, "meaning": "intermediate working state" },
    { "name": "output.body", "read": true, "write": true, "meaning": "response body" },
    { "name": "output.header", "read": true, "write": true, "meaning": "response headers" },
    { "name": "{node}", "read": true, "write": true, "meaning": "a node's own properties" },
    { "name": "{node}.result", "read": true, "write": "by skill", "meaning": "a skill's output" },
    { "name": "{node}.status", "read": true, "write": "by engine", "meaning": "skill execution status; for graph.api.fetcher ALWAYS the HTTP status of the fetch, success included (a 200 or a 301 is readable here, not just failures)" },
    { "name": "{node}.error", "read": true, "write": "by engine", "meaning": "skill error, if any" },
    { "name": "response.*", "read": "in a Dictionary output[]", "write": "by fetch", "meaning": "a data Provider's raw HTTP response body - BODY ONLY (status lives at {node}.status; response headers at {node}.header.response.* when the Provider declares feature[]=log-response-headers)" },
    { "name": "result.*", "read": true, "write": "by skill", "meaning": "a Dictionary/Fetcher result set" }
  ],
  "commands": [
    { "name": "create node", "multiline": true, "help": "help create.md",
      "syntax": "create node {name}\nwith type {type}\nwith properties\n{key}={value}",
      "notes": ["properties optional (used as defaults)", "zero or one skill via skill={route}", "name lowercase letters, digits and hyphen (root/end reserved); type is a descriptive label, conventionally Capitalized (Root, Fetcher, Module)"],
      "example": "create node end\nwith type End\nwith properties\nskill=graph.data.mapper\nmapping[]=text(hello world) -> output.body" },
    { "name": "update node", "multiline": true, "help": "help update.md",
      "syntax": "update node {name}\nwith type {type}\nwith properties\n{key}={value}",
      "notes": ["same shape as create node; replaces the node definition"],
      "example": "update node end\nwith type End\nwith properties\nskill=graph.data.mapper\nmapping[]=input.body -> output.body" },
    { "name": "connect", "multiline": false, "help": "help connect.md",
      "syntax": "connect {node-a} to {node-b} with {relation}",
      "notes": ["directional: a->b differs from b->a"],
      "example": "connect root to end with done" },
    { "name": "delete node", "multiline": false, "help": "help delete.md",
      "syntax": "delete node {name}", "example": "delete node fetcher" },
    { "name": "delete connection", "multiline": false, "help": "help delete.md",
      "syntax": "delete connection {node-a} and {node-b}", "example": "delete connection root and end" },
    { "name": "instantiate graph", "multiline": true, "help": "help instantiate.md", "alias": "start",
      "syntax": "instantiate graph\n{constant} -> input.body.{key}",
      "notes": ["required before run/execute/inspect", "seed lines optional", "seed keys may be composite (dot-bracket), so nested mock payloads seed directly: text(Peter) -> input.body.profile.name", "issuing instantiate again REPLACES the current instance (fresh state machine, cleared run marks) - the standard idiom for a second dry-run with different input"],
      "example": "instantiate graph\nint(100) -> input.body.profile_id\ntext(application/json) -> input.header.content-type" },
    { "name": "run", "multiline": false, "help": "help run.md",
      "syntax": "run", "notes": ["traverse from root to end"], "example": "run" },
    { "name": "execute", "multiline": false, "help": "help execute.md",
      "syntax": "execute {node}", "notes": ["run a single node after instantiate"], "example": "execute fetcher" },
    { "name": "inspect", "multiline": false, "help": "help inspect.md",
      "syntax": "inspect {namespace.key}", "example": "inspect output.body",
      "notes": ["{namespace.key} is a placeholder - substitute the key and do not type the braces", "inspect output|input|model reads a whole namespace"] },
    { "name": "describe graph", "multiline": false, "help": "help describe.md", "syntax": "describe graph", "example": "describe graph" },
    { "name": "describe node", "multiline": false, "help": "help describe.md", "syntax": "describe node {name}", "example": "describe node end" },
    { "name": "describe connection", "multiline": false, "help": "help describe.md", "syntax": "describe connection {node-a} and {node-b}", "example": "describe connection root and end" },
    { "name": "describe skill", "multiline": false, "help": "help describe.md", "syntax": "describe skill {skill.route}", "example": "describe skill graph.api.fetcher" },
    { "name": "list nodes", "multiline": false, "help": "help list.md", "syntax": "list nodes", "example": "list nodes" },
    { "name": "list connections", "multiline": false, "help": "help list.md", "syntax": "list connections", "example": "list connections" },
    { "name": "list graphs", "multiline": false, "help": "help list.md", "syntax": "list graphs",
      "notes": ["DISCOVERY (read-only): enumerates the deployable graph models - valid extension={graph-id} delegation targets - each with its root node's 'purpose' (living documentation); the footer points at 'describe graph {graph-id}' for a model's contract"],
      "example": "list graphs" },
    { "name": "describe graph (deployed)", "multiline": false, "help": "help describe.md", "syntax": "describe graph {graph-id}",
      "notes": ["DISCOVERY (read-only): the CONTRACT VIEW of a deployed graph model - purpose, node/connection counts, and the input.*/output.* data surface derived from the model's own mappings - wire extension= delegation input[]/output[] from it, no trial execution needed", "plain 'describe graph' (no id) still describes the CURRENT DRAFT"],
      "example": "describe graph tutorial-3" },
    { "name": "list flows", "multiline": false, "help": "help list.md", "syntax": "list flows",
      "notes": ["DISCOVERY (read-only): enumerates the Event Script flows - valid extension=flow://{flow-id} delegation targets"],
      "example": "list flows" },
    { "name": "seen", "multiline": false, "help": "help seen.md", "syntax": "seen", "example": "seen" },
    { "name": "export graph", "multiline": false, "help": "help export.md",
      "syntax": "export graph as {name}", "notes": ["fails if any node is an orphan"], "example": "export graph as my-first-graph" },
    { "name": "import graph", "multiline": false, "help": "help import.md", "syntax": "import graph from {name}", "example": "import graph from my-first-graph" },
    { "name": "import node", "multiline": false, "help": "help import.md", "syntax": "import node {node} from {name}", "example": "import node fetcher from tutorial-3" },
    { "name": "session", "multiline": false, "help": "help session.md",
      "syntax": "session | session subscribe {id} | session unsubscribe | session reset",
      "notes": ["topology subcommands (subscribe/unsubscribe/reset) work only from a WebSocket-connected session — the companion REST endpoints reject them (a companion is an assistant to a session, not a session of its own); only the read-only 'session' status query is available there"],
      "example": "session subscribe ws-178443-2" },
    { "name": "help", "multiline": false, "help": "help.md",
      "syntax": "help | help {command} | help {topic} | help {skill-topic}",
      "notes": ["prints the engine's shipped help page in-band (same content as 'describe skill {route}' for a skill)",
                "topics are lowercase: commands by name, skills hyphenated (graph-api-fetcher, graph-math), concepts (data-dictionary, session)",
                "aliases: 'help start' -> 'help instantiate'; 'help clear' -> 'help delete'"],
      "example": "help data-dictionary" }
  ],
  "node_types": [
    { "type": "Root", "structural": true, "name_must_be": "root" },
    { "type": "End", "structural": true, "name_must_be": "end" },
    { "type": "Dictionary", "structural": false, "role": "external attribute definition (config)" },
    { "type": "Provider", "structural": false, "role": "external endpoint definition (config)" },
    { "type": "<descriptive>", "structural": false, "role": "label validated by the node's skill, if any" }
  ],
  "skills": [
    { "route": "graph.data.mapper", "help": "help graph-data-mapper.md", "required": ["mapping[]"], "optional": [],
      "notes": ["mapping[] entries apply IN ORDER within the node - a later entry may read an earlier entry's target (the chain idiom: ingest -> transform -> publish inside one mapper)"],
      "example": "skill=graph.data.mapper\nmapping[]=input.body.hr_id -> employee.id" },
    { "route": "graph.math", "help": "help graph-math.md", "required": ["statement[]"], "optional": ["for_each[]", "NEXT:", "DELAY:", "BEGIN", "END"],
      "statement_types": ["COMPUTE", "IF", "MAPPING", "EXECUTE", "RESET"],
      "statement_syntax": {
        "COMPUTE": "COMPUTE: {var} -> {expr}   (JS-like; {namespace.key} substitution; result stored in this node's result namespace, read as {this-node}.result.{var})",
        "IF": "multi-line decision — MUST include THEN and ELSE or the run aborts ('node X does not have if:, then: or else:'):\nIF: <boolean expression>\nTHEN: <node-name> | next\nELSE: <node-name> | next\n(THEN/ELSE name the node to jump to, or the keyword 'next' to fall through; append the triad as one '''...''' multi-line value. A taken NODE-JUMP ends the statement list immediately - later statements do not run; a 'next' branch falls through and processing continues)",
        "MAPPING": "MAPPING: source -> target   (identical to graph.data.mapper; no {} braces around source/target)",
        "EXECUTE": "EXECUTE: {node-name}   (run another graph.math node's statements inline, IN THE CALLING NODE'S CONTEXT: any COMPUTE results land in the INVOKING node's result namespace ({invoker}.result.{var}); the executed module's own namespace stays empty. This is the module-reuse mechanism: author a formula once in an off-path Module node reading neutral model.* operands; a caller marshals inputs with MAPPING, EXECUTEs the module, then maps ITS OWN result out - e.g. MAPPING: compute.result.sum -> output.body.sum, NOT addition.result.sum. Hang the module under the island (island -[module]-> {module}) so no node is left unconnected)",
        "RESET": "RESET: {node-name}[, {node-name} ...]   (clear the run-once guard, the COMPLETION MARK and the state of one or MORE nodes - a reset branch stops satisfying a graph.join barrier until it re-executes successfully, and a branch that FAILED into its exception= route never satisfies it (completion is success-only) - comma/space list; resetting a never-executed node is a safe no-op; a node may reset ITSELF - the run-once mark is set BEFORE execution, so a self-reset survives and the node can run again. PLACEMENT RULE: put RESET FIRST among the action statements - it then runs on every path (a later taken IF jump would skip it) and everything the node stores afterwards (e.g. DELAY:'s pending pause) survives the self-wipe; the ONE exception: keep it AFTER any statement that reads state it would wipe - an IF on a just-wiped variable (e.g. {fetcher.status} after RESET: fetcher) ABORTS the run, so a defensive status check goes before the RESET. Mind the loop guard: >10 visits/second aborts the traversal - bound retry loops and pace with DELAY:)"
      },
      "notes": [
        "NEXT:/DELAY:/RESET: are ordinary statement[] lines (e.g. statement[]=NEXT: fetcher, statement[]=DELAY: 500)",
        "NEXT: takes a NODE NAME (not a connection/relation label) — unconditional jump applied AFTER the whole statement list completes (later statements still run; the last NEXT wins)",
        "DELAY: {ms} pauses AFTER this node completes, deferring the walk to the next node (paces retries)",
        "BEGIN/END delimit the loop body for for_each[] iteration — they are NOT IF-block braces (without for_each[] they are accepted and ignored)",
        "a node with only MAPPING statements is rejected — use graph.data.mapper instead"
      ],
      "for_each": {
        "entry": "for_each[]=source -> model.{var}   (RHS MUST be a model.* key)",
        "binding": "a LIST-valued source is an iteration array: model.{var} is rebound to element i each pass; multiple list entries advance in LOCKSTEP (parallel arrays) and must have the SAME length (engine error otherwise); at least one entry must resolve to a list ('No data mapping resolved from for_each entries. LHS must be a list.'); a SCALAR source binds once at resolution time (before the loop, even when lists are empty); an UNRESOLVABLE source REMOVES the model key",
        "blocks": "BEGIN/END split statement[] into pre-block (runs ONCE before) / each-block (once PER ELEMENT) / post-block (ONCE after); NO BEGIN => the WHOLE statement list is the loop body (seed accumulators in a pre-block, or the seeding re-runs each iteration)",
        "ordering": "strictly SEQUENTIAL in list order (element 0 completes before element 1 starts) — unlike the fetcher's for_each, which fans HTTP calls out concurrently and only aggregates in order; the whole loop is ONE node execution, so a long list does not trip the loop guard",
        "early_exit": "a taken IF jump breaks the loop: ends the current iteration immediately, skips remaining elements AND the post-block, routes traversal to the named node (in the pre-block it skips loop+post the same way; NEXT: exits after its block completes; ELSE: next falls through within the iteration)",
        "empty_lists": "zero iterations; pre- and post-blocks still run",
        "numbers": "COMPUTE yields DOUBLES; the f:add/f:subtract/... plugins use NUMERIC PROMOTION - all-whole inputs keep exact long arithmetic (incl. integer division and integer counters), ANY floating-point argument promotes the whole computation to a double (decided over all args, order-independent) - so f:add composes directly with COMPUTE results and decimal data; accumulate with either f:add or a pure-COMPUTE read-back; tame precision artifacts with f:round(value, int(2)) - half-up on the decimal representation",
        "execute": "EXECUTE: inlining happens BEFORE blocks are split — an executed module's statements land at the EXECUTE position and may contribute to the loop body",
        "example": "skill=graph.math\nfor_each[]=input.body.prices -> model.price\nfor_each[]=input.body.quantities -> model.qty\nstatement[]=MAPPING: int(0) -> model.total\nstatement[]=BEGIN\nstatement[]=COMPUTE: total -> {model.total} + {model.price} * {model.qty}\nstatement[]=MAPPING: totaler.result.total -> model.total\nstatement[]=END\nstatement[]=MAPPING: model.total -> output.body.total"
      },
      "example": "skill=graph.math\nstatement[]=COMPUTE: sum -> {input.body.a} + {input.body.b}\nstatement[]='''\nIF: {input.body.a} >= {input.body.b}\nTHEN: ge-path\nELSE: lt-path\n'''" },
    { "route": "graph.js", "retired": true,
      "notes": ["RETIRED in this Rust port (disabled for security); the runtime rejects it: 'Skill graph.js is retired for security reasons - use graph.math or graph.task instead'", "do NOT author graph.js nodes — use graph.math for inline compute/branch, or graph.task for anything richer"] },
    { "route": "graph.api.fetcher", "help": "help graph-api-fetcher.md", "required": ["dictionary[]"], "conditional": ["input[] (required whenever its dictionaries declare parameters - the usual case)"], "optional": ["output[] (the result set always lands at {node}.result for a later mapper)", "for_each[]", "concurrency", "exception"],
      "notes": ["concurrency 1-30 (default 3)", "dedup: identical requests (same provider + input parameters) are deduplicated WITHIN THE GRAPH INSTANCE; only SUCCESSFUL responses are cached (a failed call is never cached, so a retry after RESET: makes a real call)",
      "HTTP semantics: one Provider call is exactly ONE HTTP request - redirects are NEVER followed (a 3xx is a non-failure: status and body are captured and traversal proceeds; point the Provider url at the redirect target to land on it); {node}.status ALWAYS carries the HTTP status of the fetch, success included", "exception={handler-node}: on a failed call (HTTP >= 400) the node's status/error are set, output[] mappings are SKIPPED, and traversal JUMPS to the handler instead of aborting (without exception=, the run aborts); wire the handler back (connect handler to fetcher with retry) and bound the retry loop - full pattern in command-reference.md#failure-routing"],
      "for_each": {
        "syntax": "for_each[]={array-source} -> model.{var}",
        "source": "must resolve to a list - typically a prior fetcher's result ({fetcher}.result.{key}, the cross-node .result namespace) or any model.* array; multiple for_each[] lines iterate in lock-step",
        "wiring": "pass the current element into each call with input[]=model.{var} -> {dictionary-parameter}; non-iterated inputs are passed unchanged to every call",
        "concurrency": "bounds the parallel fan-out (1-30, default 3): calls run in batches of that size",
        "aggregation": "GUARANTEED: each iteration's result.{key} values are appended into a single array on this node's result set (N iterations -> an array of N); order deterministically follows the source list (batches execute in order; responses join in request order)"
      },
      "example": "skill=graph.api.fetcher\ndictionary[]=account-detail\nfor_each[]=profile-fetcher.result.accounts -> model.account_id\nconcurrency=3\ninput[]=input.body.person_id -> person_id\ninput[]=model.account_id -> account_id\noutput[]=result.detail -> model.account_details" },
    { "route": "graph.extension", "help": "help graph-extension.md", "required": ["extension", "input[]"], "optional": ["output[]", "for_each[]", "concurrency", "exception"],
      "notes": ["extension={graph-id} for a sub-graph, or flow://{flow-id} for an Event Script flow",
                "{graph-id} resolves among the DEPLOYED graph models (compiled at startup from resources/graph - the same ids callable at POST /api/graph/{graph-id}); a session draft is NOT addressable - export and deploy first; a missing id fails the node fast",
                "each input[] TARGET is a bare key that becomes the sub-graph's input.body.{key}",
                "the node's result.* namespace IS the sub-graph's output.body: 'result' (bare) = the whole response body, 'result.{key}' = a field of it",
                "NO whole-body '*' target on graph.extension - map named keys (the '*' merge idiom is graph.task-only); the same contract applies to flow:// targets (named keys feed the flow's input.body; result.* is the flow's output.body)"],
      "example": "skill=graph.extension\nextension=evaluate-sales-performance\ninput[]=input.body.department_id -> id\noutput[]=result -> output.body" },
    { "route": "graph.task", "help": "help graph-task.md", "required": ["task"], "optional": ["input[]", "output[]", "for_each[]", "concurrency", "exception"],
      "notes": ["task={route} of a composable function (TypedLambdaFunction registered with @PreLoad)",
                "input[] entries apply in order; RHS '*' merges the mapped value into the request body, 'header.{name}' sets a request header, any other key sets a body field",
                "request body auto-converts when the function declares a PoJo input; concurrency 1-30 (default 3)"],
      "example": "skill=graph.task\ntask=v1.hello.task\ninput[]=input.body -> *\ninput[]=text(minigraph) -> header.x-app\noutput[]=result -> output.body" },
    { "route": "graph.join", "help": "help graph-join.md", "required": [], "optional": [],
      "notes": ["returns next only when all upstream nodes complete, else .sink (pause)", "completion is SUCCESS-ONLY and CURRENT: a branch that failed into its exception= route does not count while it retries; a RESET node stops counting until it re-executes successfully; a chained upstream join counts only once it actually FIRED (a sunk evaluation does not count)"],
      "example": "skill=graph.join" },
    { "route": "graph.island", "help": "help graph-island.md", "required": [], "optional": [],
      "notes": ["executes only to sink - isolated from traversal, which never continues through it",
                "CONVENTION: the island is the graph's knowledge layer (its entity-relationship diagram) - REQUIRED whenever the graph has off-path nodes (Dictionary/Provider config, data-entity, or reusable Module nodes): wire every one under it (root -[contains]-> island -[data]-> dictionary -[provider]-> provider; island -[module]-> module), leave no node unconnected; ENCOURAGED for graphs with none (add data-entity nodes documenting the domain model)",
                "relation labels are descriptive/free-form; contains/data/provider are the shipped conventions"],
      "example": "skill=graph.island" }
  ],
  "config_nodes": {
    "_role": "Provider/Dictionary nodes configure graph.api.fetcher; they never execute and are referenced BY NAME (dictionary[]=, provider=) - but must NOT be left floating: wire them under a graph.island (root -[contains]-> island -[data]-> dictionary -[provider]-> provider), the graph's entity-relationship knowledge layer. Full spec: command-reference.md#provider-dictionary + #island",
    "Provider": {
      "properties": ["purpose", "url", "method", "feature[]", "input[]"],
      "url": "may embed {name} path placeholders, each filled by an input[] mapping to path_parameter.{name}; ${config.key:default} substitution applies (e.g. url=http://127.0.0.1:${rest.server.port:8080}/api/mdm/profile/{id})",
      "method": "GET | POST | PUT | PATCH | DELETE | HEAD",
      "input_sources": ["a constant", "a Dictionary parameter name (bare)", "a state-machine value (model.*)"],
      "input_targets": ["header.{name}", "query.{name}", "path_parameter.{name}", "body.{key}", "body (whole request body)"],
      "feature": "capability flags the calling fetcher must support; built-ins log-request-headers / log-response-headers log the HTTP headers into the fetcher node's 'header' section",
      "example": "create node mdm-profile\nwith type Provider\nwith properties\npurpose=MDM profile endpoint\nurl=http://127.0.0.1:${rest.server.port:8080}/api/mdm/profile/{id}\nmethod=GET\ninput[]=text(application/json) -> header.accept\ninput[]=person_id -> path_parameter.id"
    },
    "Dictionary": {
      "properties": ["purpose", "provider", "input[]", "output[]"],
      "input": "BARE parameter names, NOT source->target mappings (the one exception to the mapping rule); optional ':{default}' suffix supplies a default value (input[]=detail:true) — the ONLY meaning of ':' here",
      "output": "maps the Provider's raw HTTP response body (response.*) into the result set (result.{key}) exposed by the fetcher; the source may be a leaf ('response.profile.name -> result.name') or an INTERIOR path mapping the whole subtree ('response.profile -> result.profile' = the entire object, 'response.accounts -> result.account_numbers' = the entire array); the namespace ROOT itself is valid: 'response -> result.page' captures the ENTIRE raw body (the way to keep a non-JSON body such as an HTML page whole)",
      "notes": ["a fetcher's input[] targets must MATCH the dictionary parameter names or execution fails",
                "several Dictionary nodes may share one Provider; identical calls (same provider + same input values) are deduplicated into one HTTP request"],
      "example": "create node person-profile\nwith type Dictionary\nwith properties\npurpose=full profile record of a person\nprovider=mdm-profile\ninput[]=person_id\noutput[]=response.profile.name -> result.name\noutput[]=response.profile.address -> result.address"
    }
  },
  "invariants": [
    "root node named 'root'; end node named 'end'",
    "a node has 0 or 1 skill",
    "node names are lowercase letters, digits and hyphen (types are descriptive labels, commonly capitalized)",
    "every node in the traversal path must connect to >=1 node or export fails",
    "Dictionary/Provider config nodes are referenced by name and not traversed - but the required convention is NO NODE LEFT UNCONNECTED: wire them under a graph.island (root -[contains]-> island -[data]-> dictionary -[provider]-> provider), the graph's entity-relationship knowledge layer",
    "a node is executed once per run (a graph.math RESET statement is the only escape)",
    "instantiate graph must precede run/execute/inspect"
  ]
}
