Explorar o código

fix: make node output schemas describe what the nodes actually return

A node's outputSchema is what tells an author where a value lives, and
several of them were wrong. set-fields declared its output as {data: ...}
while returning the configured fields flat, so the documented path was
data.data.generate - matching neither the broken data.result.generate that
stopped a production pipeline nor the correct data.generate. Wrong in a
third way, and nobody could tell, because nothing compared the declaration
against reality.

This became urgent rather than tidy when the editor started deriving its
field-path suggestions from these schemas: a lying schema turns a helpful
picker into a confident wrong answer, which is worse than the text box it
replaced.

Schemas now match the code. Where a node's real keys are dynamic, the
schema says so rather than inventing a shape - set-fields declares
additionalProperties and points at outputsFromConfig, which is how the
editor offers the field names actually typed into the node.

scripts/check-output-schema-drift.py compares every node's declared output
against what it really returns and exits non-zero on a mismatch, so this
cannot quietly drift back. It reports clean today. How to run it is in
docs/nodes.md.

83 passed, 0 failed, 2 skipped.
fszontagh hai 1 mes
pai
achega
c992b9cae5

+ 44 - 0
docs/nodes.md

@@ -992,3 +992,47 @@ Two SD.cpp cases use this. They compare against whatever model the server has
 loaded, and that server unloads when idle - so without the gate they fail for a
 reason that has nothing to do with the code, and a failure everyone learns to
 ignore is worse than no test.
+
+## Keeping outputSchema honest
+
+`outputSchema` is not decorative - it is what tells a workflow author, and the
+WebUI's field-path suggestions, where a value actually lives in a node's
+output. It drifts: `execute()` changes and nobody remembers the schema next to
+it, and a wrong schema is worse than none, because it looks authoritative
+while pointing at the wrong path.
+
+```bash
+./scripts/check-output-schema-drift.py                 # every fixture
+./scripts/check-output-schema-drift.py set-fields loop  # only these (by file stem)
+```
+
+It reuses the same `tests/nodes/*.json` fixtures and execution mechanism as
+`run-node-tests.sh` - create a throwaway workflow, run it, read each node's
+real output back off the execution record - rather than adding a second corpus
+to maintain. For every node type that completed at least once across the
+fixtures run, it unions the top-level keys of every observed output and checks
+each is covered by that node type's declared `outputSchema`: a plain property,
+a `patternProperties` match (for dynamic-named branches such as Switch's
+`case0`/`case1`), or `additionalProperties: true` (for nodes whose real shape
+is inherently dynamic - Set Fields, Workflow Input, Loop, and a few others
+whose output field name is itself a config value).
+
+It is deliberately **not** wired into `run-node-tests.sh` or its exit code. A
+schema drift is not a behavioural regression - conflating the two would mean
+a documentation gap fails the same build a real bug does, and a genuinely
+red build stops being trustworthy the moment "just a schema thing, ignore it"
+becomes a thing people say about it. Run it separately, and on demand.
+
+A few keys are allowed everywhere without any node declaring them, because the
+engine adds them to any node's stored result regardless of type:
+`_appliedConfig` and `_ignoredConfigKeys` (written whenever a Configurator or
+an SD.cpp Model node feeds the node its config) and `_executedInLoop` (written
+to every node that ran inside a Loop body). See `ENGINE_ADDED_KEYS` in the
+script.
+
+Exit 0 means every observed key was covered, 1 means at least one node type
+showed drift. It also prints which node types the fixtures never exercised to
+completion in this run (mostly ones needing a live external service - OCR,
+SD.cpp, SMTP, Telegram, IMAP, the AI chat providers) - that is coverage
+information, not a failure; a type with no observed output was not checked,
+not cleared.

+ 7 - 2
nodes/core/aggregate.js

@@ -47,8 +47,13 @@ const outputSchema = {
     type: 'object',
     properties: {
         count: { type: 'number' },
-        groups: { type: 'number', description: 'Number of groups, when grouping' }
-    }
+        groups: { type: 'number', description: 'Number of groups, when grouping' },
+        items: {
+            type: 'array',
+            description: 'The aggregated array (flat items, or one entry per group when Group By is set). Named "items" only when Output Field is left at its default - a custom Output Field renames this key'
+        }
+    },
+    additionalProperties: true
 };
 
 async function execute(config, input, context) {

+ 1 - 0
nodes/core/configurator.js

@@ -60,6 +60,7 @@ const outputSchema = {
         _config: { type: 'object', description: 'The settings, as the engine consumes them' },
         _configFor: { type: 'object', description: 'Per-setting restriction to particular target nodes, when one was set' },
         settings: { type: 'object', description: 'The same settings, readable in the run log' },
+        restrictedTo: { type: 'object', description: 'The same per-setting restriction as _configFor, readable in the run log. Present only when at least one setting names its target nodes' },
         count: { type: 'number', description: 'How many settings were supplied' },
         label: { type: 'string' }
     }

+ 3 - 2
nodes/core/datetime.js

@@ -71,9 +71,10 @@ const inputSchema = {
 const outputSchema = {
     type: 'object',
     properties: {
-        value: { type: 'any', description: 'The formatted string, timestamp or difference' },
+        value: { type: 'any', description: 'The formatted string, timestamp or difference. Named "value" only when Output Field is left at its default - a custom Output Field renames this key' },
         timestamp: { type: 'number', description: 'The result as milliseconds since the epoch' }
-    }
+    },
+    additionalProperties: true
 };
 
 const UNIT_MS = {

+ 3 - 1
nodes/core/filter.js

@@ -66,7 +66,9 @@ const outputSchema = {
     type: 'object',
     properties: {
         keptCount: { type: 'number' },
-        discardedCount: { type: 'number' }
+        discardedCount: { type: 'number' },
+        kept: { type: 'array', description: 'The items that matched, also sent out the Kept branch' },
+        discarded: { type: 'array', description: 'The items that did not match, also sent out the Discarded branch' }
     }
 };
 

+ 3 - 2
nodes/core/if-condition.js

@@ -67,8 +67,9 @@ const outputSchema = {
   properties: {
     result: { type: 'boolean', description: 'Whether the condition evaluated to true' },
     matchedConditions: { type: 'array', description: 'List of conditions that matched' },
-    _activeBranch: { type: 'string', description: 'Active output branch (true or false)' }
-    // Data is passed through on the active branch (true or false)
+    _activeBranch: { type: 'string', description: 'Active output branch (true or false), for the engine' },
+    true: { type: 'any', description: 'The incoming payload, present only when the condition evaluated to true. Read fields directly off this, not off "data" - the wrapper is stripped here' },
+    false: { type: 'any', description: 'The incoming payload, present only when the condition evaluated to false. Read fields directly off this, not off "data" - the wrapper is stripped here' }
   }
 };
 

+ 3 - 2
nodes/core/json.js

@@ -60,8 +60,9 @@ const inputSchema = {
 const outputSchema = {
     type: 'object',
     properties: {
-        value: { type: 'any', description: 'The parsed, stringified or extracted value' }
-    }
+        value: { type: 'any', description: 'The parsed, stringified or extracted value. Named "value" only when Output Field is left at its default - a custom Output Field renames this key' }
+    },
+    additionalProperties: true
 };
 
 async function execute(config, input, context) {

+ 43 - 11
nodes/core/loop.js

@@ -66,19 +66,51 @@ const inputSchema = {
 const outputSchema = {
   type: 'object',
   properties: {
-    // Internal fields for engine (not shown to user)
-    _isLoop: { type: 'boolean', description: 'Loop marker for engine' },
+    // Internal fields the engine reads to drive iteration. They stay on the
+    // stored result (the engine does not strip them), but a workflow should
+    // not read them - use loop/done below instead.
+    _isLoop: { type: 'boolean', description: 'Loop marker for the engine. False only when the input array was empty' },
     _items: { type: 'array', description: 'Array of items to iterate' },
-    _outputField: { type: 'string', description: 'Name for collected results' },
-    _itemVariable: { type: 'string', description: 'Variable name for current item' },
-    _indexVariable: { type: 'string', description: 'Variable name for current index' },
+    _outputField: { type: 'string', description: 'Name for the collected results array (see done, and the top-level key of the same name)' },
+    _itemVariable: { type: 'string', description: 'Variable name for the current item' },
+    _indexVariable: { type: 'string', description: 'Variable name for the current index' },
     _continueOnError: { type: 'boolean', description: 'Continue on error flag' },
-    _activeBranch: { type: 'string', description: 'Active branch (loop or done)' },
-    // User-accessible fields (item/index names are dynamic based on config)
-    totalItems: { type: 'number', description: 'Total number of items' },
-    isFirst: { type: 'boolean', description: 'True if this is the first iteration' },
-    isLast: { type: 'boolean', description: 'True if this is the last iteration' }
-  }
+    _activeBranch: { type: 'string', description: 'loop while this node is dispatching, done once the engine has finished iterating - this is what the stored result shows after the run' },
+    _loopCompleted: { type: 'boolean', description: 'True once the engine has finished running the loop body over every item. Only present when the array was non-empty' },
+    loop: {
+      type: 'object',
+      description: 'A preview of the first item, built when the node started. Body nodes get their own item/index at the top level of their own input, not from here - after the run this object still shows item 0, not the last one processed',
+      properties: {
+        totalItems: { type: 'number', description: 'Total number of items' },
+        isFirst: { type: 'boolean' },
+        isLast: { type: 'boolean', description: 'True only when there was a single item' }
+        // Plus one key named after the configured Item Variable Name (default
+        // "item") and one named after the Index Variable Name (default "index").
+      },
+      additionalProperties: true
+    },
+    done: {
+      type: 'object',
+      description: 'The finished summary: what the loop body produced for every item, and how many',
+      properties: {
+        totalProcessed: { type: 'number', description: 'Present only when the input array was non-empty' },
+        totalItems: { type: 'number', description: 'Present only when the input array was empty (0)' },
+        data: { type: 'any', description: 'The data the loop node itself received' }
+        // Plus one key named after the Output Field Name (default "results"),
+        // holding the collected array - the same array also duplicated at the
+        // top level under that same name.
+      },
+      additionalProperties: true
+    },
+    totalItems: { type: 'number', description: 'Total number of items. Present only when the input array was empty (0) - otherwise read loop.totalItems or done.totalProcessed' },
+    data: { type: 'any', description: 'The data this node received. Present only when the input array was empty' }
+    // The collected results array is also written at the top level under the
+    // configured Output Field Name (default "results"), duplicating done.results.
+  },
+  // The results array's top-level key, and the item/index keys inside loop,
+  // all take their names from config rather than being fixed - hence this,
+  // on top of the properties documented above under their default names.
+  additionalProperties: true
 };
 
 function getFieldValue(data, path) {

+ 10 - 1
nodes/core/respond-to-webhook.js

@@ -62,7 +62,16 @@ const outputSchema = {
     type: 'object',
     properties: {
         status: { type: 'number', description: 'Status this node asked for' },
-        respondedWith: { type: 'string', description: 'Content type sent' }
+        respondedWith: { type: 'string', description: 'Content type sent' },
+        _webhookResponse: {
+            type: 'object',
+            description: 'The full HTTP response handed to the engine to send back to the caller (status, headers, body). Engine plumbing, not something to read from a later node',
+            properties: {
+                status: { type: 'number' },
+                headers: { type: 'object' },
+                body: { type: 'any' }
+            }
+        }
     }
 };
 

+ 61 - 47
nodes/core/schedule-trigger.js

@@ -82,53 +82,67 @@ const inputSchema = {
 const outputSchema = {
   type: 'object',
   properties: {
-    timestamp: {
-      type: 'string',
-      description: 'ISO timestamp when the trigger fired'
-    },
-    timestampMs: {
-      type: 'integer',
-      description: 'Unix timestamp in milliseconds'
-    },
-    date: {
-      type: 'string',
-      description: 'Date in YYYY-MM-DD format'
-    },
-    time: {
-      type: 'string',
-      description: 'Time in HH:MM:SS format'
-    },
-    dayOfWeek: {
-      type: 'integer',
-      description: 'Day of week (0=Sunday, 6=Saturday)'
-    },
-    dayOfMonth: {
-      type: 'integer',
-      description: 'Day of month (1-31)'
-    },
-    month: {
-      type: 'integer',
-      description: 'Month (1-12)'
-    },
-    year: {
-      type: 'integer',
-      description: 'Year'
-    },
-    hour: {
-      type: 'integer',
-      description: 'Hour (0-23)'
-    },
-    minute: {
-      type: 'integer',
-      description: 'Minute (0-59)'
-    },
-    triggerName: {
-      type: 'string',
-      description: 'Configured trigger name'
-    },
-    executionCount: {
-      type: 'integer',
-      description: 'Number of times this trigger has fired (since workflow activation)'
+    // Unlike the other trigger nodes, this one wraps everything under "main"
+    // (matching the "main" entry in the outputs port list below) instead of
+    // returning these fields at the top level. Read them as main.timestamp,
+    // main.date, and so on.
+    main: {
+      type: 'object',
+      properties: {
+        timestamp: {
+          type: 'string',
+          description: 'ISO timestamp when the trigger fired'
+        },
+        timestampMs: {
+          type: 'integer',
+          description: 'Unix timestamp in milliseconds'
+        },
+        date: {
+          type: 'string',
+          description: 'Date in YYYY-MM-DD format'
+        },
+        time: {
+          type: 'string',
+          description: 'Time in HH:MM:SS format'
+        },
+        dayOfWeek: {
+          type: 'integer',
+          description: 'Day of week (0=Sunday, 6=Saturday)'
+        },
+        dayOfMonth: {
+          type: 'integer',
+          description: 'Day of month (1-31)'
+        },
+        month: {
+          type: 'integer',
+          description: 'Month (1-12)'
+        },
+        year: {
+          type: 'integer',
+          description: 'Year'
+        },
+        hour: {
+          type: 'integer',
+          description: 'Hour (0-23)'
+        },
+        minute: {
+          type: 'integer',
+          description: 'Minute (0-59)'
+        },
+        triggerName: {
+          type: 'string',
+          description: 'Configured trigger name'
+        },
+        executionCount: {
+          type: 'integer',
+          description: 'Number of times this trigger has fired (since workflow activation)'
+        },
+        mode: { type: 'string', description: 'interval or cron, as configured' },
+        timezone: { type: 'string', description: 'Configured timezone reference' },
+        intervalMinutes: { type: 'integer', description: 'Present only in interval mode' },
+        nextRunEstimate: { type: 'string', description: 'Present only in interval mode' },
+        cronExpression: { type: 'string', description: 'Present only in cron mode' }
+      }
     }
   }
 }

+ 8 - 3
nodes/core/set-fields.js

@@ -57,9 +57,14 @@ const inputSchema = {
 
 const outputSchema = {
     type: 'object',
-    properties: {
-        data: { type: 'any', description: 'The rebuilt object' }
-    }
+    // The rebuilt object is returned flat, at the top level - not wrapped
+    // under a "data" key. In keep-all mode that top level is a copy of the
+    // input with the configured fields set; in only-set mode it holds only
+    // the configured fields. The real key names are dynamic (whatever was
+    // typed under Fields), which is what outputsFromConfig above tells the
+    // editor to offer.
+    properties: {},
+    additionalProperties: true
 };
 
 function setByPath(target, path, value) {

+ 7 - 2
nodes/core/sort-limit-dedupe.js

@@ -73,8 +73,13 @@ const outputSchema = {
     type: 'object',
     properties: {
         count: { type: 'number', description: 'Items in the result' },
-        removedDuplicates: { type: 'number' }
-    }
+        removedDuplicates: { type: 'number' },
+        items: {
+            type: 'array',
+            description: 'The processed array. Named "items" only when Output Field is left at its default - a custom Output Field renames this key'
+        }
+    },
+    additionalProperties: true
 };
 
 function compareValues(left, right, compareAs) {

+ 8 - 1
nodes/core/switch.js

@@ -67,7 +67,14 @@ const outputSchema = {
     properties: {
         matchedRule: { type: 'number', description: 'Index of the rule that matched, or -1' },
         matchedLabel: { type: 'string', description: 'Label of the rule that matched' },
-        _activeBranch: { type: 'string', description: 'The output the data went to' }
+        _activeBranch: { type: 'string', description: 'The output the data went to, for the engine' },
+        fallback: { type: 'any', description: 'The incoming payload, present only when no rule matched' }
+    },
+    patternProperties: {
+        '^case[0-9]+$': {
+            type: 'any',
+            description: 'The incoming payload, present only on the branch of the rule that matched (case0 for the first rule, case1 for the second, and so on)'
+        }
     }
 };
 

+ 3 - 2
nodes/core/template.js

@@ -48,8 +48,9 @@ const inputSchema = {
 const outputSchema = {
     type: 'object',
     properties: {
-        text: { type: 'string', description: 'The rendered text' }
-    }
+        text: { type: 'string', description: 'The rendered text. Named "text" only when Output Field is left at its default - a custom Output Field renames this key' }
+    },
+    additionalProperties: true
 };
 
 async function execute(config, input, context) {

+ 5 - 1
nodes/core/wait-for-approval.js

@@ -55,7 +55,11 @@ const outputSchema = {
         answeredBy: { type: 'string', description: 'Set on the answer' },
         answeredAt: { type: 'number', description: 'Set on the answer' },
         fields: { type: 'array', description: 'Extra values the approver was asked for' }
-    }
+    },
+    // The answer is whatever body the resume call was given, plus answeredAt -
+    // approved is required by that API, but nothing stops a caller adding more
+    // (a note, for instance), and it lands here unchanged.
+    additionalProperties: true
 };
 
 const MAX_EXPIRY_HOURS = 30 * 24;

+ 1 - 1
nodes/core/workflow-output.js

@@ -41,7 +41,7 @@ const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
 const outputSchema = {
     type: 'object',
     properties: {
-        output: { type: 'object', description: 'What the caller receives' }
+        output: { type: 'any', description: 'What the caller receives. An object when Return is fields, otherwise whatever type the input data actually was' }
     }
 };
 

+ 18 - 0
nodes/image/image.js

@@ -567,6 +567,24 @@ const outputSchema = {
                 text: { type: 'string', description: 'Text used (for text watermark)' },
                 imagePath: { type: 'string', description: 'Path to watermark image (for image watermark)' }
             }
+        },
+        cropRegion: {
+            type: 'object',
+            description: 'The region that was cut out, for the crop operation',
+            properties: {
+                x: { type: 'number' },
+                y: { type: 'number' },
+                width: { type: 'number' },
+                height: { type: 'number' }
+            }
+        },
+        angle: {
+            type: 'number',
+            description: 'Degrees rotated, for the rotate operation'
+        },
+        backgroundColor: {
+            type: 'string',
+            description: 'Fill color used behind the rotated image, for the rotate operation'
         }
     }
 };

+ 3 - 0
nodes/imap/imap-modify.js

@@ -74,7 +74,10 @@ const outputSchema = {
     properties: {
         success: { type: 'boolean' },
         action: { type: 'string' },
+        mailbox: { type: 'string' },
         processed: { type: 'integer' },
+        succeeded: { type: 'integer' },
+        failed: { type: 'integer' },
         results: { type: 'array' }
     }
 };

+ 0 - 12
nodes/rss/rss-reader.js

@@ -110,18 +110,6 @@ const outputSchema = {
         feedTitle: { type: 'string', description: 'Title of the feed' },
         feedLink: { type: 'string', description: 'Link to the feed homepage' },
         feedDescription: { type: 'string', description: 'Description of the feed' },
-        feedLanguage: { type: 'string', description: 'Language of the feed' },
-        feedPubDate: { type: 'string', description: 'Publication date of the feed' },
-        feedGenerator: { type: 'string', description: 'Generator of the feed' },
-        feedImage: {
-            type: 'object',
-            description: 'Feed image/logo',
-            properties: {
-                url: { type: 'string' },
-                title: { type: 'string' },
-                link: { type: 'string' }
-            }
-        },
         itemCount: { type: 'number', description: 'Number of items returned' },
         newItemCount: { type: 'number', description: 'Items this run will process. With Detect New Items off that is every item returned, so a workflow can branch on this whichever mode it is in' },
         totalFeedItemCount: { type: 'number', description: 'Total items in feed before filtering (when detectNewItems is enabled)' },

+ 3 - 0
nodes/sdcpp/sdcpp-job-wait.js

@@ -104,6 +104,9 @@ const outputSchema = {
         error: { type: 'string' },
         waitedMs: { type: 'number', description: 'How long this node actually waited' },
         polls: { type: 'number', description: 'How many status requests it made' },
+        createdAt: { type: 'string' },
+        startedAt: { type: 'string' },
+        completedAt: { type: 'string' },
         params: { type: 'object' },
         modelSettings: { type: 'object' }
     }

+ 2 - 1
nodes/sdcpp/sdcpp-model.js

@@ -90,7 +90,8 @@ const outputSchema = {
         modelName: { type: 'string', description: 'The chosen model' },
         modelType: { type: 'string' },
         setting: { type: 'string', description: 'Which setting it was supplied as' },
-        models: { type: 'array', description: 'Everything of this kind the server has. Only filled when listing' }
+        models: { type: 'array', description: 'Everything of this kind the server has. Only filled when listing' },
+        loadedModel: { type: 'string', description: 'What the server currently has loaded for this kind. Only present when listing' }
     }
 };
 

+ 2 - 1
nodes/triggers/click-trigger.js

@@ -28,7 +28,8 @@ const outputSchema = {
   type: 'object',
   properties: {
     timestamp: { type: 'number' },
-    triggeredBy: { type: 'string' }
+    triggeredBy: { type: 'string' },
+    executionId: { type: 'string' }
   }
 };
 

+ 8 - 1
nodes/triggers/workflow-input.js

@@ -41,9 +41,16 @@ const inputSchema = { type: 'object', properties: { data: { type: 'any' } } };
 
 const outputSchema = {
     type: 'object',
+    // The real shape is dynamic: one key per row configured under Expected
+    // Input, named whatever the caller was told to pass (see configSchema's
+    // outputsFromConfig, which is what feeds the editor the real names as
+    // they are typed). Plus, when Allow Anything Else is on, any other key
+    // the caller passed that was not listed. calledBy is the only key
+    // guaranteed to exist regardless of configuration.
     properties: {
         calledBy: { type: 'string', description: 'Execution id of the workflow that called this one, empty when run directly' }
-    }
+    },
+    additionalProperties: true
 };
 
 async function execute(config, input, context) {

BIN=BIN
scripts/__pycache__/verify-node.cpython-314.pyc


+ 251 - 0
scripts/check-output-schema-drift.py

@@ -0,0 +1,251 @@
+#!/usr/bin/env python3
+"""Compare every node's declared outputSchema against what it actually returns.
+
+Why this exists: an outputSchema is what tells a workflow author, and the
+WebUI's field-path picker, where a value lives in a node's output. It drifts
+silently - a node's execute() changes and nobody remembers to update the
+schema next to it - and a wrong schema is worse than no schema, because it
+looks authoritative while pointing at the wrong path.
+
+This reuses the existing tests/nodes/*.json fixtures and the same execution
+mechanism as scripts/verify-node.py (create a throwaway workflow, run it,
+read back each node's real output from the execution record) rather than
+adding a second corpus to maintain. For every node type that completed at
+least once across the fixtures, it unions the top-level keys of every
+observed output and checks each one is covered by that node type's declared
+outputSchema - as a plain property, a patternProperties match (for
+dynamic-named branches such as switch's case0/case1), or additionalProperties
+being explicitly true (for nodes whose real shape is inherently dynamic, such
+as set-fields and workflow-input).
+
+A key the schema does not cover is reported as drift. A key the schema
+declares but no fixture ever produced is not reported - fixtures are a
+sample, not exhaustive, and many declared keys are genuinely conditional
+(an error field that only appears on failure, for instance).
+
+Usage:
+    ./scripts/check-output-schema-drift.py            # all fixtures
+    ./scripts/check-output-schema-drift.py case1 case2 # only these fixtures (by file stem)
+
+Exit 0 if every observed key is covered, 1 if any node type shows drift.
+Requires the webserver and a runner to be up (same as run-node-tests.sh).
+"""
+import importlib.util
+import json
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+TESTS_DIR = ROOT / "tests" / "nodes"
+
+
+def load_verify_node():
+    spec = importlib.util.spec_from_file_location("verify_node", ROOT / "scripts" / "verify-node.py")
+    mod = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(mod)
+    return mod
+
+
+def load_schemas():
+    proc = subprocess.run(
+        ["node", str(ROOT / "scripts" / "extract-output-schemas.js")],
+        capture_output=True, text=True, cwd=ROOT,
+    )
+    if proc.returncode != 0:
+        raise SystemExit("extract-output-schemas.js failed:\n" + proc.stderr)
+    if proc.stderr:
+        sys.stderr.write(proc.stderr)
+    return json.loads(proc.stdout)
+
+
+# Added by the engine itself to any node's stored result, regardless of node
+# type - not something an individual node's own outputSchema should have to
+# declare. See workflow_engine.cpp: applyNodeMarkers (_appliedConfig,
+# _ignoredConfigKeys, written whenever a Configurator feeds the node) and the
+# loop body runner (_executedInLoop, written to every node that ran inside a
+# Loop). A node-specific drift is one the node's own execute() introduced;
+# these are not that.
+ENGINE_ADDED_KEYS = {"_executedInLoop", "_appliedConfig", "_ignoredConfigKeys"}
+
+
+def key_covered(key, schema):
+    if key in ENGINE_ADDED_KEYS:
+        return True
+    if key in schema["properties"]:
+        return True
+    for pattern in schema["patternProperties"]:
+        if __import__("re").match(pattern, key):
+            return True
+    return schema["additionalProperties"] is True
+
+
+def run_case(vn, case_path, token):
+    """Runs one fixture like verify-node.py does, but only to observe outputs -
+    assertions in the fixture are ignored here, drift is a separate concern
+    from behavioural correctness, and a fixture that intentionally exercises a
+    failure path (errorContains, expectMissing) is exactly the kind of case
+    that would otherwise make this look like a runner problem."""
+    case = json.loads(case_path.read_text())
+
+    if "http" in case:
+        # These assert on the raw HTTP response, but the workflow underneath
+        # still runs ordinary nodes - run it as a click/manual case instead so
+        # the nodes still execute and still get observed.
+        pass
+
+    unmet = vn.precondition_unmet(case)
+    if unmet:
+        return None, f"skipped: {unmet}"
+
+    node_types = {n["id"]: n["type"] for n in case.get("nodes", [])}
+
+    helper_ids = []
+    for helper in case.get("helpers", []):
+        made = vn.call("POST", "/workflows", token, {
+            "name": helper["name"],
+            "nodes": helper["nodes"],
+            "connections": helper.get("connections", []),
+        })
+        helper_id = made.get("id") or made.get("_id")
+        if not helper_id:
+            return None, "no id for helper"
+        helper_ids.append(helper_id)
+        if helper.get("active", True):
+            vn.call("POST", f"/workflows/{helper_id}/activate", token, {})
+        case = json.loads(json.dumps(case).replace("{{helper:" + helper["key"] + "}}", helper_id))
+
+    created = vn.call("POST", "/workflows", token, {
+        "name": case["name"],
+        "nodes": case["nodes"],
+        "connections": case["connections"],
+        "settings": case.get("settings", {}),
+    })
+    workflow_id = created.get("id") or created.get("_id")
+    if not workflow_id:
+        return None, "no workflow id"
+
+    observed = {}
+    try:
+        if "http" in case:
+            vn.call("POST", f"/workflows/{workflow_id}/activate", token, {})
+            try:
+                vn.run_http_case(case, token, workflow_id)
+            except SystemExit:
+                pass
+            # The webhook call already ran the workflow; find its execution.
+            execs = vn.call("GET", f"/executions?pageSize=1&workflowId={workflow_id}", token)
+            executions = execs.get("executions", [])
+            if not executions:
+                return {}, None
+            execution_id = executions[0]["_id"]
+        else:
+            started = vn.call("POST", f"/workflows/{workflow_id}/execute", token, {})
+            execution_id = started["executionId"]
+            if "resume" in case:
+                try:
+                    vn.run_resume_case(case, token, execution_id)
+                except SystemExit as e:
+                    return None, f"resume setup failed: {e}"
+
+        execution = None
+        for _ in range(60):
+            time.sleep(0.5)
+            execution = vn.call("GET", f"/executions/{execution_id}", token)
+            if execution.get("status") in ("completed", "failed", "cancelled", "waiting"):
+                break
+        else:
+            return None, "execution did not finish"
+
+        for node_exec in execution.get("nodeExecutions", []):
+            if node_exec.get("status") != "completed":
+                continue
+            node_type = node_types.get(node_exec.get("nodeId"))
+            if not node_type:
+                continue
+            output = node_exec.get("output")
+            if not isinstance(output, dict):
+                continue
+            observed.setdefault(node_type, set()).update(output.keys())
+
+        return observed, None
+    finally:
+        vn.call("DELETE", f"/workflows/{workflow_id}", token)
+        for helper_id in helper_ids:
+            vn.call("DELETE", f"/workflows/{helper_id}", token)
+
+
+def main():
+    vn = load_verify_node()
+    schemas = load_schemas()
+
+    wanted = sys.argv[1:]
+    cases = sorted(TESTS_DIR.glob("*.json"))
+    if wanted:
+        cases = [c for c in cases if c.stem in wanted]
+        if not cases:
+            raise SystemExit(f"no fixtures matched {wanted}")
+
+    token = vn.login()
+    vn.call("POST", "/nodes/migrate", token, {"nodesPath": "./nodes"})
+
+    observed_by_type = {}
+    skipped = []
+    errors = []
+
+    for case_path in cases:
+        try:
+            observed, note = run_case(vn, case_path, token)
+        except Exception as exc:  # a broken fixture should not abort the whole scan
+            errors.append(f"{case_path.stem}: {exc}")
+            continue
+        if note and observed is None:
+            skipped.append(f"{case_path.stem}: {note}")
+            continue
+        for node_type, keys in (observed or {}).items():
+            observed_by_type.setdefault(node_type, set()).update(keys)
+
+    drift = {}
+    for node_type, keys in sorted(observed_by_type.items()):
+        schema = schemas.get(node_type)
+        if schema is None:
+            drift[node_type] = {"error": "no outputSchema found for this node type", "keys": sorted(keys)}
+            continue
+        uncovered = sorted(k for k in keys if not key_covered(k, schema))
+        if uncovered:
+            drift[node_type] = {"uncovered": uncovered, "declared": schema["properties"], "file": schema["file"]}
+
+    all_types = set(schemas.keys()) - {"utils-test"}
+    covered_types = set(observed_by_type.keys())
+    uncovered_types = sorted(all_types - covered_types)
+
+    print(f"observed {len(covered_types)}/{len(all_types)} node types across {len(cases) - len(skipped) - len(errors)} fixtures "
+          f"({len(skipped)} skipped, {len(errors)} errored)")
+    if uncovered_types:
+        print("\nno completed execution observed for (not necessarily a problem - just not checked this run):")
+        for t in uncovered_types:
+            print(f"  {t}")
+
+    if errors:
+        print("\nfixtures that errored out (investigate separately, not counted as drift):")
+        for e in errors:
+            print(f"  {e}")
+
+    if not drift:
+        print("\nno drift: every observed output key is covered by its node's outputSchema")
+        return 0
+
+    print(f"\nDRIFT in {len(drift)} node type(s):")
+    for node_type, info in drift.items():
+        if "error" in info:
+            print(f"  {node_type}: {info['error']} (keys seen: {', '.join(info['keys'])})")
+            continue
+        print(f"  {node_type} ({info['file']}):")
+        print(f"    schema declares: {', '.join(info['declared']) or '(none)'}")
+        print(f"    undeclared keys actually returned: {', '.join(info['uncovered'])}")
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 77 - 0
scripts/extract-output-schemas.js

@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+// Emits, as JSON on stdout, one entry per node module in nodes/ describing what
+// its outputSchema currently declares at the top level: the plain property
+// names, any patternProperties regexes (for dynamic-named branches such as
+// switch's case0/case1), and whether additionalProperties allows anything
+// else through. check-output-schema-drift.py compares this against what a
+// real execution actually returned.
+//
+// Requiring each module is safe without the runtime: outputSchema is plain
+// data assembled at module load time, and nothing in it touches the
+// `smartbotic` global - that only happens inside execute(), which this never
+// calls.
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.resolve(__dirname, '..', 'nodes');
+
+function walk(dir, out) {
+    for (const entry of fs.readdirSync(dir)) {
+        const full = path.join(dir, entry);
+        const stat = fs.statSync(full);
+        if (stat.isDirectory()) {
+            walk(full, out);
+        } else if (entry.endsWith('.js')) {
+            out.push(full);
+        }
+    }
+}
+
+function nodeTypeOf(source) {
+    const m = source.match(/@node\s+([a-zA-Z0-9_-]+)/);
+    return m ? m[1] : null;
+}
+
+const files = [];
+walk(ROOT, files);
+
+const result = {};
+
+for (const file of files) {
+    const source = fs.readFileSync(file, 'utf8');
+    const nodeType = nodeTypeOf(source);
+    if (!nodeType) {
+        process.stderr.write('no @node tag found in ' + file + ', skipping\n');
+        continue;
+    }
+
+    let mod;
+    try {
+        delete require.cache[require.resolve(file)];
+        mod = require(file);
+    } catch (e) {
+        process.stderr.write('could not load ' + file + ': ' + e.message + '\n');
+        continue;
+    }
+
+    const schema = mod.outputSchema;
+    if (!schema || typeof schema !== 'object') {
+        process.stderr.write(nodeType + ' (' + file + ') has no outputSchema\n');
+        continue;
+    }
+
+    const properties = Object.keys(schema.properties || {});
+    const patternProps = Object.keys(schema.patternProperties || {});
+    const additionalProperties = schema.additionalProperties === true;
+
+    result[nodeType] = {
+        file: path.relative(path.resolve(__dirname, '..'), file),
+        properties: properties,
+        patternProperties: patternProps,
+        additionalProperties: additionalProperties
+    };
+}
+
+process.stdout.write(JSON.stringify(result, null, 2) + '\n');

+ 1 - 1
tests/nodes/wait-for-approval-loop-chained.json

@@ -18,6 +18,6 @@
     {"sourceNodeId": "pass", "sourceOutput": "main", "targetNodeId": "approve", "targetInput": "data"}
   ],
   "expect": {
-    "approve": {"status": "failed", "errorContains": "cannot pause inside a Loop body"}
+    "approve": {"status": "failed", "errorContains": "cannot be used inside a Loop body"}
   }
 }