Skip to content

FlowDrop specification registry

What this is: a complete list of the promises FlowDrop makes — every rule of the workflow language and of the runtime — so a human can review the promises instead of reading the code. For each rule you see: a stable ID, the rule in one sentence, where the code implements it, and whether a test currently guarantees it. Nothing here is invented: every rule was extracted from the codebase and cites its implementation. Where an executable spec already exists (the conformance fixtures), those files remain the source of truth — this registry is the index that points at them.

Status: first published 2026-07. FlowDrop 2.x is a pre-release line, so some rules below are marked as agreed-but-not-yet-implemented; those are labelled DECIDED and carry no test. A rule that turns out to be false is a bug in the code or in this document — both are worth reporting.

Why it exists: ordinary line coverage only says "this code ran during a test". This registry measures something stronger: "this promise is guaranteed by a test". The headline metric — grammar coverage — is rules with a guaranteeing test ÷ rules that can have one. It currently stands at 100% (Part IV), computed by scripts/spec-coverage.php rather than maintained by hand.

How to read

  • ID — a stable name for the rule, grouped by prefix (GR-* = the workflow language, RT-* = the runtime). Where the repo already numbers a rule (validator R* codes, parameter-resolution D1–D4, execution-dependency Rule 1–4, launch-manifest G1–G4), the existing number is kept and cross-referenced — never renumbered.
  • Rule — the promise, in one sentence. If the sentence is false, that is a bug (or the registry is wrong and must be corrected — both are review findings).
  • Impl — the file and lines that implement the promise.
  • Pinned — is the promise guaranteed by a test? ✅ yes, the cited test asserts exactly this · ◐ only indirectly or partially · ❌ nothing asserts it (it may well work today — but nothing stops it breaking tomorrow).
  • DECIDED — the behaviour is agreed but not yet implemented or tested. Treat it as the target, not as a description of today's code. A DECIDED rule therefore cannot also be ✅, and spec-coverage.php fails if one is.
  • TRANSITIONAL — the rule describes today's shipped, pinned behaviour and names the behaviour meant to replace it. The ✅ is real: what is pinned is what the code does now. The label says the current answer is a staging post with a named target, usually because moving off it needs a consumer in another repository to move first (INT-16's substring classification waiting on API-8's error_code is the type case). Distinct from DECIDED, which describes something that does not exist yet.
  • ← OPEN-n after a rule means the ruling that created it (Part III), not that the rule is unimplemented.
  • Binding — how a rule is tied to its test: conformance fixtures list ids in their covers: block ("spec-registry: CFG-7 (explicit null wins)"), and PHPUnit tests carry a Covers spec-registry: CFG-7 (…) line in the docblock of the test that asserts it. Grep the token to find a rule's guarantees; see Part V.
  • Unfamiliar term? See the Glossary just below.

Priority

  1. RT-* — the runtime contract: execution must not surprise.
  2. GR-* — the workflow definition language: capturing author intent.
  3. Everything else is out of scope for this registry (deliberately).

Glossary

The building blocks

  • Workflow — what the author draws on the canvas: nodes connected by edges, stored as one Drupal config entity.
  • Node — one step in a workflow ("run this prompt", "save this entity"). Each node on the canvas is an instance of a node type.
  • Node type — a reusable, site-configurable definition of a kind of node (a config entity). It points at the node processor that does the work and can override that processor's defaults.
  • Node processor (plugin) — the PHP class that actually executes a node. It declares what inputs it accepts (its parameter schema) and what it produces (its output schema).
  • Port — a named connection point on a node. Input ports receive values, output ports produce them. Ports are derived from the processor's schemas.
  • Edge (wire) — a connection from one node's output port to another node's input port. An edge has no declared type — its meaning is deduced from the ports it connects (see GR-EDGE).
  • Handle — the string the editor stores to say which port an edge attaches to, e.g. mynode-input-message.

Special ports and edges

  • trigger — a control wire: "run B after A". Carries no data.
  • error — a wire that fires only when its source node fails. Every executable node gets an error output; it ships hidden.
  • tool — a wire that offers a node as a callable tool to an AI-agent node, instead of passing data.
  • loop_back — a wire feeding a result back into a loop node (ForEach); the one kind of cycle that is deliberately legal.
  • unified I/O — optional single input/output ports that bundle all of a node's input or output values into one JSON object.
  • Dynamic ports — extra ports an author adds to a single node instance, beyond what the plugin declares.
  • Reserved names — port/parameter names the system injects or treats specially (trigger, error, tool, input, output, engine, anything __-prefixed). Authors cannot repurpose them.

Configuration and exposure

  • data.config — the values the author typed into a node's config form. The only user-owned configuration store (an architecture invariant).
  • Gate flags — per-parameter switches set on the node type: connectable (may receive a wire), configurable (appears in the config form), required.
  • Exposure — whether a port is visible and usable on a node instance. A hidden port effectively does not exist: it cannot be wired, and a hidden output's value is stripped before anyone sees it. exposed_by_default on the node type is nothing more than the default position of the per-instance show/hide toggle.
  • Parameter resolution — where a parameter's value comes from when a node runs, in priority order: value delivered on a wire → author's saved config → schema default (GR-CFG).

Saving and validating

  • Save path vs run path — saving/validating must see the workflow exactly as stored; running is allowed to normalize it first (fill defaults, drop dangling edges). WorkflowDTO::fromArray() is the run-path normalizer and must never touch the save path.
  • Validator / R-codes — the save-time rule checker. Each check has a code (R1–R13). Severity E (error) blocks the save; W (warning) does not.
  • Locator — the machine-readable "where" in a validation message, e.g. node.abc.config.model.

Running

  • Orchestrator — the engine that executes a workflow. Four strategies: direct sync (one in-memory pass, no loops), sync pipeline and async (job-queue based, loops possible), StateGraph (adds shared state and loop semantics; the playground default).
  • Pipeline / job — the persisted record of one run: the pipeline is the run, each node execution is a job with a status.
  • Compilation — turning the stored workflow into an executable plan: which nodes run, in what order, which cycles are legal (RT-CMP).
  • Output / error Output — the envelope a node execution returns. A processor signals failure by throwing \RuntimeException; the runtime converts that into an error-status Output.
  • Error routing — if a failed node has an error wire, the failure flows down it and the run continues; if it has none, the run fails (RT-ERR).
  • Branching / active_branches — a gateway node reports which named branches stay alive; wires leaving inactive branch ports are not followed.
  • Readiness — when a node has enough inputs to run: several wires into the same port = any one suffices (OR); wires into different ports = all must arrive (AND).
  • Interrupt — a node pausing the run to ask a human; resolving the interrupt resumes the run.
  • Snapshot / checkpoint — saved run progress, so a run can resume where it stopped.
  • StateGraph state / reducers — shared state carried across loop iterations, merged per field: messages append, data merges, everything else is replaced.
  • initialData — the input payload a run is launched with.

Launching from outside

  • Launch-input manifest (input_ports) — the workflow's public face: the author explicitly names which inner ports may be filled by an outside caller. Anything not declared does not exist at the launch boundary.
  • Exposure entry — one manifest row: {name, node_id, port}.
  • Schema snapshot — a frozen copy of each declared input's schema, so callers validate against what the author published, not against live plugin code. (Not the same as the run-progress snapshot above.)

Expressions

  • Expression engines — the four mini-languages authors write inside nodes: expression_language (Symfony, calculations), twig (text templates), property_path (dot-paths into data), jsonpath ($.… queries). Chosen per node via the reserved engine param.

This document's own vocabulary

  • Rule — one testable promise with a stable ID.
  • Pinned — see "How to read" above: ✅ guaranteed by a test, ◐ partially, ❌ not at all.
  • Conformance fixtures — YAML files in modules/flowdrop_runtime/tests/fixtures/conformance/, each describing a small workflow plus its expected behavior, all replayed by one test base class. They are the executable spec; this registry indexes them.
  • Grammar coverage — pinned rules ÷ all rules. The headline metric.

Part I — GR: the workflow definition language

Everything about how a workflow is written, stored, validated, and understood — the "grammar" an author's intent is captured in. If a rule here breaks, the system misunderstands what the author meant.

GR-STORE — ingress and storage shape

What the system accepts when a workflow is created, updated, or imported over the API, and how it is cleaned up before hitting the database. These are the rules for getting a workflow stored safely.

ID Rule Impl Pinned
STORE-1 Request body: empty → 400; >8 MiB → 400; invalid JSON → 400; nesting at or past 64 levels → 400 (the bound is json_decode's $depth argument, so 63 levels is the deepest accepted document); non-object/array (any JSON scalar, incl. null) → 400. Every case raises BadRequestHttpException, which Symfony renders as the 400 src/Controller/Api/ApiResponseTrait.php:165-185 DecodeJsonRequestTest (11 cases, incl. both size/depth boundaries)
STORE-2 name required, ≤255 chars (create + update) → 400. Two edges worth knowing: the presence test is empty(), so the literal name '0' is refused as missing; and the cap is strlen(), i.e. bytes, so a 200-character multibyte name is over it. TRANSITIONAL (OPEN-19): both edges are transitional pins, not design — the target is presence = non-empty string (the literal '0' is a legal name) and a limit counted in characters, not bytes flowdrop_workflow/…/WorkflowsController.php:140-146,253-259 WorkflowStorageApiTest::testNameIsRequiredOnCreate, ::testNameLengthIsCappedOnCreate, ::testNameRulesApplyToUpdate
STORE-3 Client-supplied id that already exists → 409, never overwrite WorkflowsController.php:148-154 WorkflowStorageApiTest::testDuplicateClientIdConflictsWithoutOverwriting
STORE-4 Absent nodes/edges/metadata default to [] on create; on update only touched when present (partial PUT preserves). name is the exception — it is required on update too, so there is no name-omitted PUT. Note the response envelope is not the stored value for metadata: getClientMetadata() folds format/schemaVersion back in on read (preSave strips them). The same partial-PUT rule now covers interface (the client-facing view of input_ports/output_ports, MAN-20): absent on update leaves both port lists untouched; present rewrites BOTH sides from it, even when only one side is supplied — a missing or empty inputs/outputs array clears that side to NULL, matching the admin form's === [] ? NULL : convention. On create, absent interface leaves both sides at their NULL default WorkflowsController.php:161-163,269-277 (nodes/edges/metadata); applyInterface() (interface) WorkflowStorageApiTest::testAbsentCollectionsDefaultToEmptyArraysOnCreate, ::testPartialUpdatePreservesUntouchedCollections; WorkflowInterfaceApiTest::testUpdateWithoutInterfacePreservesPortLists, ::testUpdateWithEmptyInterfaceClearsBothSides
STORE-5 Validation failure at the REST boundary → 422 with exactly {success:false, error:'Workflow validation failed', details:[{code,message,locator}]}, and $workflow->save() is never reached. Errors only: validateStructure() returns NULL the moment ValidationResult::isValid() holds, so a warning neither blocks the save nor appears anywhere in the response — only the errors list is mapped, never warnings. locator is a rename of the validator's own parameter key. The same helper guards create and update, so both paths refuse identically and a refused update leaves the stored row untouched WorkflowsController.php:348-371; call sites :171-173, :279-281 WorkflowStructuralValidationTest::testApiRejectsMissingPlugin (body shape + non-empty locator), ::testWarningOnlyWorkflowIsAcceptedAndWarningsAreNotReported, ::testUpdatePathRejectsWithoutTouchingTheStoredRow
STORE-6 preSave slims in two independent passes, and only when !isSyncing(). Pass 1 drops transient canvas state (selected/dragging/deletable, plus nodeId at both the node and data level) from every node. Pass 2 drops the node-level type and reduces data.metadata to {node_type_id} — but only for a node with a resolvable anchor (data.metadata.node_type_id, or a legacy metadata.id which is renamed to it); an unanchored node keeps its metadata and its type verbatim, since nothing could re-enrich them on read. measured, position, data.config, data.label and data.extensions are kept. Slimming runs before parent::preSave() because the parent's calculateDependencies() reads the anchor and must see the renamed one. Config sync skips both the slimming and the created/changed re-stamp, so an imported fat node round-trips verbatim and re-slims on its next user save. Idempotent: re-saving an already-slim entity changes nothing Entity/FlowDropWorkflow.php:452-469 (gate + order), :508-511 (pass 1), :521-531 (pass 2) FlowDropWorkflowKernelTest::testCanvasStateStrippedOnSave (+edges, +metadata), ::testSlimmingIsSkippedDuringConfigSync, ::testUnanchoredNodeKeepsItsMetadata, ::testSlimmingIsIdempotent
STORE-7 Config schema is strict: data.metadata may contain only node_type_id; data.config is type: ignore (unconstrained at storage — only R6 constrains it) config/schema/flowdrop_workflow.schema.yml:272-311 FlowDropWorkflowKernelTest::testAnnotatedSnapshotPassesConfigSchema; ConfigSchemaConformanceTest
STORE-8 schema_version must be semver (\d+.\d+.\d+), enforced by a Regex constraint that only bites under config validation (plain schema conformance accepts any string). As with every Symfony Regex, the empty string is exempt — the entity's '0.0.0' property default is what keeps an unset version semver flowdrop_workflow.schema.yml:68-73 WorkflowStorageApiTest::testSchemaVersionMustBeSemver
STORE-9 Import gates, in this fixed order: envelope format → signature trust → capability manifest → node-type generation → validation → flow-id shape → id collision. Every refusal after generation rolls the generated node types back. BundleImporter::import() returns a BundleImportResult status and never an HTTP code; WorkflowsController::importResponse() is the sole mapper: UNSUPPORTED_FORMAT → 422 Unsupported bundle format; UNTRUSTED_BLOCKED → 403 Bundle publisher is not trusted (+signature, hint) unless confirmed+permitted; MISSING_CAPABILITY → 422 Required processors are not installed (+manifest) — reported before validation so a missing plugin is actionable rather than a structural error; INVALID → 422 Workflow validation failed + details[{code,message,locator}]; CONFLICT → 409, never overwriting. Exposure entries are coerced for every bundle, trusted included, before they reach the validator: each entry keeps only name/node_id/port and only when scalar, cast to string; a non-array or fully-malformed entry becomes [] but keeps its index, so the validator reports it against its own schema.<side>.<index> locator instead of shifting every later one. The entity stores the original entries, so author metadata (title/description/examples) survives. ⚠ A non-empty flow.id must match /^[a-z0-9_]+$/ and be ≤64 bytes, but the check runs AFTER validation and returns INVALID carrying the (passing) validation result — so the caller gets a 422 Workflow validation failed with an EMPTY details array and no hint that the id was the reason. Known UX gap, pinned as-is; an empty flow.id skips the check (one is minted downstream). TRANSITIONAL (OPEN-19): the empty-details refusal is a transitional pin — the target 422 carries a details entry naming the cause (code identifying the flow-id refusal, locator flow.id); a rejection always names its reason Service/BundleImporter.php:76-174 (gates), :297-313 (coercion, applied unconditionally at :115-116), :126-132 (flow id); mapper WorkflowsController.php:450-505 BundleImporterTest (14 cases, incl. the 403/409/422 status codes through the real import door, locator-preserving coercion, original-entry persistence, and the empty-details flow-id refusal)
STORE-10 WorkflowDTO::fromArray() is a read/execute-path-only normalizer with exactly four production call sites (WorkflowCompiler:100, SynchronousOrchestrator:219, JobGenerationService:118, StateGraphOrchestrator:1694). It prunes any edge whose source or target does not resolve into droppedEdges (keyed by edge id); mints uniqid('edge_', TRUE) ids for id-less edges; fills node config as array_merge($metadata['config'], $data['config']), so the stored data.config value wins on collision and defaults only fill absent keys; and re-keys nodes and edges by id, so on duplicate ids the last occurrence wins and earlier ones vanish. None of this may run on the save/validate/mutate boundary, which must see the raw stored shape in order to report the very conditions this factory repairs src/DTO/WorkflowDTO.php:163-207 (prune :186-191, re-key :171/:190); merge WorkflowNodeDTO.php:188-189; mint WorkflowEdgeDTO.php:197 WorkflowDTOTest (4 prune cases + config-collision precedence + duplicate-id collapse); WorkflowDtoFromArrayCallSiteTest (structural: the call-site set is exactly those four)
STORE-11 The four entity status vocabularies (JobStatus, PipelineStatus, SessionStatus, MessageStatus) are backed enums; the backing value is the persisted field value and the string every JSON/event payload carries, so ->value is what crosses any boundary. The set helpers stay value lists: PipelineStatus::terminalStatuses(), MessageStatus::terminalStatuses(), JobStatus::activeStatuses(), allowedValues() (and PausedReason::budgetReasons(), still a constant bag) return the strings, so they drop straight into entity-query conditions and allowed_values flowdrop_job/…/JobStatus.php, flowdrop_pipeline/…/PipelineStatus.php, flowdrop_session/…/SessionStatus.php, flowdrop_session/…/MessageStatus.php StatusEnumContractTest
STORE-12 A finished turn releases the session as completed, not idle; idle means created-but-never-executed. LEGACY READ: session rows written before 2.x carry idle meaning "turn finished", so WorkflowExecutionService::getSessionStatus() maps BOTH idle and completed to Completed, and the sub-workflow sync wait accepts both. That mapping stays until the old rows are migrated or age out — no data migration ships with the writer change writers SessionExecutionService.php:301,323,356, DeferredMessageProcessor.php:193, SessionInterruptResolvedSubscriber.php:159,254,257,264; legacy read WorkflowExecutionService.php:361-362,845-856 StatusEnumContractTest, SessionTurnTest
STORE-13 One name per concept for a node's type. A node-type config-entity ID is node_type_id everywhere it appears in a payload — job metadata, node metadata, the serialised node execution result, the node-status broadcast payload, and persisted session-message metadata. A node's visual type is visual_type in the per-node snapshot payload (and nodeType as a config key, per SCH-20). The bare node_type key is not written anywhere: it used to mean the entity id in run/status/message payloads but the visual type in the snapshot payload, so a reader could not tell which without knowing the producer. DUAL READ (persisted payloads only, new key wins): SessionService::formatMessageForApi() reads a pre-2.x message's node_type and re-emits it as node_type_id; NodeSnapshot::fromArray() reads a pre-2.x snapshot's node_type and re-emits it as visual_type. Neither rewrites a stored row. The snapshot arm may be dropped once no pre-2.x snapshot is restorable (per-run scratch state — one release plus a cleanup run); the message arm must stay until pre-2.x message rows are migrated or deleted, since conversation history does not age out. Ephemeral payloads (broadcast node status, NodeExecutionResult::toArray()) need no dual read. node_id is untouched — it is the pinned jobs[]/node_statuses key (PIPE-4) writers NodeExecutionResult.php:158-175, NodeRuntimeService.php:125-131, SynchronousOrchestrator.php:340-348, SessionExecutionService.php:1516-1526, WorkflowSnapshot.php:237-247; dual reads SessionService::normaliseNodeTypeKey(), NodeSnapshot::normaliseVisualTypeKey() NodeSnapshotVisualTypeKeyTest (5), SessionServiceFormatMessageTest (3), NodeExecutionResultKeyTest, SessionTurnTest, SynchronousOrchestratorTest
STORE-14 One workflow object, one builder. Every workflow-returning surface publishes the same nine keys in the same order — id, name, description, nodes, edges, metadata, created, changed, uid — with nodes enriched by NodeMetadataResolver::enrichNodes() and metadata the read-side getClientMetadata() value (STORE-4), not the stored one. The casing is uniformly camel-free, including created/changed/uid, which is what makes it different from the playground and interrupt payloads it sits next to. A conditional tenth key, interface, is appended immediately after metadata when the workflow declares at least one input or output port (MAN-20); it is derived from getInputPorts()/getOutputPorts() and their matching schema snapshots, and is omitted entirely — never emitted as {}/[] — when both port lists are empty. ✅ Resolved: the four-hand-copied-literals risk this row used to record is closed — WorkflowsController::buildWorkflowResponseData() is now the single builder all four surfaces (list row, create, read, update) call; interface itself is projected by the shared WorkflowInterfaceProjection::fromWorkflow() utility — which the editor page's server-side embed (WorkflowController::openWorkflowEntity(), a fifth workflow-publishing surface this row's original warning predicted) also calls, so the derivation cannot fork per surface WorkflowsController.php (buildWorkflowResponseData(), call sites at the list/create/read/update methods); Utility/WorkflowInterfaceProjection.php; WorkflowController.php (editor embed) WorkflowsApiPayloadContractTest::testEveryWorkflowSurfaceSharesOneKeySet (all four surfaces compared against each other, key set + order + value types, both with and without a declared interface); WorkflowInterfaceApiTest::testNoPortsWorkflowOmitsInterfaceKey
STORE-15 Workflow search is a CONFIG-entity CONTAINS over label, literal and case-insensitive. flowdrop_workflow is a config entity, so ?search= builds a Config\Entity\Query\Condition, whose operator set is closed (=, <>, <, >, <=, >=, IN, NOT IN, STARTS_WITH, CONTAINS, ENDS_WITH) and contains no LIKE. The spelling is CONTAINS and it takes the bare term: no escapeLike(), no %…% wrapping — both are SQL idioms this storage never runs, so under them % and _ would be wildcards, and they are not. Condition lowercases the stored value and the term before str_contains(), so matching is case-insensitive and positional-anywhere. The same condition is applied to the count query, so pagination.total describes the filtered set (API-6). A non-matching term is an empty page with a coherent pagination block, not a 404 and not an error. ⚠ Two edges: the guard is if ($search), so the term '0' is treated as no search at all; and an array-valued ?search[]= still answers 500, because Symfony's InputBag::get() refuses a non-scalar before any condition is built and the method's backstop (API-7) reports it generically. ⚠ The result query sorts changed DESC and nothing pins that ordering. TRANSITIONAL (OPEN-19): the two ⚠ input edges are transitional pins — the target treats '0' as a searchable term like any other and answers an array-valued ?search[]= with 400, not 500 WorkflowsController.php:60-96 WorkflowsApiPayloadContractTest::testSearchFiltersTheListCaseInsensitivelyAndLiterally (11 term cases incl. %, _, 100%, snake_case, the wildcard-refuting _nvoice, three casings, the empty result's pagination block, and the array-term 500)

GR-API — API-boundary conduct

Rules every JSON HTTP door must follow, regardless of which module owns the route, all confirmed against the code.

ID Rule Impl Pinned
API-1 Nine controllers accept a JSON body and all nine decode via ApiResponseTrait::decodeJsonRequest(): WorkflowsController, WorkflowLaunchApiController, SessionTurnApiController, PlaygroundApiController, ChatApiController, SnapshotApiController, TriggerConfigApiController, PipelineSignalController, InterruptApiController. The gate is 8 MiB measured by strlen(), depth 64 (so 63 levels is the deepest accepted document), non-array decode refused — each as BadRequestHttpException → 400. A bare json_decode($request->getContent()) at an HTTP door is a defect, and there are none left. Adopting the decoder is necessary but not sufficient: it signals a bad body by throwing, and BadRequestHttpException is an \Exception, so a door that decodes inside a try whose only handler is catch (\Exception) reports a client error as a 500. Two doors did exactly that (PlaygroundApiController::sendMessage, ChatApiController::sendMessage) while looking compliant; both now carry a catch (HttpExceptionInterface) arm ahead of the backstop, returning the controller's own 400 envelope. Three doors (InterruptApiController, SnapshotApiController, TriggerConfigApiController) are safe by placement — they decode ahead of any try. Nuance: an optional body is guarded before the decoder, not tolerated inside it — SessionTurnApiController::executeTurn and PlaygroundApiController::createSession map an absent body to [] and send everything else through the gate, so "optional" never means "unvalidated" trait src/Controller/Api/ApiResponseTrait.php:162-196; optional-body idiom PlaygroundApiController.php:205-214; swallow guards PlaygroundApiController.php:452-457, ChatApiController.php:139-144 DecodeJsonRequestTest (11 branch cases + ::testEveryJsonBodyControllerUsesTheSharedDecoder, ::testNoControllerHandDecodesRequestBody and ::testNoControllerSwallowsTheDecoderBadRequest, source scans over src/Controller and modules/*/src/Controller); PlaygroundCreateSessionBodyContractTest (6 cases: absent body and {} still 201, malformed/non-object/oversized 400, valid name honoured); PlaygroundSendMessageErrorContractTest::testMalformedBodyIsRefusedWithBadRequest + ::testOversizedBodyIsRefusedWithBadRequest; WorkflowLaunchApiAccessTest::testPathologicalBodiesAreBounded; SessionTurnApiInputContractTest::testPathologicalBodiesAreBounded + ::testMalformedBodiesAreRejected. The chat door is covered by the swallow scan only — flowdrop_chat depends on the separate flowdrop_ai_provider repository, so its container cannot compile in a kernel test here
API-2 The session-turn door validates inputs identically to the launch door (unknown keys rejected, required enforced, schema-checked, strict resolve); the lenient else that array_merged raw caller input into node-keyed initialData is deleted, not flagged strict — amends MAN-12/MAN-13. Both doors now share WorkflowInputChecker, so the same body earns the same verdict; the turn door raises InvalidTurnInputException → 400 shared flowdrop_workflow/src/Utility/WorkflowInputChecker.php; WorkflowLauncher::validateInputs(); SessionTurnService::executeTurn(); strict resolve SessionExecutionService::buildInitialData() SessionTurnApiInputContractTest (6 cases incl. undeclared + node-keyed refusal)
API-3 "Session has no workflow" is a dedicated typed exception (MissingWorkflowException) mapped to 409 Conflict on both doors with the generic body Session has no associated workflow — a state problem on the session, same refusal family as the concurrent-turn 409; the internal message (naming the session id) goes to the logger, never the response. Controllers never catch \RuntimeException wide (masks real 500s) nor echo $e->getMessage() — the launch controller's typed pattern, everywhere. 409 is the agreed number, cross-checked with the editor package's maintainers and confirmed independently on their side (a workflow-less session is session state, the same refusal family as the concurrent-turn 409) — it is not a free choice a later batch may re-litigate into 422 throw SessionTurnService.php (executeTurn); mapped SessionTurnApiController.php, PlaygroundApiController.php (sendMessage); pattern origin WorkflowLaunchApiController.php SessionTurnApiInputContractTest::testWorkflowlessSessionIsRefusedWithConflict + ::testWorkflowlessSessionThrowsTypedException; PlaygroundSendMessageErrorContractTest
API-4 RETIRED — endpoint deleted (2.x). The node config-validate endpoint (POST /api/flowdrop/nodes/{plugin_id}/validate) reported problems the save path accepts: it used the raw plugin schema, not the derived, edge-aware view (same spirit as R6), so it flagged configurable && required params an input edge would satisfy — and it could not become edge-aware without a contract change (it received only config, no edges). It had ZERO fdnpm consumers, so the ruled fix was deletion: route, NodesController::validateConfiguration() and its validateValueType() helper are gone. Save-time validation (GR-VAL) is the one config-verdict surface removed; save-path verdicts WorkflowValidator.php
API-5 paginatedResponse() is a THIRD envelope, and its has_more is snake_case by construction. Alongside {success, data} (successResponse()) and {success, error} (errorResponse()), ApiResponseTrait::paginatedResponse() emits {success, data, pagination} where pagination is exactly total, limit, offset, has_more in that order, and has_more is computed as ($offset + $limit) < $total — a page-arithmetic claim, not a second query. Exactly three endpoints emit it: the workflow list, the playground session list and the snapshot list. The spelling is the contract in the same sense RT-PIPE's mixed casing is: has_more ships in the same response as camelCase rows on the playground list (workflowId, createdAt) and as snake_case rows on the snapshot list, so it is neither "the snake_case surface" nor "the camelCase surface" — it is a fixed key belonging to the shared envelope, and normalising it toward the rows of any one endpoint breaks fdnpm on the others. A door that hand-builds its own pagination block instead of calling the trait is how the four keys drift trait src/Controller/Api/ApiResponseTrait.php:95-106; emitters WorkflowsController.php:117, PlaygroundApiController.php:111,173, SnapshotApiController.php:230 WorkflowsApiPayloadContractTest::testListEnvelopeAndPaginationBlock (envelope keys + the four pagination keys in order); PlaygroundApiPayloadShapeTest::testListSessionsRowAndPaginationShape (has_more beside camelCase rows, and no camelCase alias next to it); SnapshotApiPayloadShapeTest::testListRowAndPaginationShape + ::testListHasMoreReflectsTheRemainingTotal (the arithmetic)
API-6 Paging parameters are clamped silently, and total is counted before pagination with the same filters. Every paginated door caps limit at 100, floors offset at 0, and reports the clamped values back in the pagination block — a client that asked for 1000 and was served 100 is told 100, or its has_more arithmetic is wrong. Out-of-range paging is corrected, never refused: there is no 400 arm. total comes from a separate count query carrying every filter the result query carries (the workflow door's search, the snapshot door's workflow_id/thread_id/status, the playground door's ?ids= and ownership scope), so a filtered page's total describes the filtered set, not the table. ⚠ The lower bound is not uniform: the workflow and playground doors also floor limit at 1 (max(1, min(100, …))), while the snapshot door applies the cap only (min(…, 100)) WorkflowsController.php:66-96; PlaygroundApiController.php:95-96,159-171; SnapshotApiController.php:172-179,212-228 WorkflowsApiPayloadContractTest::testPagingParametersAreClampedAndReported (four clamp cases + the pre-pagination total); PlaygroundApiPayloadShapeTest::testListSessionsClampsLimitAndOffset; SnapshotApiPayloadShapeTest::testListLimitIsCappedAndOffsetFloored + ::testListFiltersNarrowRowsAndTotalTogether
API-7 A blanket catch (\Exception) on an API method is a reporting device, never an implementation — and it is a defect class in its own right. Where one exists it logs at error on the owning module's channel with the caught getMessage() and returns a fixed generic string through errorResponse(); the caught message never reaches the response body (that is API-3's rule, restated as the backstop's obligation), and every typed arm the method has — HttpExceptionInterface first, then the domain exceptions — sits ahead of it (a door that decodes outside any try needs no such arm, per API-1). What the backstop cannot do is tell a caller which kind of failure it is looking at, and that is the hazard: it renders "this endpoint has never worked" indistinguishable from "the server hiccupped". Three shipped endpoints proved it — GET /workflows?search= (a QueryException, config entity queries have no LIKE operator), GET /pipeline/{id}/logs (an \InvalidArgumentException, execution_logs is a field that does not exist) and the playground message routes (a 404 from an int vs string id comparison) — each routed, documented, answering, and permanently broken behind a plausible 500/404. So an API method carrying a backstop is trustworthy only to the extent a test drives its success arm through the real controller: a suite that asserts only the refusal arms would have passed on all three backstops WorkflowsController.php:123-127, PlaygroundApiController.php:478-483, InterruptApiController.php:120-130, PipelineApiController.php:85-91,119-125,187-193,221-227, SnapshotApiController.php:109-114, CategoriesController.php:61-70 ✅ the three success arms driven through the real controllers: WorkflowsApiPayloadContractTest::testSearchFiltersTheListCaseInsensitivelyAndLiterally, PipelineApiPayloadContractTest::testLogsRouteAnswersItsDocumentedShape, PlaygroundApiPayloadShapeTest::testGetMessageReturnsItsOwnSessionsMessage + ::testGetMessageStatusPublishesTheLightweightPollShape; the typed-arms-ahead-of-the-backstop half by DecodeJsonRequestTest::testNoControllerSwallowsTheDecoderBadRequest; the fixed-generic-string half on a cacheable door by CategoriesControllerTest::testStorageExceptionYieldsCacheableErrorEnvelope (META-3, which is where this rule was being violated)
API-8 DECIDED (OPEN-18) — go-forward rule, not yet built: every refusal a FlowDrop API door emits carries a stable machine-readable error_code beside the human error string, and message text is never contract — no client may classify a refusal by substring, and no test may pin wording as load-bearing. The 422 validation path already complies (details[].code, STORE-5). The first retrofit has landed: ApiResponseTrait::errorResponse() takes an optional $errorCode and emits error_code only when supplied — additive, so no existing consumer sees a change — and the signal API's three substring-classified 409s (INT-16) now supply PIPELINE_TERMINAL, INWARD_SIGNAL_ALREADY_PENDING and NO_ACTIVE_PAUSE, published as constants on PipelineSignalController because a code's meaning must have one definition a client and a test can both name. The rule stays ❌ because it says every refusal, and most doors still emit none; it is now a migration with a first step taken rather than a design with none. New endpoints comply from birth. Codes join the never-renumbered discipline of the R-code namespace: once published, a code's meaning never changes trait src/Controller/Api/ApiResponseTrait.php:84-97; first retrofit PipelineSignalController.php:50-60 ❌ the rule as written (every door) is unpinned; the three retrofitted refusals are pinned by PipelineSignalRoutedPathTest::testRefusalMessagesKeepTheirDiscriminatingSubstrings

GR-VAL — the validator (existing R-code namespace; canonical list: WorkflowValidatorInterface.php:18-139)

Every reason a workflow is refused — or warned about — when the author saves it. Errors (Sev E) block the save; warnings (W) never do.

Rule order: structure → node type → plugin → config → expressions → edge endpoints → edge exposure → exposure map → terminal reachability. Warnings never block (WorkflowValidator.php:56-58). Locator grammar: workflow | node.{id} | node.{id}.config[.{key}] | schema.{input|output}.{index} | edge.{index}.

⚠ Half of this section's guarantees rest on one mocked test, and the rows say which. WorkflowValidatorTest prophesies both the node processor plugin manager and the node metadata resolver, so a rule pinned only there is pinned against what the prophecies return — if real plugin metadata diverges from the mocks, the rule is guaranteed by a fiction and nothing reports it. Thirteen rules now carry a second witness against real flowdrop_node_type entities and real processor plugins, as conformance fixtures under modules/flowdrop_runtime/tests/fixtures/validator/ run by ValidatorConformanceTest; the launcher re-runs the validator over the stored definition (VAL-LAUNCH), so those fixtures exercise the real stack, and each row's Pinned cell names its fixture:

  • Structural — R2, R3, R8.a, R8.b, R12, R13. Graph shape; no schema involved.
  • Schema-driven — R6.d, R6.e, R6.f, R6.g, R6.h, R6.i, R6.j. These are the ones the mocks stood in for most heavily: the schema R6 judges config against is the derived, edge-aware one that NodeMetadataResolver::buildForNodeTypeId() builds from the live plugin, which is precisely the object the unit test replaces with a prophecy.

The remaining 20 are still mock-only: R1.b, R1.c, R4.a–R4.e, R5.a–R5.c, R6.a, R6.c, R6.l, R7.b, R7.d, R7.e/f, R8.c, R11, W-T, VAL-PERF. Three of those are blocked rather than merely unwritten: R6.a needs the harness to stop coercing a fixture's data.config to an array before the validator sees it (a non-array config is the whole rule); R6.c and R6.l are warnings, and a fixture observes refusals, not warnings, so they need a warning channel in the expect block. R4.a–R4.e need the fixture schema to carry workflow exposure entries, which it does not yet.

The unit test stays the primary pin — it is the fast feedback loop — and adding a second witness never means deleting it. Where the two disagree, the disagreement is the finding: either the code or the rule sentence is wrong. R2/R3 were numbering holes; they and R9–R13 were assigned by the decisions in Part III. R2, R3, R9, R10, R11, R12 and R13 are all implemented.

ID Rule Sev Pinned
R5.a >500 nodes rejected (R5_TOO_MANY_NODES) E WorkflowValidatorTest::testR5TooManyNodes
R5.b >1000 edges rejected (R5_TOO_MANY_EDGES) E ::testR5TooManyEdges
R5.c Node with empty/absent id rejected, located by array position E ::testR5NodeMissingId
R1.a Node whose node-type-resolved executor plugin has no definition rejected (R1_PLUGIN_MISSING), no instantiation E ::testR1MissingPlugin; kernel 422+not-persisted
R1.b Node with no node_type_id anchor / unresolvable node type is skipped by R1 (deferred) — R1's skip stands; a node anchored to an unknown type is rejected by R9, while a legitimately anchor-less node stays accepted accept ::testR1EmptyPluginIsSkipped
R1.c A missing plugin is reported once, never re-reported per exposed port ::testMissingPluginIsNotDoubleReported
R6.a Non-array data.config rejected (R6_CONFIG_INVALID) at node.{id}.config — but only on a node carrying a node_type_id anchor: checkConfig() returns before the guard for an anchor-less node, so a Note-style node with a scalar data.config is accepted by all of R6 E WorkflowValidatorTest::testR6NonArrayConfigIsRejected, ::testR6AnchorLessNodeSkipsTheNonArrayConfigGuard
R6.b Every key on the derived configSchema.required list must be present (array_key_exists — explicit NULL satisfies); each missing key yields one R6_CONFIG_REQUIRED at node.{id}.config.{key}. The list is not the node type's required flags: a param the node type also marks connectable is deliberately omitted from configSchema.required (SCH-6), so a required && configurable && connectable param absent from data.config is accepted at save even when no input edge supplies it — the omission is decided from the node-type flags, never from the actual edge set E ::testR6MissingRequiredConfigKeyIsRejected, ::testR6ExplicitNullSatisfiesRequiredAndIsNotValidated, WorkflowStructuralValidationTest::testR6ConnectableRequiredParamIsAcceptedWithoutAnEdge
R6.c Unknown config key → warning, accepted ("ignored at runtime") W WorkflowStructuralValidationTest::testValidatorWarnsButAcceptsUnknownConfigKey
R6.d Wrong JSON-schema type rejected; unknown type names pass. Shared shape of R6.d–R6.h: all five raise the same code R6_CONFIG_INVALID at node.{id}.config.{key} and run in one fixed short-circuit order — type → enum → minimum → maximum → minLength → maxLength → pattern — with a return after the first violation, so one config key never yields more than one error (a value both outside its enum and over maxLength reports the enum failure only). They are distinguishable by message text, not by code E ::testR6WrongTypeIsRejected, ::testR6UnknownTypeNamePasses (a schema with no type at all passes too), ::testR6OnlyTheFirstConstraintViolationIsReported; second witness against the real derived schema validator/val-config-wrong-type.yml
R6.e Value outside enum (strict) rejected E ::testApiRejectsInvalidConfigValue; second witness against the real derived schema validator/val-config-enum-violation.yml
R6.f minimum/maximum violations rejected (bounds are checked for int/float values only — a numeric string is never bounds-checked) E ::testR6BelowMinimumIsRejected, ::testR6AboveMaximumIsRejectedButInRangeAccepted; second witness against the real derived schema validator/val-config-bounds-violation.yml (which also pins the numeric-string exclusion via ports_absent)
R6.g minLength/maxLength (mb_strlen) violations rejected E ::testR6LengthBoundsUseCharacterCount (äöüß, 4 chars / 8 bytes, against maxLength: 4 it must satisfy and minLength: 5 it must violate — both bounds fall between the two counts, so byte counting fails each in the opposite direction); second witness against the real derived schema validator/val-config-length-bounds.yml (same value, same two bounds, asserted through ports/ports_absent; verified by mutation — swapping mb_strlen for strlen fails it)
R6.h pattern mismatch rejected; an invalid schema pattern does not block the author E ::testR6PatternMismatchIsRejected, ::testR6InvalidSchemaPatternDoesNotBlockTheAuthor; second witness against the real derived schema validator/val-config-pattern-mismatch.yml (both halves: the mismatch refused, the unclosed character class not charged to the author)
R6.i Config value NULL = unset, never validated accept ::testR6ExplicitNullSatisfiesRequiredAndIsNotValidated; second witness against the real derived schema validator/val-config-explicit-null.yml — the one R6 rule no refusal fixture can express, so it launches a node whose NULLs would fail every constraint it declares
R6.j Unresolvable node type skips the schema-driven checks of R6 — required keys, type/enum/bounds/pattern and the R6.c unknown-key warning are all skipped. Annotated 2026-07-30: not literally "all of R6" — the R6.a non-array guard runs before the schema lookup, so a scalar data.config is still rejected on a node whose type resolves to no metadata accept ::testR6UnresolvableNodeTypeSkipsSchemaChecks, ::testR6NonArrayConfigIsRejectedWithoutResolvableType; second witness against the real derived schema validator/val-unresolvable-type-skips-schema-checks.yml, whose load-bearing assertion is rules_absent: [R6] — that R6 stays silent is the claim, and naming the rule that did fire says nothing about it
R6.l A config string containing {{ secrets. without the leading $ produces a non-blocking warning at node.{id}.config.{key} telling the author to write ${{ secrets.NAME }}; array-valued config is walked recursively (so a reference nested in JSON-shaped config such as HTTP headers is covered), a correctly written ${{ secrets.X }} produces none, and the save is never blocked. Implemented as a local regex, never a call into the runtime secret resolver — flowdrop_workflow sits below flowdrop_runtime in the module layering W ::testMalformedSecretReferenceWarns, ::testWellFormedSecretReferenceIsSilent
R6.k RETIRED — validateParams() deleted from the processor contract (2.x). The method was never invocable to any effect (the base returned success and no verdict gated anything), so it was removed from FlowDropNodeProcessorInterface along with every override and both runtime call sites (NodeRuntimeService::validateNode, ScopedToolInvoker::validateArgs). Save-time schema/expression validation (R6, R11) is the only config-verdict surface; invalid runtime values fail inside the node (GR-ERR) design
R8.a Edge with an unresolvable source rejected (R8_EDGE_SOURCE_MISSING); empty = missing E ::testR8DanglingSourceIsRejected; second witness against real node types validator/val-edge-endpoint-missing.yml
R8.b Edge with an unresolvable target rejected (R8_EDGE_TARGET_MISSING); empty = missing E ::testR8DanglingTargetIsRejected; second witness against real node types validator/val-edge-endpoint-missing.yml
R8.c Both endpoints missing → two errors, same locator E ::testR8BothEndpointsMissingYieldTwoErrors
R7.a Edge into a declared-but-not-exposed input port rejected (R7_EDGE_TARGET_NOT_EXPOSED) at edge.{index} E ::testEdgeIntoNotExposedInputPortIsRejected
R7.b Edge from a declared-but-not-exposed output port rejected (R7_EDGE_SOURCE_NOT_EXPOSED) at edge.{index} (canonical: error, ships hidden) E ::testEdgeFromNotExposedErrorPortIsRejected
R7.c Effective exposure = instance config.ports[].exposed override, else metadata exposedByDefault; exposing via config legalizes the edge accept ::testEdgeFromExposedErrorPortIsValid
R7.d Port absent from node-type metadata (dynamic/user-defined) is out of R7 scope accept ::testExposedAndUnknownPortEdgesAreValid
R7.e/f Unparseable handle, empty/unknown endpoint node, unresolvable node type → R7 skipped (R5/R8/R1 own those). Annotated 2026-07-30: the skip is per endpoint, not per edge — an edge with one unusable handle or one dangling node still has its other endpoint checked, so an R8 dangling-source error and an R7 hidden-target error can both be reported for the same edge (by construction, since the two passes are independent). The unresolvable-type arm also splits by cause: an unknown node type is reported by R9 (not R1), a resolvable type whose executor plugin is missing by R1, and an anchor-less node by nobody — in all three R7 stays quiet accept ::testR7UnparseableHandleSkipsTheExposureCheck, ::testR7UnparseableHandleDoesNotSkipTheOtherEndpoint, ::testR7MissingEndpointNodeIsLeftToR8, ::testR7MissingEndpointNodeStillChecksTheLiveEndpoint, ::testR7UnresolvableNodeTypeSkipsTheExposureCheck
R4.a Exposure name must match /^[a-z0-9_-]+$/ — empty or absent included — rejected with R4_NAME_FORMAT at schema.{side}.{index}. Unlike R4.c this does not skip the entry's remaining checks: R4.b uniqueness, node existence and port declaration all still run, so two name-less entries yield two R4_NAME_FORMAT plus one R4_NAME_DUPLICATE on the empty name E ::testR4NameFormat, ::testR4NameFormatDoesNotSkipTheEntry
R4.b Exposure names unique per side (input/output independent) E ::testR4DuplicateName
R4.c Exposure node_id must exist; failure skips the entry's other checks E ::testR4NodeMissing
R4.d Exposure port must be declared by plugin param schema (input) / output schema (output). The check still skips a node whose plugin is unresolvable (don't double-report R1/R9), but the old import-path hole is closed: R9 rejects a node anchored to an unknown type, and BundleImporter now generates a bundle's node types before validating, so the skip no longer swallows the normal import path — it remains only for anchor-less nodes and for types whose plugin R1 already reports. Annotated 2026-07-30: the skip covers only the port-declaration check and R10 (both need the plugin) — R4.a's name format and R4.b's uniqueness are decided from the entry alone and are still reported for such a node E ::testR4PortMissingOnOutput, ::testR4PortCheckSkipsAnchorLessNode, ::testR4PortCheckSkipIsNarrowerThanTheEntry
R4.e Exposure name must not be a runtime-reserved parameter name (ReservedName::reservedExposureNames(), currently __interrupt_id__) — rejected with R4_NAME_RESERVED at schema.{side}.{index} on either side. R4.a's format regex admits __-prefixed names, so this closes the gap where an author-exposed name would collide with a value the runtime injects into the same manifest (e.g. WorkflowNode::getParameterSchema()'s injected __interrupt_id__) E ::testR4NameReservedOnInput, ::testR4NameReservedOnOutput, ::testR4NonReservedDunderFreeNameIsAccepted
W-T Every edge whose source node's plugin class implements TerminalNodeProcessorInterface (read from the plugin definition's class, never instantiated) yields one non-blocking warning at edge.{index} naming the target and the terminal source — so a terminal node with N outgoing edges produces N warnings and isValid() stays TRUE. Only sources are inspected (an edgeless workflow looks nothing up) and the target is not checked for existence, so an edge from a terminal node to a deleted node produces this warning and R8's R8_EDGE_TARGET_MISSING; a dangling source is skipped (R8 owns it) W ::testTerminalNodeDownstreamWarns, ::testTerminalNodeWarnsOncePerOutgoingEdge
VAL-PERF The validator calls FlowDropNodeProcessorPluginManager::createInstance() only for the R4.d/R10 exposure checks, memoised per plugin id, so a workflow with no exposure entries makes zero direct constructions. Not a claim about total plugin construction: R6's config-schema lookup and R7/R10's portExposureDefaults both call NodeMetadataResolver::buildForNodeTypeId() (NodeMetadataResolver.php:253), which constructs the executor plugin itself — once per distinct node type per validation. R1 and W-T stay definition-only (hasDefinition()/getDefinition()) inv ::testPluginInstanceIsMemoised, ::testValidWorkflowConstructsNoPlugins (both mock the resolver, so they pin exactly the narrowed claim)
VAL-LAUNCH The full validator re-runs at launch (defense in depth): WorkflowLauncher::validateDefinition() re-runs every rule on the stored definition and, on any error, logs a warning and throws WorkflowLaunchValidationException carrying the ValidationResultbefore any job generation, pipeline creation, deferred scheduling or queue item. The HTTP boundary maps it to 422 with per-error code/message/locator detail, mirroring the save path inv CanvasHiddenInputPortTest (3), WorkflowLauncherTest::testInvalidDefinitionIsRejectedBeforeLaunch (throw + logged warning + never() on all four side effects), WorkflowLaunchApiAccessTest::testInvalidDefinitionIsRejectedWith422
R2 Node ids must be unique (R2_NODE_DUPLICATE_ID) — the read path keys nodes by id, so a duplicate would silently collapse last-wins (← OPEN-4) E ::testR2DuplicateNodeIdIsRejected; second witness against real node types validator/val-duplicate-node-id.yml
R3 Edge ids must be present (R3_EDGE_MISSING_ID) and unique (R3_EDGE_DUPLICATE_ID) at save; read-path minting stays legacy read tolerance only (← OPEN-5) E ::testR3EdgeMissingIdIsRejected, ::testR3DuplicateEdgeIdIsRejected; second witness against real node types validator/val-duplicate-edge-id.yml (the duplicate half; the missing-id half is unreachable through the conformance harness, which supplies a default edge id)
R9 Node's node_type_id anchor must resolve to an existing node type (R9_NODE_TYPE_UNKNOWN); the error names the node and the type. Anchor-less nodes (Notes and other non-executables) are out of scope. Distinct from R1 by construction: entity missing → R9 (no plugin to check, R1 quiet); entity present but plugin missing → R1 — one defect, one report. BundleImporter generates a bundle's node types before validating (rolling them back when the import is refused) and a dry run discounts R9 errors for manifest-generatable types, so bundle import keeps working; config sync runs no validation, so already-synced content is caught at next save/launch (VAL-LAUNCH). Doctor remedies: Replace node type / Remove node, shared with R1 (← OPEN-6) E ::testR9UnknownNodeTypeIsRejected, ::testR9KnownNodeTypePasses, ::testR9AnchorLessNodeIsSkipped; importer ordering + rollback in BundleImporterTest; second witness against the real resolver validator/val-unknown-node-type.yml
R10 Workflow-exposure entry must target a port exposed on the target node instance (R10_EXPOSURE_HIDDEN_PORT) — the exposure-map twin of R7, resolved through the same chain (instance config.ports[].exposed override → metadata exposedByDefault → exposed): hidden means hidden in both directions. The error names the workflow port, the node and the node port. Metadata-undeclared (dynamic) ports are out of scope, mirroring R7.d; disjoint from R7 by construction — R7 inspects edges, R10 inspects exposure entries, so one hidden port never earns one entry two reports. Doctor remedies: Expose the port on the instance (R7's expose-port mutation) / Remove the exposure entry (RemoveExposureEntry). The authoring picker is filtered to match (MAN-3), so an author is never offered an entry this rule would then reject — one codebase, one picker, no cross-repo gate (← OPEN-2; amends MAN-3) E ::testR10ExposureOfHiddenInputPortIsRejected, ::testR10ExposureOfDefaultHiddenOutputPortIsRejected, ::testR10InstanceOverrideLegalizesExposure, ::testR10MetadataUndeclaredPortIsSkipped; doctor arm ValidatorDiagnosticTest::testExposureHiddenPortOffersExposeAndRemove; mutation WorkflowMutatorTest::testRemoveExposureEntry
R11 Every configured expression must pass its engine's validate() at save (R11_EXPRESSION_INVALID); the error names the node, config key, engine and expression. Config carries no generic "this is an expression" marker, so the check is scoped to the known expression-bearing keys of the in-tree processors: data_extractor.path, data_mapper dynamic output values, data_shaper.mapping sources (special values like _LITERAL:/_NOW_ skipped, nested _source/_each walked), prompt_template.template (twig). All four engines are wired; twig/jsonpath verdicts are trivially permissive (LANG-12/13), which is harmless. Empty expressions are always valid; an unknown engine id is skipped — R6's enum check on the engine key owns that error. An engine whose validate() throws is treated as a rejection (one R11_EXPRESSION_INVALID for that expression) and the throwable is never propagated, so a misbehaving engine yields the caller's 422 with a per-expression error rather than a 500 (← OPEN-3; resolves LANG-5) E ::testR11ValidExpressionPasses, ::testR11InvalidPropertyPathExpressionIsRejected, ::testR11InvalidExpressionLanguageExpressionIsRejected, ::testR11EmptyExpressionIsAccepted, ::testR11UnknownEngineIsLeftToConfigValidation, ::testR11ShaperSpecialValuesAreSkippedButSourcesAreChecked, ::testR11PromptTemplateIsCheckedAgainstTwig, ::testR11ThrowingEngineIsTreatedAsRejection
R12 An edge whose non-empty source equals its target is rejected (R12_EDGE_SELF) at edge.{index} regardless of handles — wiring a node's own output into its own input is still refused — and regardless of whether the node exists (a self-edge on a deleted node also earns R8's two errors). An edge with an empty source is never reported as a self-edge even when the target is empty too: R5/R8 own that (← OPEN-7) E ::testR12SelfEdgeIsRejected, ::testR12SelfEdgeWithDistinctHandlesIsStillRejected; second witness against real node types validator/val-self-edge.yml
R13 Duplicate parallel edges rejected (R13_EDGE_PARALLEL_DUPLICATE); the error names the earlier edge's position. Detection keys on the two endpoint ids and the two raw handle strings verbatim (source|sourceHandle|target|targetHandle) with no port-name parsing or normalisation — so edges between the same nodes over different ports stay valid, and two edges over the same logical port spelled differently (one with sourceHandle omitted, one with the explicit {node}-output-{port} handle) are both accepted. Edges with an empty endpoint are excluded from the comparison entirely (← OPEN-7) E ::testR13DuplicateParallelEdgeIsRejected, ::testR13DistinctPortPairsBetweenSameNodesAreValid, ::testR13OmittedAndExplicitHandlesAreNotDuplicates; second witness against real node types validator/val-parallel-duplicate-edge.yml

GR-EDGE — edge grammar

A wire carries no declared type. Its meaning — data, trigger, error, tool, or loopback — is deduced from the names of the ports it connects. These are the deduction rules; get them wrong and a wire silently changes meaning.

ID Rule Impl Pinned
EDGE-1 Handle encoding {nodeId}-{input\|output}-{portName}; port name = everything after the FIRST -input-/-output-; kind classification is suffix-exact, never substring src/Utility/EdgePortClassifier.php EdgePortClassifierTest::testExactSuffixMatching, ::testPortNameExtraction, ::testPortNameSplitsAtTheFirstDirectionMarker
EDGE-2 No wire key is structurally required — absent keys default to ''/[] and become R8 "missing endpoint" src/DTO/JsApi/Edge.php:66-75 EdgeTest::testDefaultsForMissingKeys; WorkflowValidatorTest::testEdge2KeylessEdgeIsReportedAsMissingEndpoints
EDGE-3 Trigger edge ⇔ target handle ends -input-trigger ReservedName::PORT_TRIGGER; classifier ✅ conformance topology-*
EDGE-4 Loopback edge ⇔ target handle ends -input-loop_back (value is loop_back, NOT loopback) ReservedName.php:121-128 WorkflowCompilerSpecialEdgeTest::testLoopbackEdgeDetectedByHandle
EDGE-5 Tool edge ⇔ data.edgeType === 'tool_availability' (wins) else target handle ends -input-tool. Precedence runs both ways: a declared tool_availability makes a non-tool handle a tool edge, and any other recognised type declaration makes a -input-tool handle NOT one. A declaration only wins if EdgeType::isKnown() accepts it — absent, empty, non-string and unrecognised values all fall through to the handle. Carrying an unknown string through as a type of its own is what makes it dangerous: nothing matches it, so it is neither excluded from execution (CMP-5) nor classified, and a typo like loopbak silently becomes an ordering edge in the compiled graph and in SG-16's forward walk, where a mistyped loopback edge turns into a forward cycle CMP-5 then hard-errors on. TRANSITIONAL (OPEN-17): the fall-through on an unrecognised non-empty string is a transitional pin — the target rejects it at save with a validator error (new R-code), so a typo'd edgeType can never silently reclassify a wire; absent/empty declarations keep the handle fallback classifier edgeType(); EdgeType::isKnown() EdgePortClassifierTest::testExplicitEdgeTypePrecedence, ::testUnrecognisedEdgeTypeFallsThroughToTheHandle
EDGE-6 Error edge ⇔ an ExecutionEdge (the job-metadata record) whose source handle ends -output-error. isErrorEdge() accepts only that shape, so a transport JsApi\Edge is never classified here. Its presence is what makes a failing node return an error payload ({message, code, node_id, retryable}) and the job be marked FAILED + error_routed, with the run continuing down the error edge instead of throwing and failing src/Utility/EdgePortClassifier.php:85-87; consumers AbstractOrchestratorBase.php:748,889, StateGraphOrchestrator.php:1618, JobGenerationService.php:789 EdgePortClassifierTest::testErrorEdgeClassification + error-routing suites
EDGE-7 Tool-only node = ≥1 outgoing edge and every outgoing edge a tool edge; zero outgoing edges, or any non-tool outgoing edge, means not tool-only. A tool-only node is excluded from the compiled execution graph (isExcluded($nodeId) TRUE, reason tool_only) and generates no job — it runs inline only when a consumer invokes it as a tool. The classifier owns the rule and JobGenerationService reads it; the compiler applies the same rule over its own DependencyEdge::isToolAvailability() records and additionally requires a resolvable tool-consumer target EdgePortClassifier::isToolOnlyNode :193-206; JobGenerationService.php:157; WorkflowCompiler::shouldExcludeNode() :517-536 ✅ compiler tests + EdgePortClassifierTest::testToolOnlyNode
EDGE-8 edge.data.condition is a removed feature: schema-tolerated (declared nullable, so a stored workflow carrying it round-trips unchanged under strict config-schema validation), logged, always routes. The warning fires only for a non-empty string condition, and it is emitted where a source node's outgoing edges are resolved — so "once per edge" means once per dispatch pass, and a source that re-executes (a loop) warns again rather than once per launch schema flowdrop_workflow.schema.yml flowdrop_workflow.edge_data:condition; StateGraphOrchestrator::outgoingEdges() StateGraphRemovedEdgeConditionTest::testConditionedEdgeIsStoredWarnedAndFollowed, ::testAbsentOrEmptyConditionIsSilent, ::testConditionDoesNotGateLoopReentry
EDGE-9 Two edge domains, one key bag each. EdgeKeys names only the camelCase transport keys (source, target, sourceHandle, targetHandle) that JsApi\Edge reads and writes; the snake_case job-metadata record (source_handle, target_handle, is_trigger, is_loopback, is_tool, branch_name, edge_id) is owned solely by ExecutionEdge, which is the only reader and writer of those names. EdgeKeys must never carry a job-metadata constant: the four it used to carry (IS_TRIGGER, IS_LOOPBACK, BRANCH_NAME, EDGE_ID) had no reader at all and made the neighbouring EdgeKeys::SOURCE_HANDLE look like the way to write a job-metadata handle — which silently produced a camelCase key nothing reads src/Constants/EdgeKeys.php; src/DTO/JsApi/Edge.php; src/DTO/ExecutionEdge.php ConstantsContractTest::testEdgeKeysValues + ::testEdgeKeysHasNoJobMetadataConstants

GR-SCHEMA — node schema discovery (plugin → NodeMetadata → editor)

How a node's PHP plugin declares its inputs and outputs, and how the system turns that into the ports, forms, and defaults the editor shows. This is where the editor's entire picture of a node comes from — if these rules break, the canvas lies to the author.

The x-port-order values SCH-11–14 inject are the port schema's render weight: transformSchemaToPorts() maps x-port-order onto NodePort::displayOrder, NodePort::toArray() emits it only when it diverges from 0 (SCH-27), and the editor sorts a node's ports ascending on it — so a reserved port sinks below every author-declared port (weight 0). Unlike x-config-order (SCH-31), this value reaches a runtime reader: the @flowdrop/flowdrop editor's portUtils reads port.displayOrder.

ID Rule Impl Pinned
SCH-1 Input surface only via getParameterSchema(), output only via getOutputSchema(); the plugin attribute carries no port/visual data. Was marked structural and therefore excluded from the denominator; it is neither — the attribute's constructor signature is the claim, so a ports, icon or category parameter appearing there is exactly the regression the rule exists to prevent, and reflection reads it src/Attribute/FlowDropNodeProcessor.php:41-47 NodeProcessorSchemaDeclarationTest::testPluginAttributeCarriesNoPortOrVisualData (the declared parameter list, verbatim)
SCH-2 deriveSchemas() reads the gate flags (connectable/configurable/required) from the node-type entity's parameter config only, each defaulting FALSE when the entity declares no entry — so a param the entity never declares appears in neither derived schema and in neither required list. Same-named keys in a plugin's getParameterSchema() are never read by the gate, but they are not stripped either: cleanPropertySchema() is a pass-through, so they survive verbatim into the served input/config schema. ⚠ "No consumer may read them" was too strong for one of the three: a property-level required: TRUE IS read, by ToolParameterScope::normalizeForModel(), which lifts it into the enclosing object's standard required array before a tool-exposed node's schema becomes a model-facing input_schema — so writing it changes what an LLM is told is mandatory, and nothing else. required: FALSE said nothing on any path and has been deleted from the 23 declarations that carried it. connectable/configurable are the mirror case: 20-odd properties write them flat and the node-type form read them from a NESTED flowdrop key no plugin has ever written — a reader with no writer beside a writer with no reader. The form now reads the flat spelling that exists NodeMetadataResolver.php:661-663; cleanPropertySchema() :734-736 NodeMetadataResolverDeriveSchemasTest
SCH-3 deriveSchemas() splits one param schema in two: param ∈ inputSchema iff connectable, ∈ configSchema iff configurable; both/one/neither legal NodeMetadataResolver.php:589-700 ✅ (same suite)
SCH-4 format: hidden / hidden: TRUE params are dropped from both derived schemas and from configDefaults, overriding entity connectable/configurable. The hidden check runs AFTER the reserved config-only branch (SCH-5), so a hidden dynamicInputs/dynamicOutputs/branches is still surfaced into configSchema when the node type opts in — the hidden marker does not suppress a reserved param. One READER, two spellings: the five copies of $format === 'hidden' || ($schema['hidden'] ?? FALSE) === TRUE scattered across the resolver, the generator and the node-type form are now one call to PortExposure::isHiddenParameter(). Both spellings are still honoured. format: hidden is the one FlowDrop ships and the one to write; the bare hidden: TRUE is DEPRECATED but not dropped, because no in-repo plugin writes it while a third-party one might, and dropping it would silently UN-hide that parameter — not cosmetic, since a visible parameter becomes connectable and configurable. Retiring it needs the release-of-warning courtesy x-display-order got (SCH-31) PortExposure::isHiddenParameter(); :650-654, reached only past the reserved branch at :635-645 NodeMetadataResolverDeriveSchemasTest
SCH-5 Reserved config-only params (dynamicInputs,dynamicOutputs,branches) are never input ports; on per-node-type configurable: TRUE opt-in they enter configSchema and always get a configDefaults entry — entity default, else schema default, else []. Never omitted and never NULL, unlike an ordinary configurable param, whose entry is omitted when its resolved default is NULL :635-645 vs the ordinary path at :704-706 ✅ (5 cases in suite)
SCH-6 required emitted into inputSchema.required iff required && !configurable; into configSchema.required iff required && !connectable :666-678 NodeMetadataResolverDeriveSchemasTest
SCH-7 Output survives into metadata iff entity outputs[name]['exposed'] ?? TRUE (fail-open) :726-753 ports-output-stripping.yml
SCH-8 transformSchemaToPorts() is the only schema→NodePort mapping for schema-derived ports: id = the property key, name = title ?? key, dataType = the mapped normalized type, required = membership in the schema's required list, defaultValue = default ?? NULL, exposedByDefault = PortExposure::isExposedByDefault(), displayOrder = (int) x-port-order ?? 0. The two unified I/O ports (SCH-17/18) bypass it — they are constructed directly, always dataType: json and displayOrder: 0 :795-824 vs the direct construction at :371-379/:390-398 NodeMetadataResolverTransformPortsTest + UnifiedIoPortInjectionTest
SCH-9 Union types normalize to first non-null member; all-null/empty → NULL, which the caller reads as the sink per SCH-35 (it was string, one of that rule's seven disagreeing answers). Every member must itself be a JSON Schema type — SCH-34 extended per member normalizeSchemaType() (now ?string) ✅ (same suite) + SchemaTypeVocabularySweepTest for the array form
SCH-10 Amended by SCH-34/35/36/37 and by SCH-41 (the gate is the SERVED PAYLOAD, not the enum: x-data-type is honoured whenever the payload declares the lane, which is how a site's or a shape's lane becomes declarable) — read those first. Schema→dataType map is closed over JSON Schema's seven words: string→string, number\|integer→number, boolean→boolean, array→array, object→json, null→sink; any other or absent type yields the SINK, never string. The tool→tool, mixed→mixed and trigger→trigger arms are gone — those are lanes, not schema types, and accepting them here is what let a lane name ride in type (SCH-37). A property's x-data-type overrides the mapped type whenever it names a lane FlowDrop ships (it was a one-entry DATA_TYPE_OVERRIDES allowlist holding only messages, which made messages the sole expressible semantic type); any other value is ignored and the schema type maps as usual. Every value the map can return, override included, must also be declared by NodesController::getPortConfiguration() — a coupling now held at both ends by PortDataType (SCH-36) and still test-enforced, with no runtime assertion; an undeclared type would leave the port compatible with nothing in the editor. The override is declared by message_assemble, conversation_normalize (both ends) and conversation_buffer's messages output — see MEM-5 :868-882; override allowlist DATA_TYPE_OVERRIDES just above it ✅ same suite (incl. ::testConversationBufferMessagesOutputIsMessagesTyped, pinned against a real producer's schema) + NodesControllerPortConfigTest
SCH-10.a Amended by SCH-41/44: the overlay is the LAST of three sources (shipped enum → port shapes → overlay), and SCH-41 is what finally made this row's promise true — until then a site could add a lane here that no port could ever declare. A stored flowdrop_node_type.port_config config value is an overlay on the shipped defaults, not a replacement. Served wholesale (as it was until this release) it is a snapshot: a site that ever saved one keeps serving the type list it saved, and every type added afterwards is absent — which by SCH-10's coupling leaves every port carrying it compatible with nothing, not even itself. Stored keys still win outright (a recoloured or renamed type, a site-only type, the site's own rules); shipped entries the stored value never mentions fill the gaps — dataTypes merged per id with unmentioned shipped ids appended after the stored ones (so the site's ordering survives), compatibilityRules unioned on the from/to pair, scalar keys stored-wins. A site therefore cannot suppress a shipped rule by omission (there is no negative form to store) — the safe direction, since a missing rule silently refuses valid edges while an extra one only permits NodesController::mergeUnderDefaults() NodesControllerPortConfigTest::testStoredConfigOverlaysTheShippedDefaults
SCH-11 Reserved trigger input injected on every node except Start processors (unless already declared), carrying x-port-order: 100 :276-289:819 TriggerInputInjectionTest
SCH-12 Reserved trigger output injected except Terminal processors, x-port-order: 100 :305-318:819 TriggerOutputInjectionTest
SCH-13 Reserved tool output injected iff isToolExposed(), x-port-order: 110 :323-336:819 ToolOutputInjectionTest
SCH-14 Reserved error output injected except NonExecutable processors, x-port-order: 120, ships hidden :344-357:819 ErrorPortInjectionTest
SCH-15 Injected reserved ports emit x-exposed-by-default: FALSE only when divergent from TRUE 5 sites ✅ (all 5 injection suites)
SCH-16 Reserved-port exposure defaults table: input/output/tool/error/loop_back_input=F, trigger in/out=T; node type overrides per key, storing divergent keys only FlowDropNodeType.php:235-242 FlowDropNodeTypeReservedPortExposureTest
SCH-17 Unified I/O input port prepended iff isUnifiedInputExposed(), dataType json, description enumerates connectable keys only :342-357 UnifiedIoPortInjectionTest
SCH-18 Unified I/O output port prepended iff isUnifiedOutputExposed(), description enumerates exposed output keys only :361-376 ✅ (same suite)
SCH-19 Both key enumerations skip trigger and hidden params (format: hidden / hidden: TRUE) — a hidden port does not exist on the instance, so it never appears in a unified port description :872-940 NodeMetadataResolverKeyEnumerationTest
SCH-20 Visual type comes from the config entity only. When getSupportedVisualTypes() holds >1 entry a reserved nodeType string property (enum = the supported types, default = the entity's visual type, x-group: general) is written into configSchema, and its config default is stored under the same nodeType key. Unlike SCH-21's reserved props this write is unconditional — it overwrites a plugin-declared nodeType property. The enum is declarative only: nothing validates a stored data.config.nodeType against it at run (the entity accessor has no runtime caller besides this line and the node-type form) :413-423 NodeMetadataBuildTest
SCH-21 Reserved config props: instanceTitle/instanceDescription on every node; maxRetries + ports on executable nodes only; skipped if already declared :487-546 NodeMetadataResolverReservedConfigPropsTest
SCH-22 configEdit populated only for ConfigEditProviderInterface implementors :417-420 NodeMetadataBuildTest
SCH-23 uiSchema is generated from the final configSchema (after reserved-prop injection) and is NULL exactly when no property carries a registered x-group — an unknown group falls back to the default bucket, so a schema tagged only with unknown groups is NULL too and the form stays on its flat path. The baseline ships every group collapsed; enrichNode() opens a group per instance when that group's fields are set in data.config :449; UiSchemaGenerator.php:106-128; instance open-state :134-141 ✅ (same suite) + UiSchemaGeneratorTest
SCH-24 buildForNodeType() catches \Exception only: it logs Failed to build node data for @id: @error on the flowdrop channel at error severity and returns NULL, so enrichNode() returns the stored node untouched (no data.metadata key written). A \Throwable that is not an \Exception (e.g. a TypeError/Error from a broken plugin) is not caught and fails the request :472-478; unchanged-node path :120-122 ✅ (same suite)
SCH-25 Enrichment anchors on node_type_id (via NodeAnchor::read()) only: it sets type to the constant universalNode, replaces data.metadata, and normalizes data to an array before the write; data.config/id/label/position are untouched and re-running is a no-op. When the anchor is empty or the node type no longer builds, the node is returned unchanged — no data.metadata key is added. The data normalization is defensive only and unreachable in practice: the anchor lives inside data, so a node whose data is not an array returns at the empty-anchor guard before it :111-146 (early returns :117/:122, normalization :128) NodeMetadataResolverEnrichAnchorTest
SCH-26 Editor catalog serves enabled node types only, sorted category→name NodesController.php:99-130 NodesControllerCatalogTest
SCH-27 Wire shape is lean-by-divergence: defaultValue emitted only non-NULL, exposedByDefault only FALSE, displayOrder only ≠0 — editor must read absence as exposed/0 NodePort.php:172-197 NodePortTest
SCH-28 Executor plugin id resolves from the node-type entity, never trusted from stored node metadata NodeMetadataResolver.php:188-200 NodeMetadataResolverEnrichAnchorTest + WorkflowCompilerRunEnrichmentTest
SCH-29 Run path re-enriches from the live node type then normalizes via WorkflowDTO::fromArray — stored metadata never authoritative at run WorkflowCompiler.php:92-104 WorkflowCompilerRunEnrichmentTest
SCH-31 x-config-order orders config fields ascending (absent or non-numeric = 0, ties keep declaration order) inside the generated uiSchema only — if no property carries a registered x-group, generate() returns NULL, there is no layout, and x-config-order has no effect on the flat form. ⚠ The pre-2.x spelling x-display-order is RETIRED. It was read for one release behind a deprecation warning that named the offending property and said support would go in the next release; this is that removal. A schema still carrying only the old key orders at 0, like any untagged property, and UiSchemaGenerator no longer takes a logger UiSchemaGenerator.php, configOrder() UiSchemaGeneratorTest::testLegacyDisplayOrderKeyIsNoLongerRead
SCH-32 Reserved loop_back input injected on every re-enterable node type — all but Start, Terminal and NonExecutable processors — x-port-order: 95, x-data-type: trigger (was type: any; see SCH-38 for why they were the same thing), ships hidden (so no canvas gains a re-entry handle until an author exposes the port per instance). A plugin declaring its own loop_back keeps it, injection skipped: ForEach's declaration carries live semantics (SG-5), and the node-type form applies the same precedence — the reserved row is appended only when the plugin declares nothing, so those types keep storing through the parameters table rather than reserved_port_exposure. The loop machinery itself was already node-agnostic (EDGE-4, CMP-5, DATA-5, and the branch-driven arm of shouldFollowLoopbackEdge); only the declaration was per-type. Consequence: the port moves from metadata-undeclared (out of R7 scope, R7.d) to declared-and-hidden, so a hand-authored edge into an unexposed loop_back that R7 previously ignored is now rejected as R7_EDGE_TARGET_NOT_EXPOSED — unreachable from the editor (no handle existed to drag from), so API/hand-built workflows only. Stored workflows carrying such an edge are brought forward by flowdrop_workflow_post_update_legalize_loopback_edges, which writes the R7.c instance override rather than letting them fail at their next save or launch (EXPO-19) NodeMetadataResolver.php:305-345; form row NodeTypeFormSectionsTrait.php:300-317; submit precedence FlowDropNodeTypeForm.php:936-947, :1052-1054 LoopBackInputInjectionTest (6 cases)
SCH-33 A schema that will not load — or loads without a usable properties map — never reclassifies stored parameters. The node-type form's submit reclassifies every parameters-table row against the plugin's LIVE schema — plugin parameter vs reserved port (the loop_back precedence of SCH-32), and the type each default is cast to. The loader answered a createInstance()/getParameterSchema() failure with ["properties" => []], which is indistinguishable from "this plugin declares nothing": $loopBackIsReserved flipped TRUE, the plugin-declared loop_back row of foreach/reason/memory_read was skipped as reserved, and the save wrote a parameters map missing it — permanent config loss from a momentary failure, surfacing much later as an R7.a rejection on the loopback edge. Now loadPluginParameterSchema() returns NULL on failure — a throw, AND a degraded schema whose properties key is missing or not an array, since a parameterless plugin declares properties: [] (the abstract base does) and an absent map loses stored rows exactly like a throw — and the two seams both close: validateForm() REFUSES the save when the entity has stored parameters to lose (with nothing stored there is nothing to lose, and blocking would strand the rest of the form behind a broken plugin), and submitForm() — reachable without validation, e.g. a programmatic submit — writes the STORED map back verbatim rather than the accidentally-empty computed one, and treats the loopback row as not rendered so reserved_port_exposure is preserved too. getPluginParameterSchema() keeps its empty-schema fallback for the render path only, where "no properties" correctly means "render no rows" NodeTypePluginHelperTrait.php (loadPluginParameterSchema() / getPluginParameterSchema()); FlowDropNodeTypeForm::validateForm() (refusal), ::submitForm() ($schemaLoaded guard, storedParameters()) NodeTypeFormParameterPreservationTest (6: declared-loop_back round-trip, reserved-row exposure round-trip, unreadable schema refuses the save and preserves the map, degraded no-properties schema likewise, a submitted plugin switch is ignored on the edit form — the hidden #value clamps classification to the STORED plugin's schema, submit handler preserves the map with validation bypassed)
SCH-34 A property's type is a JSON Schema type: one of the seven words, or an array whose every member is one. It is NOT the port lane. The two vocabularies overlap on string/number/boolean/array, which is why a lane name written into type looked correct and shipped seven wrong ports (type: json on MessageFromText's message and Reason's assistant_message; type: any on the four loop_back inputs; type: float on HttpRequest's request_time) — the schema→lane map answered the miss with string, and nothing complained. Enforced statically by the SchemaTypeName alias on PropertySchema, on both schema sides: getParameterSchema() returns ProcessorSchema, getOutputSchema() returns ProcessorOutputSchema (the same shape with properties optional, for a node that declares no outputs). PHPStan is level 10 with an empty baseline, so an offender fails analysis — fix it, never baseline it. A schema built at run time is out of the analyser's reach and is swept instead FlowDropNodeProcessorInterface.php (ProcessorSchema, ProcessorOutputSchema); NodeMetadataResolver.php (SchemaTypeName, PropertySchema) SchemaTypeVocabularySweepTest::testEveryDeclaredTypeIsOneOfTheSevenWords (walks every instantiable processor, covering the runtime-built and array-form declarations the analyser cannot see) + PHPStan level 10
SCH-35 Lane derivation is total and never falls back to string. Every one of the seven JSON Schema words has an arm; null, and an ABSENT type, resolve to the sink (PortDataType::SINK = mixed). This replaces SCH-10's ?? "string", which was one of seven disagreeing answers to "what does an undeclared port mean?" — the resolver said string, the config form's formatTypeDisplay() showed the builder mixed, the served defaultDataType said string, WorkflowInterfaceProjection said string, fdnpm's utils/config.ts said mixed, SCH-9's union normalize said "first non-null member", and the node-type form's default cast said string. All of them now say the sink. Boundary effect (SCH-40): the projection's change WIDENS the launch and session-turn endpoints for undeclared ports — a caller may now post an array or NULL where is_scalar() was silently demanded. Widening breaks no existing caller, and the alternative was a constraint nobody declared NodeMetadataResolver::mapSchemaTypeToDataType() / ::normalizeSchemaType() (now ?string); NodesController defaultDataType; WorkflowInterfaceProjection; NodeTypeFormSectionsTrait::formatTypeDisplay(); FlowDropNodeTypeForm::submitForm() NodesControllerPortConfigTest::testEveryMappedDataTypeIsServed (asserts the absent case is the sink) + ::testTheSinkIsMixedAndAnyIsDeprecatedNotDropped
SCH-36 Amended by SCH-41: PortDataType is one declaration site for the SHIPPED set, and the served payload — enum ∪ shapes ∪ overlay — is the vocabulary. This row's closing sentence ("the enum is the SHIPPED set, not a ceiling") is now enforced rather than merely stated. The lane vocabulary has ONE declaration site, PortDataType. It had five and they disagreed: the derivation's range, the served payload (25), fdnpm's defaultPortConfig.ts (22 — missing any, mixed, messages), DynamicPortTrait::DYNAMIC_PORT_DATA_TYPES (9), and four bare TypeScript literals. The served payload and the dynamic-port picker now derive from the enum; the npm defaults and the branch-port literals remain hand-mirrored and are the workflow-contract axis's to close. The enum is the SHIPPED set, not a ceiling — SCH-10.a lets a site add lanes through the stored overlay, so never reject a stored lane because tryFrom() returned NULL src/Enum/PortDataType.php; DynamicPortTrait::dynamicPortDataTypes(); NodesController::getPortConfiguration() NodesControllerPortConfigTest::testServedLanesAreTheShippedEnumPlusRetiredSpellings + DynamicPortTraitTest
SCH-37 A control port declares its LANE, never a JSON Schema type. trigger, tool and loop_back carry no value, so no type describes them; they say x-data-type instead, and the reserved-port injections assign the lane by role rather than deriving it. The control lanes remain full members of the vocabulary (PortDataType::carriesValue() === FALSE) — what is forbidden is spelling them in type. carriesValue() is also what keeps them out of the dynamic-port picker and off the author-pickable type list (enabled: FALSE in the payload) NodeMetadataResolver.php (reserved-port injections); PortDataType::carriesValue() SchemaTypeVocabularySweepTest (a lane in type names itself in the failure) + NodesControllerPortConfigTest::testControlDataTypesAreServedButNotOffered
SCH-38 A loop_back input is a control sink, and any is retired. any and trigger differed in exactly one way — the payload built a rule into any from every other lane and built none into trigger — and that is a property of BEING the sink, not of being any. The rules now target trigger, loop_back declares x-data-type: trigger, and any is gone from the enum. It is NOT gone from the payload: the editor builds its compatibility map from the served list alone, so omitting a lane makes every edge on a port that still declares it incompatible — silently, and on the one lane whose job was to accept anything. Nothing FlowDrop ships wears it, but a third-party plugin, a port_config overlay (SCH-10.a) or an fdnpm consumer may, so it is served enabled: FALSE with both rule directions (an alias would not do — SCH-39) and a description that says deprecated. Removed in 3.0.0, by emptying NodesControllerPortConfigTest::RETIRED_LANES and deleting the two rulesTargeting/rulesFrom calls. This WIDENS ordinary trigger inputs: a data output may be drawn into one. Deliberate — "run B once A has produced X" is a reasonable thing to draw, and the runtime discards the value on a trigger input rather than delivering it. Scope of the widening: compatibility rules are an AUTHORING AFFORDANCE only. No server path validates a connection against port types — WorkflowValidator, the compiler and the pipeline never read dataType (R7 gates edges on EXPOSURE, not type), and the runtime routes on handle names via EdgePortClassifier. So this changes what the editor lets an author drag and nothing else; a wire it now permits was already accepted by every server path if hand-authored. The three loop guardrails are untouched, because none of them was ever the type: the port ships hidden (SCH-32), the cycle exemption keys off the port NAME (CMP-5), and the dotted styling comes from the editor's own isLoopbackEdge() handle match. The is_loopback / loopback_metadata keys those ports carried alongside had no reader in either repo and are deleted — the identically-named EDGE key in EdgeKeys is a different thing and is alive NodesController::rulesTargeting() / ::rulesFrom(); NodeMetadataResolver.php (loopback injection); PortDataType::CONTROL_SINK NodesControllerPortConfigTest::testEveryTypeButToolIsCompatibleWithTheControlSink + LoopBackInputInjectionTest
SCH-38.a A retired lane keeps one release of served-but-disabled compatibility. A lane leaves the enum the moment nothing ships it, but leaves the PAYLOAD only a major later, because the two lists answer different questions: the enum says what a port may newly declare, the payload says what the editor knows how to connect. Dropping both at once breaks every edge on a port declared under the previous release, with no error anywhere — the editor simply reports the connection incompatible. any is the standing case (SCH-38); the list of such lanes is NodesControllerPortConfigTest::RETIRED_LANES, spelled out so that removing one is a deliberate edit and not drift NodesController::DATA_TYPE_ANY; NodesControllerPortConfigTest::RETIRED_LANES NodesControllerPortConfigTest::testTheSinkIsMixedAndAnyIsDeprecatedNotDropped + ::testTheDeprecatedSinkSpellingIsWireableBothWays
SCH-39 Compatibility is asymmetric, and the data sink needs rules in BOTH directions. The editor's checker maps an OUTPUT lane to the set of input lanes it may enter, seeded exact-match, widened only by explicit rules. mixed had rules in NEITHER direction, so a mixed port refused a string wire — every port in the nineteen files that declared it, and every dynamic port left at its default lane (DYN-2). It is worn by outputs as well as inputs (DataExtractor's result, ForEach's item_result, StateStorage's value), so one direction is not enough, and an ALIAS of the control sink is not enough either: the checker's aliases copy the outgoing set only. tool is excluded from every sink rule in both directions. A rule that WIDENS carries its reason with it — SCH-38's trigger widening and messages → array\|json both state theirs at the declaration. The stakes are authoring-time, not runtime: nothing server-side checks an edge against port types (see SCH-38), so a wrong rule costs an author a wire they cannot draw, or lets them draw one nothing will reject NodesController::getPortConfiguration(), ::rulesTargeting(), ::rulesFrom(); consumed by connections.ts buildCompatibilityMap() NodesControllerPortConfigTest::testEveryLaneIsCompatibleWithTheSinkBothWays + ::testMessagesIsOneWayCompatibleWithArrayAndJson
SCH-40 A port's declared type is consumed at a PUBLIC BOUNDARY, and any change to it states its effect there. The path is plugin property typeWorkflowInterfaceProjection (raw, into a field named dataType) → SchemaFragmentChecker::kind()::matchesType()WorkflowInputCheckerWorkflowLauncher and SessionTurnService — the shared answer to "what may a caller supply?" for the launch endpoint and the session-turn endpoint alike. kind() accepts BOTH vocabularies on purpose, and that is why nothing failed when the seven ports shipped mistyped: it swallowed the difference, so the only symptom was a handle the wrong colour. Its permissive arm therefore covers mixed, any, trigger and tool — the loopback ports that said any now say trigger, and without the arm they would fall to the scalar-only default and go from unconstrained to scalar-only. Tightening this — keying off the seven words alone — is strictly stronger and reasonable, and is a semver-relevant change to a public endpoint that must be announced, never carried in on a refactor. The projection's field name is likewise a client-facing contract and its rename rides the workflow-contract axis SchemaFragmentChecker::kind(); WorkflowInterfaceProjection; WorkflowInputChecker SchemaFragmentCheckerTest::testControlLanesWaiveTheTypeCheck, ::testAnAbsentTypeIsNotChecked + WorkflowLauncherTest::testMixedPortAcceptsStructuredInputValue

| SCH-41 | The lane vocabulary is the SERVED PAYLOAD, not the enum. It composes PortDataType (shipped) ∪ the port-shape registry (code plugins, then site config entities) ∪ the stored port_config overlay, in that precedence order, and PortConfiguration::hasLane() is the one question a caller asks. The resolver's x-data-type gate consulted PortDataType::tryFrom() instead, which is why SCH-10.a's promise was empty in practice: a site could add a lane to the payload, colour it and write rules for it, and no port could ever declare it — the declaration was silently replaced by the schema-derived lane and the only symptom was a handle the wrong colour. Reconciles SCH-10's gate with SCH-36's anti-rejection principle: the enum says what FlowDrop SHIPS, the payload says what is DECLARABLE. A lane the payload does not declare is still refused, for SCH-10's original reason — it would be compatible with nothing in the editor, not even with another port of its own lane | PortConfiguration::build() / ::hasLane() (moved out of NodesController, which is now a thin cacheable wrapper); NodeMetadataResolver::isServedLane() | ✅ NodeMetadataResolverTransformPortsTest::testSiteDeclaredLaneIsHonoured / ::testUnservedDataTypeOverrideIsIgnored + PortShapeRegistryTest + NodesControllerPortConfigTest::testEveryMappedDataTypeIsServed | | SCH-42 | A shape is a named JSON Schema, and the shape id IS the lane id. One id space, no second vocabulary axis. A shape is declared in one of two places, and the difference is WHOSE it is: a plugin (#[PortShape], discovered from Plugin/PortShape) for a shape that belongs to code — it ships and updates with the ports that wear it, needs no config installed, and may COMPUTE its schema, which is what derivers exploit (one shape per order type, per entity type, per remote contract; a derived lane id is base:derivative); a flowdrop_port_shape config entity for a shape that belongs to the site — added in the UI by a site builder with no PHP, exported with the site's config, reviewable in a diff. Every shipped non-primitive lane has a shape: error (a NEW lane, worn by the reserved error output, which declares x-data-type: 'error' alongside type: 'object') and messages (an existing lane that gains a schema). Enforcement is NOT part of this: nothing validates a value against a shape, and a shape names a value rather than checking it | Attribute\PortShape; Plugin\PortShape\PortShapePluginBase; Entity\PortShape; PortShapeDefinition; PortShapeRegistry; Plugin/PortShape/ErrorShape.php, MessagesShape.php | ✅ PortShapeRegistryTest::testTheShippedShapesAreDeclaredInCode / ::testPluginDeclaredShapeJoinsTheVocabulary / ::testDeriverMintsOneShapePerEntity / ::testShapeMayDeclareNoSchema + ErrorPortInjectionTest::testErrorPortWearsTheErrorLane | | SCH-43 | Shape compatibility is NOMINAL and mostly derived. order accepts order because both spell order, never because two schemas were compared — structural typing would make edge legality depend on schema evolution, so two unrelated shapes that coincide today would silently interconnect and then silently disconnect when one gained a field. Every shape self-matches (seeded by the editor's checker, not emitted as a rule) and derives a ONE-WAY widening into json and the sink, for SCH-39's reason: consumers already typed json must keep accepting a shaped value without being rewired, and the reverse is refused because a bare object carries no guarantee of the shape. Anything further is a hand-written rule that states its reason at the declaration — the form refuses a widening with no reason, since the served payload's from/to pair cannot carry one. Shapes join the lane list BEFORE the payload's sink/control rules are generated, so a new lane gets those by construction rather than by a second derivation | PortShapeRegistry::derivedRules(); PortConfiguration::composeShapeLanes() / ::unionRules(); PortShapeForm::validateForm() | ✅ PortShapeRegistryTest::testNewShapeJoinsTheServedVocabulary / ::testDeclaredWideningIsServedWithItsReasonKeptInConfig / ::testShapeRefinesShippedLaneRatherThanReplacingIt | | SCH-44 | A shape REFINES another declaration of its lane; four ids are reserved. Where two sources declare one lane id the site's config entity wins over the code plugin, and both win over the shipped payload entry — whole-entry, not key-wise, because a partial shape is a deliberate redefinition (the same choice SCH-10.a makes for the stored overlay). A shipped-id collision is therefore not a break: the lane stays in the vocabulary, stays self-compatible and keeps its derived rules, which is the failure the reserved namespace was drafted to prevent. Precedence is applied in the registry rather than by routing config through a deriver, because plugin discovery has no merge step and "who wins" would otherwise depend on module weight. What IS reserved is narrower and load-bearing: trigger, tool, mixed and any — the ids every generated rule in the payload is built from — and a shape naming one is IGNORED by the registry (not fatal: config import and drush cset reach around the form, and one bad shape must not unwire every canvas) as well as refused by the form | PortShapeInterface::RESERVED_IDS; PortShapeRegistry::all(); PortShapeForm::validateForm() | ✅ PortShapeRegistryTest::testReservedLaneIdIsIgnored / ::testConfigShapeOverridesPluginShapeOfTheSameId | | SCH-45 | flowdrop_node_type.port_config is typed config. The site's lane overlay had no schema anywhere in the repo: no validation, no translation of the labels a site writes, and a $configSchemaCheckerExclusions entry in every kernel test that touched it. It is now a config_object with the full lane and rule shape, and the exclusion is gone — so a malformed overlay fails at save rather than at render, and the strict schema checker covers the one config a site is most likely to hand-write. The flowdrop_node_type.flowdrop_port_shape.* entity is typed alongside it, with the JSON Schema itself as type: ignore: a JSON Schema's keyword space is not ours to enumerate, and a mapping would refuse every keyword we had not thought of | config/schema/flowdrop_node_type.schema.yml | ✅ every kernel test that writes an overlay, now under the strict checker (NodesControllerPortConfigTest::testStoredConfigOverlaysTheShippedDefaults) | | SCH-46 | A shape's schema is served on the LANE entry, not on each port that declares the lane. The registry held every shape's JSON Schema and laneDefinitions() dropped it, so the field list reached no client and fdnpm's NodePort.schema — declared "for template variable autocomplete" — had no producer in either repo. It is now emitted on the lane entry, absent rather than empty when a shape declares none (a client must be able to tell "promises nothing" from "promises an object with no properties"). Three reasons it rides the lane: ONE COPY, where a per-port stamp repeats on every node type wearing the lane and error is on every node there is; INVALIDATION THAT IS ALREADY CORRECT, since this payload carries the registry's cache tag (config:flowdrop_port_shape_list, via PortConfiguration::cacheTags()) while the node payload carries only config:flowdrop_node_type_list — a schema baked into ports would go stale when a site edited a shape; and THE PER-PORT SLOT STAYS FREE, which is the one that decided it — NodePort.schema is the only per-port refinement point, and the narrower schema that wants it is one a RUN observed (a lane promising object turns out to carry {title, description} for this node in this workflow). That is per-instance information and could never live in a payload cached per node TYPE. Still authoring information only: nothing validates a value against a served schema (SCH-42), and an observed schema must never become what enforcement checks — it is a sample, not a contract. By SCH-10.a's whole-entry merge a stored overlay entry for the same lane id replaces the composed one, schema included, exactly as it already does for name/color/category | PortShapeRegistry::laneDefinitions(); PortConfiguration::composeShapeLanes() | ✅ PortShapeRegistryTest::testTheLaneEntryCarriesTheDeclaredSchema / ::testShapeMayDeclareNoSchema |

SCHEMA-30 retired: "missing-properties must fail closed" was drafted against a second, drifted copy of the metadata pipeline that once lived on FlowDropNodeTypeManager and failed open where its neighbours failed closed. That fork is deleted: the manager now enumerates node types and passes NodeMetadataResolver output through, so there is one derivation and it fails closed (?? []) everywhere. Nothing left to pin.

GR-CFG — config/parameter resolution (cross-ref: docs/development/parameter-resolution-truth-table.md D1–D4; fixtures ports-*)

When a node runs, where does each parameter value actually come from? Priority order: value delivered on a wire → the author's saved config → the schema default — with exact rules for NULL, required, hidden, and internal parameters.

ID Rule Impl Pinned
CFG-1 (D1) Gate flags fail closed (?? FALSE) ParameterResolver.php:128-130 ports-absent-flag-polarity.yml
CFG-2 (D2) exposedByDefault fails open (?? TRUE) — deliberate opposite polarity :138 ✅ same fixture (pins the seam)
CFG-3 (D4) Effective default = E ?? P ?? NULL; entity null indistinguishable from absent :142-144 ports-entity-default-overrides-schema.yml
CFG-4 Priority 1 RUNTIME: iff (internal OR (connectable AND exposed)) AND array_key_exists :269-276 ✅ fixtures + ParameterResolverExposureTest
CFG-5 Priority 2 CONFIG: iff !internal AND configurable AND array_key_exists :280-283 ports-connectable-not-configurable.yml
CFG-6 Priority 3 DEFAULT: unconditional fallback :286-287 ports-precedence-full-chain.yml
CFG-7 Presence = array_key_exists at every priority: an explicit NULL at a higher priority wins — a wire-delivered NULL beats the author's config, a config NULL beats the schema default. All three input builders agree: AbstractOrchestratorBase::buildRuntimeInputData (:774), SynchronousOrchestrator::resolveNodeInputs (:708), and the StateGraph builder (StateGraphOrchestrator.php:1859) all gate wire-value presence with array_key_exists, so an explicitly emitted NULL survives on every engine :269,280; builders as cited ports-explicit-null-wins.yml pins the resolver + the sync builder (the conformance harness runs fixtures only under SYNCHRONOUS_PIPELINE, ConformanceTestBase.php:80-81); the unified rule is pinned as unit tests — MultiSourcePortResolutionTest::testWireNullIsDeliveredAsPresentKey and StateGraphMultiSourcePortResolutionTest::testWireNullIsDeliveredAsPresentKey (both: key PRESENT, value NULL)
CFG-8 Internal __ params: always accept runtime, never config, else default. "Bypass all gates" means the connectable/exposed/configurable gates only — it does not exempt them from declaration. The resolution loop iterates the plugin's declared properties (CFG-13), so an internal name absent from getParameterSchema() is never in the bag no matter who supplies it, and the processor's getString('__x__', $default) silently returns the default forever. This is a live foot-gun, not a hypothetical: WorkflowNode declared __interrupt_id__ for exactly this reason in its first commit, then __execution_mode__ was added three months later as a read only — so JobAsync was unreachable, the queue-dispatch path the README documented could not run, and an author asking for async silently got synchronous execution reporting success. Unit tests could not see it (they hand process() a ParameterBag the resolver would never build); only driving the real resolver catches it. Choosing the __ prefix is choosing a semantics, not a naming style: that setting needed an author-configurable surface too, which the prefix forbids outright, so it shipped as the reserved non-internal ReservedName::EXECUTION_MODE (executionMode) instead — connectable + configurable, taking the ordinary CFG-4/5/6 chain. process() refuses job_async outright when flowdrop_interrupt is absent rather than falling back to synchronous — async decides that the parent does not wait, so running the child inline is a different execution than the one requested, reported as a success. The published enum is the modes the node can support, not the enum type's cases (WorkflowNode::SUPPORTED_MODES = job, job_async). WorkflowExecutionMode is shared with WorkflowExecutor, which implements job_fire_forget; WorkflowNode structurally cannot, and the reason is a rule about this table's own gate model rather than an unwritten branch. job and job_async are output-equivalent — both fill the sub-workflow's declared output ports, async just later on resume — so choosing between them changes scheduling and is safe to decide per execution, which is what connectable buys. A detached run has no outcome to deliver, so it changes the node's shape: its output ports must not exist. FlowDrop can express that (an unexposed output is unwireable under R7 and non-delivering via filterUnexposedOutputs) but exposure resolves from config, while connectable/configurable are per-PROPERTY gates (CFG-1) — there is no way to make one value of an enum config-only while its siblings stay wireable. A detached run therefore cannot be a value of this property at all; it needs its own config-only setting or its own derivative. process() refuses an unsupported mode, and refuses job_async outright when flowdrop_interrupt is absent rather than falling back to synchronous — async decides that the parent does not wait, so running the child inline is a different execution than the one requested, reported as a success :261-283; declaration requirement :116,124; supported set WorkflowNode::SUPPORTED_MODES ports-internal-param.yml + WorkflowNodeExecutionModeForwardingTest (declared forwards / undeclared stripped / unknown rejected) + WorkflowNodeProcessTest::testUnsupportedExecutionModeThrowsRuntimeException, ::testAsyncModeWithoutTheInterruptModuleThrows, ::testExecutionModeIsDeclaredSoTheResolverForwardsIt (the enum is exactly job/job_async)
CFG-9 required && resolved === NULLMissingParameterException, after cascade, before validation :170-176 ports-required-null-default-throws.yml
CFG-10 A non-NULL resolved value is validated against the plugin schema; any violation throws ParameterValidationException carrying the failing constraint name. NULL skips validation entirely. type and enum are checked first (either one failing returns immediately); the remaining constraints are then selected by the value's PHP runtime type, not the declared typeminimum/maximum/exclusiveMinimum/exclusiveMaximum/multipleOf run for any is_numeric() value (numeric strings included), minLength/maxLength/pattern/format for is_string(), minItems/maxItems/uniqueItems for is_array(). Constraint keys are read with isset(), so an explicitly NULL constraint is treated as absent :179-181, :435-464 (type+enum), :460, :512, :556 ports-validation-strict-throws.yml (wire path — launch R6 refuses invalid config before the resolver runs) + ParameterResolverValidationTest (all twelve constraints + NULL skip + runtime-type selection: string schema, numeric string, maximum for the runtime-type rule)
CFG-11 Type check closed-match; unknown type names pass; no coercion :658-670 ports-validation-no-coercion.yml (wire path, same R6 caveat) + same unit test (full type matrix)
CFG-12 format validation covers 9 formats; unknown formats pass :597-628 ports-validation-format-throws.yml (config path — R6 does not check format; the resolver is the only guard) + same unit test (all 9 formats + unknown passes)
CFG-13 Resolved bag = exactly the plugin schema's property keys (+ dynamic forwards); unknown config keys never reach the processor :113-221 ports-resolved-bag-shape.yml
CFG-14 Every schema property is present in the bag, NULL when unresolved :183 ✅ same fixture
CFG-15 After the schema loop, each dynamicInputs[].name present in the merged runtime inputs is copied into the bag verbatim — no exposure gate, no required check, no validateValue() call, so the entry's declared type/dataType is DECLARATIVE only (cf. DYN-2). A name that is already a key in the bag (any schema property, including one resolved to NULL) is skipped, so the statically resolved value wins; empty names are skipped. Definitions are read from the resolved dynamicInputs value, falling back to raw config :198-221; verbatim forward at :215, collision guard !array_key_exists($dynName, $resolved) at :213 ports-dynamic-passthrough.yml — the colliding name is now fed through the reserved input port (a declared dynamic name bypasses the connectable filter at :385-387), so the collision guard is actually exercised, plus a type: integer entry fed a string that lands unvalidated
CFG-16 The reserved input port is decomposed before the schema loop. If its value is not an array nothing is decomposed: warning: "Unified 'input' value is not an array, skipping decomposition" is logged and the runtime inputs are returned unchanged — the raw input key survives, so a schema property literally named input still receives the scalar. Otherwise only keys that are connectable, internal (__) or a declared dynamic-port name are kept (the rest logged at debug: "Filtered non-connectable key"), and every individually wired port then overwrites the same key from the payload :359-412; non-array early return :368-371, filter :376-395, overlay :397-406 ports-unified-input-merge.yml + ports-unified-input-non-array.yml (scalar payload: node completes, connectable keys stay at their schema defaults, the scalar reaches a property named input)
CFG-17 Unified I/O merge filters on connectable only; the main-loop re-gate drops hidden-port smuggled values (two guards, one outcome) :387 vs :269 ✅ same fixture
CFG-18 configDefaults[name] written only when configurable and effective default non-NULL NodeMetadataResolver.php:680-683 NodeMetadataResolverDeriveSchemasTest

GR-EXPO — port exposure (the shipped 2026-07 invariant)

What showing or hiding a port means. One flag, one meaning, both directions: a hidden port cannot be wired, and a hidden output's value is stripped before anyone can see it.

ID Rule Impl Pinned
EXPO-1 PortExposure is the single PHP accessor for effective exposure (mirrors the editor package's isPortExposed node-level check); 6 consumers: ParameterResolver, ToolParameterScope, R7, R10, NodeRuntimeService, and the workflow form's exposure picker (MAN-3). instancePorts() is the one place data.config.ports is located on a stored node, so the save boundary and the picker read the override from the same slot src/Utility/PortExposure.php:8-33 PortExposureTest
EXPO-2 (D3) isExposed: scan direction-scoped config.ports for entry with exposed key → that value; else exposedByDefault :54-68 ✅ 4 cases
EXPO-3 Direction key mapping: OUTPUT→outputs, anything else→inputs; overrides direction-scoped :55
EXPO-4 Untouched node stores no ports key → default always applies :38-41
EXPO-5 isExposedByDefault(schema) FALSE only when x-exposed-by-default is exactly FALSE :95-97 PortExposureTest::testIsExposedByDefaultIsFalseOnlyForAnExactFalse (identity, not truthiness: NULL/0/''/'false' all stay exposed)
EXPO-6 x-exposed-by-default read only at authoring time (form seeding ×2, generator freeze, the two freeze update hooks) — never at execution. Correction: the flag is also read once on the API-generation path, at NodeMetadataResolver::transformSchemaToPorts, but the schema it reads there has already had the flag re-written from config (EXPO-7), so the plugin's own suggestion still never reaches generated metadata 3 cited sites + flowdrop_node_type.install, NodeMetadataResolver.php:816; declaring plugins: Trigger.php, ToolInvoke.php, ConversationBuffer.php, StateStorageNode.php PortSchemaExtensionReachTest (grep-backed: the 9 production files touching the key, none of them engine code). StateStorageNode is the first declaring plugin to live inside an engine module (flowdrop_stategraph), which the module-level path check reads as engine code — declaring a suggestion in a processor's own schema is authoring input, not the execution path consulting the flag, so the engine assertion exempts a reviewed declaration under src/Plugin/FlowDropNodeProcessor/ and nothing else + ExposureConfigOnlyMetadataTest (API metadata follows config, both directions) + NodeTypeGeneratorKernelTest::testMaterializesExposedByDefault
EXPO-7 Materialization divergent-only, destructive-on-TRUE: config exposed_by_default: FALSE writes the x-flag; anything else unsets it NodeMetadataResolver.php:658-664,744-750 ✅ derive-schemas suite
EXPO-8 Known seam: a plugin param added post-install with no re-save has no config key → runtime treats it as exposed regardless of the plugin's FALSE suggestion doc :56-61 ports-absent-flag-polarity.yml
EXPO-9 One-time freezes: update_10004 writes FALSE-only, idempotent, never overwrites an existing key, skips non-ports (non-connectable param / exposed: FALSE output); update_10005 repairs the alpha10 sweep, unsetting only exact-FALSE, instance overrides survive flowdrop_node_type.install:167-322 ExposureFreezeUpdateTest (10004) + ExposureSweepRepairUpdateTest (10005)
EXPO-10 Runtime input gating: for a connectable-but-hidden port Priority 1 is skipped, not failed — the runtime/LLM value is ignored and resolution continues to the configured value when the param is configurable and present (SOURCE_CONFIG), else to the entity/schema default (SOURCE_DEFAULT). Never SOURCE_RUNTIME, and never NULL-by-omission: hiding a port changes precedence, it does not unset the parameter. Internal __-prefixed params sit outside the gate and always accept runtime input ParameterResolver.php:269 (gate $isInternal \|\| ($connectable && $exposed)), :280-287 ✅ 4 tests + 2 fixtures
EXPO-11 Output stripping iterates the plugin's getOutputSchema()['properties'] only, so undeclared keys (dynamic outputs, reserved control ports) pass through. ⚠ Product gap — contradicts the exposure invariant: two early-outs bypass the pass entirely, an empty $result and a plugin whose output schema declares no properties. In the second case nothing is stripped even when the node type marks the output exposed: FALSE, so a hidden output's value is delivered NodeRuntimeService.php:715-724, loop :730 ✅ output-exposure suite (incl. ::testEmptyPropertiesSchemaBypassesStrippingEntirely, which pins today's leak)
EXPO-12 CONTROL_OUTPUTS (active_branches,state_update) never stripped :739-741 ::testControlOutputsAreNeverStripped
EXPO-13 Entity exposed: FALSE → stripped unconditionally; else instance override → type default → TRUE :742-760 ✅ 5 tests + fixture
EXPO-14 Stripping runs before unified I/O output composition, serializability check, Output wrapping, job records, checkpoints, real-time, tool results :199-226 NodeRuntimeServiceOutputExposureOrderTest (ordering-sensitive by construction: a hidden port never reappears inside the unified output — which filters on exposed only — and a json-hostile value on a hidden port does not fail the node)
EXPO-15 Tool-fillable set: param model-fillable iff connectable && exposed and no data edge feeds it ToolParameterScope.php:76-88 ToolParameterScopeTest
EXPO-16 Each exposed_by_default checkbox seeds from the saved entity value; with none it mirrors PortExposure::isExposedByDefault($schema) only while the port exists (input: connectable ticked; output: exposed ticked) and reads unchecked otherwise. Both are #states: enabled on that sibling selector, and a disabled checkbox is not submitted — so a port that does not exist never shows a greyed-out tick and cannot be submitted as exposed NodeTypeFormSectionsTrait.php:239-251,502-514 NodeTypeFormExposureSeedingTest (saved value wins; suggestion consulted only while the port exists; both checkboxes #states-gated on their sibling)
EXPO-17 displayOrder/x-port-order is cosmetic only — the engine never reads it (fdnpm's counterpart is NodePort.displayOrder; x-port-order appears nowhere in that repo's source) NodePort.php:47-57 PortSchemaExtensionReachTest::testPortOrderIsConfinedToTheMetadataLayer (grep-backed: only the DTO and the metadata resolver) + NodePortTest / NodeMetadataResolverTransformPortsTest for the mapping
EXPO-18 New-port exposure drift, and its repair. A node-type output the shipped config/install YAML declares hidden (exposed: true + exposed_by_default: false) reaches fresh installs only: config/install runs on module install, so an existing site keeps an outputs map with no entry at all for the new port — and a missing entry resolves to exposed (SCH-14 / filterOutputSchema defaults both flags TRUE). Same code, two canvases. flowdrop_node_processor_post_update_hide_tool_artifacts_output writes the shipped pair onto tool_invoke.tool_artifacts for the release that added it; it touches an absent entry only, so any stored entry (author choice, or its own previous run) survives and the hook is idempotent. The general seam is EXPO-8's, in the output direction flowdrop_node_processor.post_update.php:54-93 NodeProcessorUpgradePathTest (repair, author override, idempotency)
EXPO-19 Loopback edges stored before SCH-32 are legalized, not broken. Injecting the reserved loop_back input moved the port from metadata-undeclared (R7.d, out of scope) to declared-and-hidden, so R7.a would reject every stored edge landing on it — at save and at launch (VAL-LAUNCH), i.e. a workflow nobody touched stops running. flowdrop_workflow_post_update_legalize_loopback_edges walks stored workflows and writes R7.c's own escape hatch — the instance data.config.ports override, in the exact shape the R7 doctor's expose-port mutation writes — on each node a -input-loop_back edge targets. Which nodes need it is decided from live metadata, never a type list: a type that hand-declares loop_back (ForEach, Reason, MemoryRead) already resolves exposed and is skipped, a type that gets no injection (Start/Terminal/NonExecutable) has no metadata entry and stays in R7.d, and an override that already exposes the port is left alone. An override that explicitly hides a wired loopback port is overwritten — it cannot predate the injection, and honouring it would leave the workflow permanently un-launchable flowdrop_workflow.post_update.php:171-320 LoopBackEdgeLegalizationUpdateTest (per-type selectivity, validator round-trip, author override, idempotency)

GR-DYN — dynamic ports

Ports an author adds to a single node instance beyond what the plugin declares: naming rules, and the fact that they deliberately bypass the exposure machinery.

ID Rule Impl Pinned
DYN-1 Opt-in by spreading buildDynamic{Inputs,Outputs}Schema() into the param schema; declared as reserved array params, default [] DynamicPortTrait.php:82-202 DynamicPortTraitTest
DYN-2 Amended by SCH-36 (the enum is DERIVED, not restated) and scoped by SCH-41: the picker offers the SHIPPED lanes only, NOT declared shapes. dynamicPortDataTypes() is a static method on a trait in the base flowdrop module, and reaching the shape registry from there would invert the module layering (flowdrop_node_type depends on flowdrop, never the reverse) or require a service-locator call inside a trait. The constraint costs little, because DYN-2 is declarative only: nothing validates a dynamic port's dataType, so a stored shape lane on one resolves and executes — it is simply not offered in the picker. Revisit if the picker moves out of the trait. Port item: name,label,dataType required; the dataType enum is every shipped lane whose PortDataType::carriesValue() is TRUE — the control lanes are filtered out because a dynamic port carries a value by definition. It was a hand-written list of NINE (string,number,boolean,array,json,mixed,file,image,messages), and being hand-written is what made it wrong: an author could pick from just over a third of the vocabulary, and the missing lanes were missing for no stated reason. Default stays mixed. The closure remains declarative only — it constrains the port editor's choices; no PHP path validates per-item constraints, so an out-of-enum dataType is accepted at resolution and execution time DynamicPortTrait::dynamicPortDataTypes(), :108-122
DYN-3 validatePortNames() throws on: name not /^[a-zA-Z][a-zA-Z0-9_]*$/; forbidden names; duplicates across the input∪output union. Empty/non-array defs skipped silently. Two caveats: the __-prefix guard is unreachable (a leading underscore already fails the pattern, so the caller gets the pattern message), and the pattern is not D-anchored, so a single trailing newline ("port\n") is accepted. A def whose name is not a string is skipped silently, the same fallback as an empty name or a non-array def (pre-narrowing it was an unhandled TypeError) :284-321 DynamicPortTraitTest
DYN-4 Unconnected dynamic ports yield NULL (key present) :258-268 DynamicPortTraitTest
DYN-5 Static param names beat dynamic ones (collision → dynamic ignored) ParameterResolver.php:173-175 ports-dynamic-passthrough.yml
DYN-6 Dynamic ports have NO exposure semantics, and by construction rather than by exemption: the port name is absent from the plugin's parameterSchema/outputSchema, so every exposure gate misses it. ParameterResolver forwards the value from runtimeInputs after the main loop, with no exposure check; filterUnexposedOutputs iterates declared schema properties only, so a dynamic output's value is never stripped; R7 skips the edge because there is no metadata exposure default to enforce against. Correction: R10 does not skip it — a dynamic port named in a workflow-exposure entry is refused earlier as PORT_MISSING ("not declared by plugin"), so a dynamic port is not exposable at the workflow boundary at all ParameterResolver.php:205-221; NodeRuntimeService.php:730; WorkflowValidator.php:643-646, :990-999 (R10's plugin-declaration check) ports-dynamic-passthrough.yml + NodeRuntimeServiceOutputExposureTest::testUndeclaredKeysPassThrough + WorkflowValidatorTest::testExposedAndUnknownPortEdgesAreValid
DYN-7 A metadata/form rule only: on node-type configurable opt-in (= SCH-5) the reserved param is routed to configSchema — never inputSchema, so it is never a wireable port — with default from the entity default, else the schema default, else []; without opt-in it is dropped from both schemas. It says nothing about runtime, where the definitions are resolved as an ordinary schema property and then read back at ParameterResolver.php:205. Because the trait declares default: [], opting out resolves the definitions to [] and the ?? $workflowValues[…] fallback there is unreachable — so stored definitions do not survive an opt-out (that fallback only fires for a plugin declaring the param with no default) NodeMetadataResolver.php:635-645; DynamicPortTrait.php:82-88; ParameterResolver.php:205 ✅ 3 tests

GR-MEM — agent-loop conversation memory

The message-shaping nodes an agent loop needs beyond a plain buffer: idempotent append, tail healing, assembly, provider-sequence normalization, and the string→message adapter every text producer needs to reach any of them. ConversationBuffer is @internal; the message-shaping node processors live in flowdrop_node_processor (no memory backend needed).

MEM-6..MEM-8 are a different promise on the same subsystem — who a memory bucket belongs to. They are grouped here because the user scope is what a conversation buffer is usually keyed by.

ID Rule Impl Pinned
MEM-1 ConversationBuffer append is idempotent by tool_call_id: a tool-role message whose metadata.tool_call_id already exists anywhere in the buffer (stored + already-appended-this-call) is dropped, logged at debug, and never appended; the returned count reflects the deduped buffer. One exception, MEM-10: an id currently held by a SYNTHETIC healed placeholder is replaced, not deduped ConversationBuffer.php:268-330 (appendMessages loop), :531-548 (collectAnsweredToolCallIds), :648-680 (isDuplicateAppend) ConversationBufferTest::testProcessDropsDuplicateToolResultByToolCallId (+ ::testProcessAppendsToolResultWithUnseenToolCallId pinning a new id is not caught)
MEM-2 Same guard, assistant side: an assistant message is dropped as a duplicate only when EVERY id in its metadata.tool_calls is already declared somewhere in the buffer — a partial-overlap turn (at least one new id) is kept, not dropped :554-566 (collectDeclaredToolCallIds), :581-596 (declaredIdsOf), :648-680 (isDuplicateAppend) ConversationBufferTest::testProcessDropsDuplicateAssistantToolCallsTurn + ::testProcessKeepsAssistantTurnWithPartiallyNewToolCallIds
MEM-3 Appending a user-role message first heals a dangling call: if any assistant tool_calls id in the buffer has no matching tool-role tool_call_id anywhere in the buffer, a synthetic tool-role message ('Tool call was interrupted; no result.', metadata: {tool_call_id, healed: true}, timestamp from the injected datetime.time) is inserted for each dangling id directly after the assistant turn that declared it — NOT at the buffer end, which only closed the pair for a dangle that happened to be last; a provider requires each result adjacent to its call, so a mid-buffer dangle (the loop crashed, the conversation carried on) produced a history the provider still rejected. Healing runs ONLY on this user-turn boundary — a tool/assistant append never triggers it. ToolPairing::repair() applies the same adjacency rule on the reasoning path but over ReasonMessage, which carries neither metadata nor timestamp: round-tripping buffer rows through it would drop exactly the fields this node promises to preserve, so the rule is deliberately re-stated here rather than reused :304-308 (boundary gate in appendMessages), :698-730 (healOpenTail) ConversationBufferTest::testProcessHealsOpenTailBeforeAppendingUserMessage + ::testProcessHealsMidBufferDangleAdjacentToItsCall + ::testProcessDoesNotHealOnNonUserAppend
MEM-4 message_assemble: N dynamic messages-typed input ports (DynamicPortTrait, config-only per DYN-7) flatten into one static messages output, declared port order = message order. A connected list contributes each of its items; a connected single message object (a bare associative array) is wrapped as one item; an unwired/NULL port contributes NOTHING — no placeholder, the deliberate opposite of MergeNode's array-mode NULL-padding (arity is not the point here, message content is) MessageAssemble.php:82-124 MessageAssembleTest (9 cases incl. ::testUnwiredPortContributesNothing, ::testSingleMessageObjectIsWrapped)
MEM-5 The row shape is DECLARED by the messages port shape (Plugin/PortShape/MessagesShape.php, SCH-42), not by this row, and not by the three docblocks that used to restate it. conversation_normalize: one messages input → messages + dropped outputs. In execution order: (1) each entry is coerced to the flat {role, content, tool_calls?, tool_call_id?} shape ReasonMessage::fromArray() / the reason node's messages input expect — both that flat shape and the buffer's {role, content, metadata} shape are accepted; a non-array or roleless entry is dropped, and a NON-scalar content (parts-style multimodal arrays) is json_encoded into the text content rather than blanked (unencodable content falls back to ''). (2) system-role messages float to the front, each group (system / rest) keeping its own relative order — never drops (the shipped ChatReasoner drops ALL history system turns regardless of position, so reordering rather than dropping keeps this node useful to a reasoner that replays them). (3) tool pairing is healed/dropped via the SAME ToolPairing repair the reason node applies mid-loop (an unanswered tool_calls id gets a synthesized interrupted result; an orphan/duplicate tool result is dropped; a RE-declared tool_call id is dropped from the later turn — the first declaration owns the pairing, so a double-persisted turn cannot leave a declaration that can never be answered) — reused rather than re-implemented so the two paths can't drift, and the drop count comes from ToolPairing::repair()'s own pass (ToolPairingResult), never from a mirror of its rules. Floating first is outcome-equivalent to pairing first: pairing is id-based and a system turn is never a party to it. (4) a final guard strips any message that is STILL leading with role tool after (1)-(3) — step (3)'s id-matching alone would miss a malformed input where a tool result precedes the assistant turn declaring its id (declared-id lookup is order-independent) — and because that strip can un-answer the declaring turn, step (3) is RE-RUN afterwards (heal() is idempotent), so the output is provider-sendable, not merely orphan-free. "Provider-sendable" is scoped to what ToolPairing guarantees: unique declarations each answered exactly once, synthetics adjacent, no orphans/duplicates, no leading tool row — a pre-existing REAL result the INPUT displaced from its declaring turn is not moved, a shape the shipped producers never emit. WIREABILITY: the input is only reachable if a shipped producer emits the messages type, so conversation_buffer's messages output declares x-data-type: 'messages' too (as message_assemble already does); nothing loses wiring, since messages is one-way compatible with array/json per SCH-10 and the port-config rules. memory_read's value output is deliberately NOT retyped — it returns whatever was stored under any key, not a message list ConversationNormalize.php:92-163 (process), :189-217 (coerceMessage); ToolPairing.php:70-146 (repair); ConversationBuffer.php:628-638 (output schema) ConversationNormalizeTest (17 cases covering all four rules + the dropped count, incl. ::testLeadingToolStripLeavesNoDanglingToolCall, ::testReportsDropsConsistentlyForFalsyToolCallId, ::testSerializesPartsStyleContentInsteadOfBlankingIt) + ToolPairingTest::testRepairReportsItsOwnDropCount / ::testHealIsIdempotent; the retyped producer via NodeMetadataResolverTransformPortsTest::testConversationBufferMessagesOutputIsMessagesTyped + NodesControllerPortConfigTest::testMessagesIsOneWayCompatibleWithArrayAndJson
MEM-6 A user scope that cannot resolve a real uid REFUSES. ScopeResolutionTrait::resolveScopeId() returns NULL (not '') for scope: user whenever the execution context is absent, carries no user_id metadata, carries a non-castable one, or carries 0/'0'. The session scope refuses the same way (MEM-13); every non-identity scope keeps degrading to ''. The distinction is load-bearing: '' is mapped to the shared __global__ bucket by AbstractMemoryBackend::buildStorageKey(), so a degrading user scope silently merged every identity-less execution path (launch endpoint, queue, cron, drush) into ONE bucket, and uid 0 merged every anonymous visitor into user:0. Uid 0 is the absence of an identity, not an identity — Drupal's EntityOwnerTrait reports 0, never NULL, for anonymous, which is why the zero check and not just an empty check ScopeResolutionTrait.php (resolveScopeId(), resolveUserScopeId()); bucket mapping AbstractMemoryBackend.php:41 ScopeResolutionTraitTest (5: missing, empty, uid 0 both spellings, no context, non-scalar)
MEM-7 Every consumer honours the refusal, and each logs exactly one warning naming node/pipeline/workflow. MemoryRead returns its default with found: FALSE, MemoryWrite and MemoryDelete return success: FALSE, and ConversationBuffer returns an empty buffer (messages: [], count: 0) — in every case resolved_scope_id is '' and the memory backend is not called at all, neither for the read nor for the write-back. A refused buffer append therefore drops the turn rather than appending it to a shared history. The refusal is decided once, in the trait, and the warning is emitted through the shared logScopeRefusal() so the four consumers cannot drift MemoryRead::process(), MemoryWrite::process(), MemoryDelete::process(), ConversationBuffer::process(); ScopeResolutionTrait::logScopeRefusal() MemoryReadTest (3), MemoryWriteTest (3), MemoryDeleteTest (3), ConversationBufferTest (3), ScopeResolutionTraitTest::testLogScopeRefusalWarnsWithNodeContext, + SessionMemoryUserScopeTest::testAnonymousOwnedSessionWritesNoUserMemory (end to end)
MEM-8 user_id is the session OWNER or nothing. SessionExecutionService::buildOrchestrationOptions() stamps playground.user_id only when the session's owner uid is positive; an owner-less or anonymous-owned (uid 0) session stamps NO key, and there is deliberately no fallback to the acting user — memory follows the conversation, so binding an owner-less conversation to whoever POSTs the turn would hand one visitor's bucket to the next. From there the orchestrators copy playground.user_id into the node execution context's metadata, which is what MEM-6 resolves. (The removed fallback was also dead: getOwnerId() returns 0, never NULL.) SessionExecutionService.php (buildOrchestrationOptions()); AbstractOrchestratorBase.php:1020-1022; StateGraphOrchestrator.php:1335-1336 SessionExecutionServiceHierarchyTest (3: no owner, uid 0, real uid) + SessionMemoryUserScopeTest::testOwnerUidReachesUserScopedMemory (owner uid reaches the user bucket through a real turn)
MEM-9 Driving a session is a WRITE, and the routes say so. The canonical turn route (flowdrop_session.api.session.turn) requires _entity_access: flowdrop_session.update — not .view — alongside the execute session workflow capability and the CSRF header, and the playground's drive callback (accessDriveSession, gating send-message / stop / reset) asks the same entity for update. Read-only surfaces (the session page, message polling) stay on view. This is the route half of MEM-8: the turn runs under the OWNER's identity and reads/writes the owner's user memory bucket, so an observer holding only view any flowdrop_session must not reach it. For that gate not to lock out ordinary chat users, FlowDropSessionAccessControlHandler grants update on ownership ALONE — no edit own needed (no shipped role grants it) — and refuses ownership for uid 0, so anonymous never "owns" an anonymous session. The uid-0 refusal is not local to that arm: EVERY ownership comparison across the session surface requires a positive uid on both sides — session view/update/delete, session-message view (which reads the PARENT session's owner), and snapshot view/update/delete. This is MEM-6's sentence applied to entity access: uid 0 is the absence of an identity, not one identity shared by every anonymous visitor, so a bare 0 === 0 on any of those six arms hands every visitor of a public chat or playground site every other visitor's conversations, transcripts and run snapshots the moment the site grants a … own … permission to the anonymous role. Two deliberate edges of the grant: (a) update also opens the entity edit form and any generic PATCH surface — accepted for every field EXCEPT the owner field, which checkFieldAccess() clamps to the edit-any/administer tier, because re-homing a session re-binds its memory principal; (b) interrupt resolution is a sanctioned drive path of its own — resuming a paused turn is gated on the INTERRUPT entity's resolve access (own/any), not on session update, which is the assignment/inbox model, not a bypass flowdrop_session.routing.yml; FlowDropSessionAccessControlHandler::checkAccess() (view/update/delete arms, all through isRealOwner()), ::checkFieldAccess() (uid clamp); FlowDropSessionMessageAccessControlHandler::checkAccess() (view arm); FlowDropWorkflowSnapshotAccessControlHandler::checkAccess() + ::isRealOwner(); PlaygroundApiController::accessDriveSession() SessionTurnRouteAccessTest (6: route requirement, owner, observer 403, edit any, anonymous non-owner, owner-field clamp) + PlaygroundSessionAccessTest::testDriveCallbackRequiresExecuteCapability + SessionAccessControlTest (uid 0 refused on view/update/delete, each against a positive-uid owner allowed on the same fixture) + SessionMessageAccessControlTest::testAnonymousDoesNotReadAnAnonymousTranscript + SnapshotAccessControlTest::testAnonymousOwnsNoSnapshotOnAnyArm (all three arms)
MEM-10 A heal is a placeholder, not a verdict. When a tool result arrives for an id whose buffer entry is the SYNTHETIC healed message (metadata.healed === TRUE), the real result REPLACES it at the same index — same position, so MEM-3's adjacency to the declaring assistant turn is kept — and the healed marker goes with the placeholder. Without the replace path MEM-1's dedupe guard saw the id as answered and dropped the tool's actual answer forever, leaving the model reading "interrupted" for a call that returned. Only a placeholder is ever overwritten: after the swap the slot is an ordinary answered result, so a second real result for the same id is deduped by MEM-1 as usual, and an incoming message that itself carries healed never replaces anything. The swap is logged at debug ConversationBuffer.php:292-294 (checked before the dedupe guard), :342-380 (replaceHealedPlaceholder) ConversationBufferTest::testProcessReplacesHealedSyntheticWithTheRealResult + ::testProcessDedupesSecondRealResultAfterTheReplacement + ::testProcessReplacesAnIntraBatchHealOnLaterCall (the heal and the real result arriving on separate process() calls)
MEM-11 Both spellings of a tool call's id are read, everywhere. Inside an assistant turn's metadata.tool_calls, a call's id is read as tool_call_id ?? id — the node's own normalized shape AND the provider-flat OpenAI shape the docblock and the message schema both advertise. There is one read site (declaredIdsOf()) feeding dedupe (MEM-2), heal-detection (MEM-3) and id recording, so the three cannot disagree. Reading only tool_call_id made all of them silently blind to a buffer written from a raw provider payload: no dedupe, and — worse — every already-answered call looked dangling and collected a spurious synthetic :581-596 (declaredIdsOf), sole call sites :554-566, :613-621, :648-680, :698-730 ConversationBufferTest::testProcessDedupesToolResultAgainstOpenAiShapedToolCalls + ::testProcessHealsOpenAiShapedDanglingToolCall + ::testProcessDoesNotHealAnAnsweredOpenAiShapedToolCall (mixed spellings across the pair)
MEM-12 The buffer read-modify-write is serialized per storage scope, and says only that. process() wraps the MemoryManager get()…set() in Drupal's lock service on flowdrop_memory_buffer:<scope>:<scope_id>:<key>, so parallel branches (or an async orchestrator racing a scheduler re-fire) queue instead of each reading the pre-append list and writing back a version missing the other's turn. Bounded, never fatal: acquirewait(5)acquire, and an append that still cannot take the lock proceeds UNSYNCHRONIZED with a warning — losing a turn is worse than a rare interleave. Released only when acquired, in a finally. What is NOT claimed: storage-level atomicity (the backend has no compare-and-swap) and any protection from a writer that bypasses this node. The class docblock previously claimed the write was "atomic"; it never was ConversationBuffer.php:175-189 (wrap), :216-218 (bufferLockName), :234-247 (acquireBufferLock) ConversationBufferTest::testProcessAppendsUnderTheBufferLockAndReleasesIt + ::testProcessProceedsWithWarningWhenTheLockIsNeverAcquired + ::testProcessTakesNoLockForRefusedScope
MEM-13 A session scope that cannot resolve a real session id REFUSES. ScopeResolutionTrait::resolveScopeId() returns NULL (not '') for scope: session whenever the execution context is absent, carries no session_id metadata, carries a non-castable one, or carries 0/'0' (session entity ids are positive integers). Same load-bearing distinction as MEM-6: '' maps to the shared __global__ bucket, so a degrading session scope silently spliced every identity-less execution path (launch endpoint, queue, cron, drush) into ONE shared conversation-history bucket — the exact leak class MEM-6 closed for user, closed here for the other identity scope. Non-identity scopes (workflow, pipeline, execution, global, unknown) keep degrading to ''. Consumers honour the refusal per MEM-7 ScopeResolutionTrait.php (resolveScopeId(), resolveSessionScopeId()); bucket mapping AbstractMemoryBackend.php:41 ScopeResolutionTraitTest (5: missing, empty, id 0 both spellings, no context, non-scalar) + one refusal test per consumer (MemoryReadTest, MemoryWriteTest, MemoryDeleteTest, ConversationBufferTest)
MEM-14 The append reports its delta, not just the buffer snapshot. ConversationBuffer emits appended (how many incoming messages were added as NEW entries) and appended_any (appended > 0) alongside count. Load-bearing because every drop above is SILENT: MEM-1/MEM-2 dedupe, an empty-content drop, and the MEM-10 placeholder swap (a replacement changes a message rather than adding one, so it counts as neither) all leave count — a snapshot of the whole buffer, identical across two identical passes — as the only evidence, which is no evidence at all. appended counts appends and deliberately NOT the size delta: the max_messages window can evict as many older messages as this call added, so a size comparison would read a windowed append as a no-op. A refused identity scope (MEM-13) returns 0/FALSE with its empty buffer. appended_any is exposed by default (a gateway wires it); appended is not, matching count — on BOTH sides, since the shipped config hides it and the plugin schema must say so too via x-exposed-by-default or a regenerated node type seeds it exposed and drifts back. The shared <verb>_any/<verb> shape this and RT-TOOL-10 both follow is documented in docs/development/flowdrop-node-processor.md ConversationBuffer::process(), ::appendMessages() ConversationBufferTest (dedupe drop → 0/FALSE; unseen id → 1/TRUE; MEM-10 replacement → 0/FALSE; windowed append → appended 1 while count is unchanged) + ConversationBufferNodeTypeTest (keys and exposure both match the processor schema) + MemoryUpgradePathTest (the appended post_update repairs drift, respects an author override, is idempotent, survives a deleted node type; appended_any needs no hook)
MEM-15 One access ladder, three handlers, evaluated in one fixed order. FlowDropSessionAccessControlHandler, FlowDropSessionMessageAccessControlHandler and FlowDropWorkflowSnapshotAccessControlHandler all decide an operation by walking the same four rungs and stopping at the first that answers: (1) administer flowdrop → allowed; (2) the entity type's own admin permission → allowed; (3) the operation's … any … permission → allowed; (4) the operation's … own … permission → allowedIf(isRealOwner), the positive-uid comparison of MEM-9. A caller holding none of them gets forbidden(), not neutral(), so no later hook can grant what the ladder refused; an operation the handler does not name falls through to neutral() instead, leaving the decision to core. An entity of an unexpected type is also neutral(), but where that guard sits differs: the session and message handlers check the type before rung 1, the snapshot handler checks EntityOwnerInterface only after the two admin rungs — so a foreign entity reaching the snapshot handler is allowed for an administrator and neutral for everyone else. Ownership results carry cachePerPermissions() + cachePerUser() + the entity they were decided against; the permission-only rungs carry cachePerPermissions() alone. Two deliberate departures, both narrowings: a session-message is never updatable or deletable below the admin tier (delete is a flat forbidden(), update is unnamed and therefore neutral), and a message whose parent session cannot be loaded is forbidden('Parent session not found.') before the ladder's any rung — so even view any flowdrop_session_message does not open an orphan. Create access is a separate, shorter ladder per entity type (admin tiers, then execute session workflow for a message, create flowdrop_session for a session, and nothing at all for a snapshot beyond the admin tiers — snapshots are written programmatically) FlowDropSessionAccessControlHandler::checkAccess()/::checkCreateAccess(); FlowDropSessionMessageAccessControlHandler::checkAccess()/::checkCreateAccess(); FlowDropWorkflowSnapshotAccessControlHandler::checkAccess()/::checkCreateAccess() SessionAccessControlTest (both bypass tiers in both directions, own/any/none per operation, unknown operation neutral, foreign entity type neutral, create arms, per-user caching), SessionMessageAccessControlTest (same, plus ::testOrphanedMessageIsForbiddenEvenForViewAny, ::testDeleteIsForbiddenBelowTheAdminTier, ::testUpdateOperationIsNeutral, ::testSessionViewPermissionsDoNotGrantMessageAccess), SnapshotAccessControlTest::testOwnAnyAndNoneAcrossEveryOperation (+ the bypass, neutral and caching arms)
MEM-16 The emitted row's shape is declared by the messages port shape (SCH-42); this row states the node's RULES, not the payload format. message_from_text: the string→message adapter. A text value plus a role become ONE message, emitted twice — a one-element messages-typed list (the port that wires anywhere, since messages is one-way compatible with array/json per SCH-10) and the same row as a plain json message object. Content coercion mirrors MEM-5's step (1) so the two cannot disagree about what a value means: a scalar casts, a non-scalar is json_encoded rather than blanked, and unencodable content falls back to ''. Three closed rules: (a) role is one of user|assistant|system|tool and anything else — including an absent value — resolves to user, never travelling to a provider that would reject the whole request; (b) tool_call_id is attached ONLY on role tool, trimmed, and omitted when blank — on any other role the id names no pairing ToolPairing could answer, so carrying it through would seed a buffer row whose id looks answerable and is not; (c) empty content emits an EMPTY list and an empty object, the same "contribute nothing, never a placeholder" choice MEM-4 makes for an unwired port — an empty turn would satisfy the reason node's no-messages guard while giving the model nothing, so an unwired port would become a silent inference on a blank prompt. A tool row with no content is no exception: an unanswered call is ToolPairing's to synthesize (MEM-3), and it says so MessageFromText.php:97-123 (process), :142-151 (coerceContent), :68 (ROLES) MessageFromTextTest (14 cases covering all three rules, both coercion branches and the port typing, incl. ::testToolCallIdIsIgnoredOnNonToolRole, ::testEmptyToolResultEmitsNoMessage, ::testMessagesOutputIsMessagesTyped)

GR-MAN — workflow launch-input manifest (cross-ref G1–G4, all shipped 2026-07-27)

The workflow's public face: which inputs an outside caller may supply when launching it. Only inputs the author explicitly declared exist at the launch boundary — everything else is refused.

ID Rule Impl Pinned
MAN-1 Workflow input ports are a declared manifest (input_ports), never derived from the exposure cascade; a plugin gaining a param cannot widen the launch surface. The builder never consults PortExposure: a declared entry is built even when the instance hides that port, and an exposed-but-undeclared param never reaches the snapshot form :143-168; importer; WorkflowSchemaBuilder.php:72-101 ::testManifestIsIndependentOfExposureCascade
MAN-2 Entry shape {name,node_id,port} + optional title,description,examples,required (input side only); required is folded into the built object schema's top-level required list rather than surviving on the property fragment WorkflowSchemaBuilder.php:72-113,177-200; schema flowdrop_workflow.schema.yml (port_exposure.required) ::testAuthorMetadataOverlaid, ::testRequiredEntryLandsInTopLevelRequiredList
MAN-3 The authoring workflow form (FlowDropWorkflowForm::getAvailableNodePorts()) is no longer the only manifest writer: the REST API's interface mapping (MAN-20) writes input_ports/output_ports too, from a caller-supplied client contract rather than an operator picking ports in a form. Both writers still land on the same server shape and the same validator (R4.a-e, R10) — the form's picker offers metadata-declared, exposed-on-instance ports minus already-exposed pairs and minus trigger; the API mapping is entry-shaped from the client payload with no such offer-side filtering, so a caller can submit an entry the validator will reject (hidden port, unknown node) and get a 422, whereas the form's picker is filtered to never offer one. Exposure is resolved through R10's chain either way: PortExposure::instancePorts() + PortExposure::isExposed() (instance config.ports[].exposed override → port metadata exposedByDefault → exposed) FlowDropWorkflowForm.php:724-794 (form); WorkflowsController::applyInterface() (API) ExposurePickerHiddenPortTest (5 tests: hidden input/output withheld, exposed offered, instance override restores, instance-hide withdraws, already-exposed pair still excluded); WorkflowInterfaceApiTest::testInterfaceEntryWithUnknownNodeIdIs422 (the API side gets no such offer-side filtering, so the validator is what refuses it)
MAN-4 R4 guards manifest integrity (see GR-VAL R4.a–e) — (cross-reference; R4.a–e carry the tests)
MAN-5 Snapshot built per entry from the plugin fragment, stripped by pickContractKeys() to exactly type,enum,format,default,required,properties,items,minimum,maximum,minLength,maxLength,pattern — every other key dropped at every depth, recursing into properties[*] and items. So plugin annotations (title,description,examples) and x-* extensions never reach the stored snapshot; annotations are re-attached at read time only (?annotated=1, MAN-6) WorkflowSchemaBuilder.php:28-41 (CONTRACT_KEYS), :116-152 ✅ 2 tests (incl. ::testContractStripRecursesIntoPropertiesAndItems)
MAN-6 Author metadata overlaid after stripping, wins over plugin annotations, input direction only; empties dropped :177-200 ✅ 3 tests
MAN-7 default survives when it is NULL, scalar, or a one-level array whose every element is scalar/NULL (an empty array included). An object default survives only when it has public vars, and is stored as get_object_vars() — i.e. coerced to an array; an empty object and any nested value are dropped, logging Default value dropped from schema snapshot (unsupported shape). while every other contract key of the fragment is retained :212-237 (sanitiseDefault), drop site :139-147 ✅ 4 tests
MAN-8 Unresolvable entries (node missing, plugin missing, port not declared by the plugin) skipped with log warning. ⚠ A structurally malformed entry (missing/empty name/node_id/port) is skipped silently — the shape guard logs nothing :73-96,276-330 ✅ 4 tests (incl. the silent-skip divergence)
MAN-9 Workflow-as-port composition: cycle guard keyed on workflow id → {type:object, properties:{}} + warning; sibling re-entry allowed :57-66,102-103 ✅ 2 tests
MAN-10 (G1) Declared manifest names win over node-id pass-through WorkflowSchemaResolver.php:41-44 ::testDeclaredNameWinsOverNodeIdPassThrough
MAN-11 Named input resolves to initialData[node_id][port] :33-36
MAN-12 (G2) Node-keyed pass-through is an internal contract (session/chat_input); the public launch boundary refuses it. ⚠ Amended by API-2: the session door today array_merges raw caller input into this internal shape (SessionExecutionService.php:645-650) — the "internal" framing only holds once API-2 lands and the pass-through is unreachable from outside :45-52; launcher :219-224 ✅ 3 tests (pin the launcher side; the session leak is API-2's ❌)
MAN-13 (G3) strict: TRUE (launcher) throws \InvalidArgumentException naming the offending key and the workflow; lenient (session) drops it and logs a warning carrying not a declared input name and @name — never silently. In both modes the value is never written into the returned initialData (the continue precedes every write). ⚠ Amended by API-2: the lenient session mode is slated for deletion; strict becomes the only externally reachable behavior :54-72 ✅ 2 tests
MAN-14 Malformed manifest entries dropped-with-warning even in strict mode (strict polices the caller, not the stored manifest). An empty node_id/port drops the same way :74-86 ✅ 2 tests
MAN-15 WorkflowInputChecker::check() short-circuits at the first failing stage (unknown keys → missing required → per-value) and returns one message; the launcher wraps it in InvalidLaunchInputException. Every message appends Known inputs: … (or This workflow accepts no inputs.). RESOLVED (OPEN-16): the missing-required stage now unions the manifest entry's required (NodePort::fromArray($port)->required) with the names in the published snapshot fragment's top-level required list, so a name either source flags is enforced — entries written before a rebuild (or in tests with no snapshot) still work from the entry alone. flowdrop_workflow.port_exposure now defines required (boolean, nullable, input side only), and FlowDropWorkflowForm exposes it as a checkbox on each input's metadata panel WorkflowInputChecker.php:48-116; schema flowdrop_workflow.schema.yml (port_exposure.required); WorkflowLauncher.php:200-205; FlowDropWorkflowForm.php (buildInputEntryPanel(), narrowEntries(), applyInputMetadata()) ✅ 4 tests (incl. ::testOmittedRequiredInputIsRefused pinning the entry-only path, ::testMissingInputRequiredOnlyBySnapshotIsRefused pinning the snapshot-only path, and the unknown-beats-missing order)
MAN-16 (G4) Values checked against snapshot fragments via the shared SchemaFragmentChecker, and only type and enum are enforced by the per-value stage; ⚠ every other snapshot key (minimum,maximum,minLength,maxLength,pattern, nested properties/items) is declared in the published contract but never checked there, as is a non-string/union type. The top-level required list is the one declared-contract key that is now enforced, but by the earlier missing-required stage, not this one (OPEN-16/MAN-15). No coercion; structured kinds (object,array,list,map,json) all collapse to is_array; mixed/any are a kind of their own that waives the TYPE check entirely (every value matches, including NULL and an array) while still honouring enum — they are declared no-constraint types, not unknown ones falling through to the scalar-only string default, and the runtime already reads them that way (ParameterResolver::validateValue() short-circuits on mixed; ToolParameterScope::normalizeForModel() drops mixed/any since omitting type is JSON Schema's "any type"). A boundary refusing what the runtime accepts would give a port's declared type two meanings. enum compares with strict in_array (so 1"1"). Per-value violations aggregate into one InvalidLaunchInputException joined by ;; a name with no fragment skips the value check entirely SchemaFragmentChecker.php:99-116, kind() :46-56, matchesType() :72-88; snapshot required emission in WorkflowSchemaBuilder.php:72-113 ✅ 10 tests (incl. ::testDeclaredButUnenforcedConstraintsStillLaunch pinning declared-not-enforced, the two-violation aggregate, ::testMixedAcceptsEveryValue and ::testMixedStillHonoursEnum pinning the waiver's exact scope, ::testOtherTypesStillRefuseMismatches pinning that it did not soften the rest, and — since API-2 makes both doors share WorkflowInputCheckertestMixedPortAcceptsStructuredInputValue at both boundaries: WorkflowLauncherTest (launch) and SessionTurnApiInputContractTest (turn)); required emission covered separately by WorkflowSchemaBuilderTest::testRequiredEntryLandsInTopLevelRequiredList
MAN-17 Output side: outputs[name] = results[node_id][port]; presence is array_key_exists, so a NULL value counts as produced; produced-nothing → dropped with warning (composes with EXPO-13); malformed entries skipped silently WorkflowSchemaResolver.php:97-126 ✅ 4 tests
MAN-18 Rebuild is triggered only when input_ports or output_ports differ from the original, or $forced; isSyncing() (config import) returns before any work, so an import never bumps a version — and a change inside a source plugin's fragment never triggers a rebuild, leaving the snapshot stale until something else does. An empty port list stores NULL for that side's schema. Version: any of major/minor/patch from 0.0.0 yields 1.0.0; otherwise the named digit bumps and the lower ones reset. none writes the schemas and logs a debug, leaving schema_version untouched. A builder throw logs error, preserves the previous snapshot, and lets the save proceed WorkflowSchemaSnapshotter.php:45-55, :61-77, :112-139 ✅ 8 tests
MAN-19 wait: TRUE against an async-declared workflow refused before pipeline creation WorkflowLauncher.php:74-81
MAN-20 The workflow REST API maps the client interface object onto the server's input_ports/output_ports manifest. Client id <-> server name (a rename between the two is a breaking change for callers); v1 accepts exactly one binding per entry (bindings[0].nodeId/.portId -> node_id/port), refusing more than one with a 400; a zero-binding entry is a client-side draft with no server representation and is skipped, not stored, and not an error; dataType, schema, defaultValue and meta are all ignored on writedataType/schema are derived server-side from the bound port's own schema fragment, and defaultValue/meta have no server column yet (deliberately deferred). Input-side author metadata (title<->client name, description, examples, required) round-trips the same way form-authored metadata does (MAN-2/MAN-6); output entries carry only the name/node_id/port triple. The mapped ports are set via setInputPorts()/setOutputPorts() before validateStructure() runs, so R4.a-e and R10 are the only things that ever refuse a bad name/binding — the controller's own 400s cover shape only (missing id, malformed bindings, more than one binding), never whether the target node/port exists WorkflowsController.php (applyInterface(), mapInputInterfaceEntries(), mapOutputInterfaceEntries(), resolveInterfaceEntryId(), resolveInterfaceEntryBinding()) WorkflowInterfaceApiTest (create with interface round-trips both sides + schema/version rebuild; over-bound entry -> 400 nothing stored; zero-binding draft skipped; update without interface preserves; update with empty interface clears both sides to NULL; unknown node id -> 422 via the validator; reserved name -> 422 R4_NAME_RESERVED; no-ports workflow omits the key)
MAN-21 A port's lane reaches the workflow contract, and it is not the JSON Schema type. An interface entry's dataType carries the LANE (error, messages, json, a declared shape id — SCH-41/SCH-42) and its schema carries the JSON Schema fragment; the two are separate vocabularies and the entry has always had a slot for each (fdnpm types dataType as NodeDataType and renders it through the lane-vocabulary picker). dataType previously held the fragment's type, so an error port answered object and a messages port answered array — the editor's own lane chip contradicted the port the entry was bound to. The lane is resolved once, at snapshot-build time, against the served vocabulary (NodeMetadataResolver::resolvePortLane(), the same composition the editor's ports get) and pinned into the fragment as x-data-type: pickContractKeys() strips the plugin's raw declaration with the other annotations and the builder stamps the RESOLVED answer back, so the snapshot holds a lane known to be served rather than one merely asked for, and a caller sees the lane the workflow was published against rather than a live one that could drift. WorkflowInterfaceProjection is a pure reader of it; a pre-MAN-21 snapshot carries no stamp and falls back to the derived half alone (PortDataType::fromSchemaType()), which is narrower than the truth and never wider, and self-corrects on the workflow's next rebuild (MAN-18 rebuilds on a port-list difference, not a fragment one). The emitted schema is the STRUCTURAL contract only — x-data-type, title, description, examples and property-level required are stripped on the way out because the entry states each of them itself. Boundary effect (SCH-40): none. The launch doors check values through WorkflowInputChecker against the stored snapshot via SchemaFragmentChecker, which reads type and enum only and never consults this projection; what changed is what a reader of the contract is told, not what a caller must pass. Still ignored on write per MAN-20 — the editor's lane picker can therefore set a dataType the server discards on the next read, since the lane is derived from the bound inner port and an author-chosen override has no server column (A2.5) Enum/PortDataType.php (fromSchemaType()); NodeMetadataResolver.php (resolvePortLane()); WorkflowSchemaBuilder.php (build(), the x-data-type stamp); Utility/WorkflowInterfaceProjection.php (entries(), ENTRY_OWNED_KEYS); flowdrop_workflow.schema.yml (schema_fragment.x-data-type) WorkflowInterfaceApiTest::testDeclaredLaneReachesTheContract (a declared messages over an array and a derived json over an object, on one node, plus the snapshot stamp), ::testLegacySnapshotFallsBackToTheDerivedLane, ::testCreateWithInterfaceMapsAndRoundTrips (both keys in the round-trip)

GR-LANG — expression sub-languages

The mini-languages authors embed inside nodes to transform data — and what happens when an expression is wrong.

Authors write these in: DataMapper output value (default expression_language), DataExtractor path (default jsonpath), DataShaper mapping values (default property_path), PromptTemplate template (raw Twig, hard-wired), trigger mappings (JSONPath/property-path + literal escapes). Engine selection per instance via reserved engine param.

ID Rule Impl Pinned
LANG-1 Extraction engines treat context as data-to-query; transformation engines as a variable map ExpressionEvaluatorInterface.php:34-37 lang-context-family-split.yml
LANG-2 ExpressionEvaluatorAwareTrait::getEvaluator() wraps any plugin-manager failure as \RuntimeException("Unknown expression engine '<id>': …") before any evaluator runs. On a real node this is a second line of defence: DataMapper/DataExtractor/DataShaper declare engine with enum: getAvailableEngineIds(), so an out-of-set value is refused earlier — at save by WorkflowValidator (CONFIG_INVALID) and at resolution by CFG-10 as ParameterValidationException. The \RuntimeException is what a caller bypassing the schema (direct trait use, tool invocation) sees ExpressionEvaluatorAwareTrait.php:46-57; enums at DataMapper.php:153, DataExtractor.php:207, DataShaper.php:420 lang-unknown-engine.yml (drives the production trait directly)
LANG-3 Empty expression at the evaluator level: expression_language/twig → NULL; property_path/jsonpath → context unchanged (divergent polarity!), and all four validate('') TRUE. No author can observe that divergence through the shipped nodes: both consumers short-circuit before it — DataMapper maps an empty output expression to NULL whatever the engine (DataMapper.php:121-124), and DataExtractor returns {data: <input>, success: TRUE, is_jsonpath: FALSE, match_count: 0} for an empty path, resolving the evaluator but never calling evaluate() (DataExtractor.php:104-116). The polarity is an evaluator-API contract, not node-visible behaviour 4 evaluators + the 2 consumer short-circuits lang-empty-expression-polarity.yml (evaluator level) + DataMapperTest::testEmptyExpressionReturnsNull and DataExtractorTest::testEmptyPathShortCircuitsBeforeTheEvaluator (node level)
LANG-4 Non-array context wrapped as ['data' => ctx] for EL/Twig 2 sites lang-scalar-context-wrapping.yml
LANG-5 Evaluator validate() has exactly ONE production call site — the save-time WorkflowValidator (R11); getSyntaxHint() still has zero. A second validate() caller or a vanished validator call goes red so the surface never drifts silently again grep-verified ExpressionValidateCallSiteTest (2 tests)
LANG-6 EL: SyntaxError → "Expression syntax error: …" (a missing variable IS a SyntaxError to Symfony EL, so it takes this arm); any other throwable → "Expression evaluation error: …" — either way it throws ExpressionLanguageEvaluator.php:66-82 ✅ 3 fixtures (lang-el-*.yml, lang-missing-key-divergence.yml)
LANG-7 Twig: a missing variable renders silently as ''; the result is always a string, HTML-escaped unless piped through \|raw. Twig is not failure-free: SyntaxError\RuntimeException('Twig syntax error: …') and RuntimeError\RuntimeException('Twig runtime error: …'), both chaining the original. No other throwable is caught (LoaderError and \Error propagate raw), so the failure surface is exactly those two arms TwigEvaluator.php:88-113 ✅ 5 fixtures (lang-twig-*.yml incl. lang-twig-syntax-error.yml, lang-missing-key-divergence.yml)
LANG-8 property_path: never throws; unreadable path → NULL (indistinguishable from legit NULL) PropertyPathEvaluator.php:59-74 ✅ 3 fixtures (lang-property-path-*.yml, lang-missing-key-divergence.yml)
LANG-9 jsonpath never throws, and there are three distinct outcomes, not one: (1) a $-query that compiles but matches nothing returns the caller's default (NULL through the evaluator) with no log entry; (2) any \Throwable from compiling or running the query logs warning: "JSONPath query failed for '<path>': …" and returns the default; (3) on the non-$ property-path fallback route, AccessException/UnexpectedTypeException return the default silently, and only other throwables log "Property path extraction failed …". Absence of a warning therefore does not mean the path was valid. On that fallback route the caller's default is also not always reached: PropertyAccess ignores invalid indices, so a merely absent array key is "readable" and yields NULL (the LANG-8 conflation), while the non-array/non-object guard does return the default JsonPathService.php:234-239 (no-match), :249-255, :285-294 lang-jsonpath-broken-query.yml + JsonPathServiceTest logger spies: broken query warns once, a no-match $-query warns never, and a property-path miss is silent
LANG-10 EL validate() pre-declares all identifiers → unknown variables validate OK, throw at runtime :94-100 lang-el-validate-predeclares-unknowns.yml
LANG-11 property_path validate() delegates to the same PropertyAccess parse evaluate() feeds (after dot→bracket normalization), so its accept-set is a superset of what evaluate() can resolve (e.g. [user-name] validates AND resolves); only paths PropertyAccess itself cannot parse (unclosed bracket, empty segment) validate FALSE. R11's LANG-11 gate is cleared :82-96 lang-property-path-validate-evaluate-agree.yml
LANG-12 jsonpath validate() returns TRUE unconditionally for non-$ paths JsonPathEvaluator.php:97-102 lang-jsonpath-validate-non-dollar-unconditional.yml
LANG-13 Twig validate() is compile-only; runtime-only failures validate TRUE TwigEvaluator.php:124-130 lang-twig-validate-compile-only.yml
LANG-14 Path is JSONPath iff trimmed string starts $; else property path JsonPathService.php:198-200
LANG-15 Arity collapse: single match unwrapped unless path is "multi-value" (sniffed for [*] [? .. [n:m]); a filter matching one item stays an array :243-247,376-383
LANG-16 String context auto-json_decoded; non-JSON string passes through; "null" decodes to NULL :328-338 ✅ 3 fixtures (lang-jsonpath-*-context*.yml)
LANG-17 Dot→bracket normalization duplicated in THREE classes (JsonPathService, PropertyPathEvaluator, plus undocumented copy in GetWorkflowData) — currently in agreement, drift risk 3 sites ✅ (service side only)
LANG-18 applyMappings() literal escapes: literal:x, "x", 'x' bypass extraction (trigger mappings only) :178-193,394-422
LANG-19 extractAll() on property path wraps single result; NULL and no-match collapse to [] :127-131
LANG-20 exists('') = TRUE; exists() on scalar+property-path = FALSE :149-164
LANG-21 DataMapper catches only \RuntimeException from evaluate() and re-wraps it as \RuntimeException("Expression error in dynamic output '<port>': …"), aborting process() so no partial output map is returned. Unlike DataShaper/DataExtractor (which catch \Throwable), a non-\RuntimeException throwable — e.g. a \TypeError from an engine — escapes unwrapped, without the port name, and because both orchestrators catch only \Exception it is not converted into a node failure / error-edge route at all DataMapper.php:126-136 (catch (\RuntimeException) vs DataShaper.php:224/DataExtractor.php:121 (catch (\Throwable); AbstractOrchestratorBase.php:309, SynchronousOrchestrator.php:595 (catch (\Exception) DataMapperTest::testExpressionErrorPropagates (the wrapped arm) + ::testNonRuntimeExceptionEscapesUnwrapped (the narrow catch, on record as a decision)
LANG-22 DataExtractor: any evaluator throwable is re-wrapped as \RuntimeException naming the path and engine → the node FAILS → error-edge routing (same policy as DataMapper, per LANG-29). Scope is expression errors only (invalid path, engine error): the configured default never applies on an error, but it is untouched for the soft-miss case (LANG-24), which still substitutes it and still succeeds. What went away is the {success: FALSE, error} envelope an error used to produce (error was never a declared output port) — not default-on-no-match DataExtractor.php:118-130 DataExtractorTest (2 cases)
LANG-23 DataExtractor first-match unwrapping keys off the ENGINE: only jsonpath running a $-query reduces an array result to its first match (extract_all FALSE); property_path (any non-jsonpath engine) delivers array results whole, even for $-prefixed paths, and the jsonpath property-path fallback route (non-$) never unwraps. The is_jsonpath output flag remains a $-prefix sniff report — a routing/reporting hint independent of the engine :105,136-153 ✅ (3 cases)
LANG-24 DataExtractor: a NULL extraction is a soft-miss, never an error — the configured default is substituted and success = FALSE means "no non-NULL match found"; the node succeeds. Extraction engines cannot distinguish a legit stored NULL from a no-match (LANG-8), so both take the default. Only NULL trips the soft-miss: FALSE/0/"" are delivered as success. This is live shipped behaviour, not a legacy remnant — the default config value and the success output port both survive LANG-29 unchanged, and the port's own description still reads "FALSE means the configured default was delivered" (:228-230) :132-168 ✅ (2 cases)
LANG-25 DataShaper resolves five sentinels before the engine is consulted: _NOW_ → an ATOM-formatted timestamp, _NULL_ → NULL, _EMPTY_ARRAY_[], _EMPTY_OBJECT_new \stdClass(), and the _LITERAL: prefix → the remaining substring verbatim. Every sentinel increments mappedFields, including _NULL_resolveSpecialValue() returns the wrapper ['value' => NULL], which is itself non-NULL, so the counter is bumped before the value is unwrapped; only a NULL from an expression that actually reached the engine goes uncounted. Any throwable from a source expression that does reach the engine is re-wrapped as \RuntimeException("Expression error in Data Shaper source '<expr>' (engine '<id>')…") → node fails → error-edge routing (LANG-29) DataShaper.php:185-196 (counting), :216-232 (wrapping), :243-263 (sentinels) DataShaperTest (2 cases; the short-circuit case covers all five sentinels and pins mappedFields)
LANG-26 PromptTemplate: any Twig\Error\Error from rendering (SyntaxError/RuntimeError/LoaderError) is caught and re-wrapped as \RuntimeException chaining the original → the node FAILS → error-edge routing (LANG-29 policy) PromptTemplate.php:131-145 PromptTemplateTest
LANG-27 SwitchGateway does not evaluate expression: the first branch whose value is ===-identical to it wins (no coercion, no engine) and its name is emitted as the single element of __active_branches__. No match falls back to default_branch. The guard is empty($activeBranch), not a NULL check, so it ALSO throws InvalidNodeConfigurationException("No active branch found for expression: <expr>") when a branch DID match but its name is falsy ("0", ""). The input_value output is always NULL — the matched value is never echoed SwitchGateway.php:55-85; match loop :61-66, empty() guard :72-74, NULL input_value :78 SwitchGatewayTest (6 cases, incl. a matching branch named "0" that throws and the NULL input_value)
LANG-28 IfElse declares six operators (equals, not_equals, contains, starts_with, ends_with, regex) as a schema enum only — there is no IfElse-specific validator. The enum is enforced generically by WorkflowValidator::validateConfigValue() at save (CONFIG_INVALID) and by CFG-10 at resolution. There is no save-time check that matchText is a compilable regex: an uncompilable pattern surfaces at runtime as InvalidNodeConfigurationException('Regex execution failed for pattern: …'), and an operator reaching process() outside the enum throws InvalidNodeConfigurationException('Unknown operator: <op>'). With the default caseSensitive: FALSE both the input and the pattern are strtolower()ed, so a regex comparison silently loses case-sensitive classes ([A-Z] becomes [a-z]). TRANSITIONAL (OPEN-19): pattern-lowercasing is a transitional pin — the target implements caseSensitive: FALSE for regex via the engine's i modifier, never by rewriting the pattern IfElse.php:155-158 (pattern lowercased), :176-185 (both throws), :207-220 (declarative enum), WorkflowValidator.php:850-857 IfElseTest (3 tests: unknown operator, uncompilable pattern, and the lowercased-pattern outcome)
LANG-29 Uniform expression error policy (← OPEN-8): an expression error — a path the engine cannot evaluate, an engine throwable — is wrapped in \RuntimeException → node fails → error-edge routing. IMPLEMENTED across all four expression-consuming processors: DataMapper (LANG-21), DataExtractor (LANG-22), DataShaper (LANG-25), PromptTemplate (LANG-26). Scope is errors, not empty results: an expression that evaluates cleanly and finds nothing is not a failure, so DataExtractor's default + success: FALSE soft-miss (LANG-24) is deliberately untouched by this policy — "expression errors throw and route to the error edge" is the whole of the change, and default-on-no-match is still shipped behaviour. Leniency for genuine errors, if ever wanted, becomes an explicit node config option — never a default. Uniformity holds for \RuntimeException everywhere, and for any \Throwable only in DataShaper and DataExtractor — DataMapper's catch is narrower (LANG-21), and since both orchestrators catch only \Exception, an \Error/\TypeError from an engine escapes the policy entirely policy, spans 4 nodes ✅ via the per-processor pins (DataShaperTest/PromptTemplateTest bind LANG-29 directly; DataMapper/DataExtractor pinned under LANG-21/22)

Part II — RT: the runtime execution contract

Everything about how a valid workflow behaves when it runs. If a rule here breaks, execution surprises the author — the top-priority failure mode.

RT-CMP — compilation & ordering (cross-ref docs/development/execution-dependency-rules.md Rules 1–4; fixtures topology-*)

Turning a stored workflow into an executable plan: which nodes will run, in what order, and which cycles are legal.

ID Rule Impl Pinned
CMP-1 Compile = DependencyGraph (all edges) + ExecutionGraph (runnable nodes) + NodeMappings, produced in this stage order inside one try-block: structure preconditions (CMP-3) → dependency graph → cycle detection (CMP-5) → tool wiring (CMP-9/10) → execution graph (CMP-6/8) → node mappings (CMP-11). Any \Exception from any stage — a CompilationException from an inner stage included — is logged at error and re-thrown as a NEW CompilationException with message 'Workflow compilation failed: ' . $previous->getMessage(), code 0, and getPrevious() set to the original. So a caller always sees one exception type, the inner precondition/cycle message arrives prefixed exactly once, and the cause stays reachable WorkflowCompiler.php:110-164 (wrap :152-163) WorkflowCompilerTest (3 artifact cases + ::testInnerFailureIsRewrappedWithTheCausePreserved)
CMP-2 Run-path re-enrichment from the live node types (= SCH-29) is conditional, not a property of compilation: it happens only on compile()'s array entry point and only when a NodeMetadataResolver was injected; handing compile() a pre-built WorkflowDTO skips it entirely. Every DTO-building caller therefore enriches first — SynchronousOrchestrator:215-219 and JobGenerationService:113-118 do, and StateGraphOrchestrator:1690-1694 (which builds a DependencyGraph directly, never calling the compiler) does too. Without a resolver the (attacker-controllable) stored metadata cache is trusted as-is. After enrichment the array is normalized through WorkflowDTO::fromArray() (STORE-10). TRANSITIONAL (OPEN-19): enrichment being conditional on wiring is a transitional pin — the target makes re-enrichment a property of compilation itself (every entry point, resolver mandatory), so no caller can compile stale stored metadata by construction :90-104 and those callers WorkflowCompilerRunEnrichmentTest (4 + ::testCompilingAPrebuiltDtoSkipsEnrichment, which pins the DTO-input bypass as contract)
CMP-3 Preconditions run first inside the compile try-block, in this order, the first failure throwing CompilationException: empty id → Workflow must have an ID; zero nodes → Workflow must have at least one node; then per node in definition order, missing id → All nodes must have an ID, missing type → Node {id} must have a type. Each reaches the caller prefixed by CMP-1's wrap :175-194 ✅ (4 cases)
CMP-4 Edge type derived from handles, never declared (= EDGE-3..6) DependencyEdge.php:23-137
CMP-5 Cycle detection excludes tool_availability/loopback/agent_result; loopback+tool cycles legal; any other cycle → hard error. SG-16 depends on this: rejecting every other cycle is what makes the forward graph a DAG, which is why loop extent is two reachability sweeps instead of an SCC pass. Loosening this rule would silently make loop membership ill-defined :208-273; excluded set EdgeType::isExcludedFromExecution() ✅ (5 cases)
CMP-6 Exclusion precedence: NonExecutable always out; tool-only out; rootless-in; loopback-only-in out; edge-free node IS executed. The loopback-only arm bounds SCH-32: the reserved port exists on every re-enterable node, but a node reached only by loopback edges is still excluded, so a loop head needs a forward entry edge (which is how ForEach is wired) :494-561 ✅ (3 cases)
CMP-7 Trigger deps beat data deps for ordering (executionDeps = triggerDeps ?: dataDeps) :410-430 topology-trigger-priority-over-data.yml
CMP-8 Order = Kahn toposort; ties broken by definition order (artifact, not id) :663-697 ✅ (independent roots + tied fan-out siblings; the test records this as an artifact authors may not lean on)
CMP-9 Tool-name uniqueness per consumer over flattened leaf set at compile time; collision → CompilationException; passthroughs exempt/checked at consumer :290-362 ✅ (3 + ToolBox suite)
CMP-10 Tool consumer must implement ToolsAwareInterface or compile error :356-361
CMP-11 NodeMapping carries both processorId and nodeTypeId; runtime loads entities by nodeTypeId :734-765

RT-ERR — node error semantics

What happens when a node fails: how failure is signalled, which failures take the error wire, which fail the whole run, and how retries work.

ID Rule Impl Pinned
ERR-1 \RuntimeException from a processor is caught → error-status Output; never propagates (the error-edge channel). The real-time broadcast for a converted failure is completed, not failed — the node finished; the error verdict rides the Output NodeRuntimeService.php:228-239 NodeRuntimeServiceErrorChannelTest (3)
ERR-2 RetryableExceptionInterface survives type erasure as error_retryable metadata: when the caught \RuntimeException implements the marker the error Output carries error_retryable = TRUE; otherwise the key is absent (getMetadata('error_retryable') returns NULL, never FALSE). The absence is load-bearing — the retry gate is a strict !== TRUE check, so anything other than the literal TRUE marker is treated as non-retryable marker :236-238; strict gate AbstractOrchestratorBase.php:364-368 ✅ 2 tests (marker present/absent, asserted on the Output)
ERR-3 Non-RuntimeException \Exception escapes, node FAILED, re-thrown wrapped — fails the run, does NOT take the error edge :326-344 ✅ 2 tests (wrapped re-throw + cause; no Output exists, so nothing can route)
ERR-4 InterruptExceptionInterface re-thrown unchanged after event dispatch + interrupted status :276-311 ✅ 2 tests (same instance re-thrown, event payload, no subscriber)
ERR-5 WorkflowStopException extends \Exception, so it bypasses the inner error-Output catch; NodeRuntimeService catches it in a clause placed before the generic \Exception clause and re-throws the SAME instance after broadcasting real-time completed for the node — were the ordering reversed the stop would be re-wrapped into a RuntimeException and fail the run. The orchestrator then marks the job COMPLETED (never FAILED), records getResult() under the node's result key, abandons downstream and unscheduled sibling jobs, and finishes the run with status completed catch + COMPLETED broadcast :313-326 (the generic \Exception catch begins :327); job COMPLETED StateGraphOrchestrator.php:1473-1487, record + abandon :807-814 ✅ 3 tests (incl. the same-instance escape + [RUNNING, COMPLETED] broadcast at the runtime boundary)
ERR-6 After unexposed outputs are filtered and the unified output port is composed, assertSerializableOutput() rejects any leaf that is not scalar/null/array/stdClass/JsonSerializable with NonSerializableOutputException naming the node id, the plugin id, the dotted path and the offending type. Because it is a \RuntimeException thrown inside the processor try block it becomes an error-status Output carrying that message with no error_retryable metadata (so it never retries and follows the normal error-edge-or-fail path). ⚠ Because the check runs after filtering, a non-serializable value in an output the node type does not expose is dropped and never policed — a real gap; the tests pin today's behaviour and must change with the fix. TRANSITIONAL (OPEN-19): the target runs assertSerializableOutput() on the full pre-filter output, so an unexposed non-serializable leaf is policed exactly like an exposed one order :200,221; walker + message :383-432 ✅ 7 helper cases + 2 through executeNode() (exposed value → non-retryable error Output; unexposed value → success, no exception)
ERR-7 Error-edge routing (pipeline engines): error Output + ≥1 error edge → job FAILED + error_routed, output becomes {error:{message,code,node_id,retryable}} — plus an optional details key, present only when the Output carries error_details metadata (a structured verdict such as the confirmation gate's decline record, see RT-GATE-4) — and the run continues AbstractOrchestratorBase.php:234-242,899-934 (payload shape shared via AbstractOrchestratorCore::buildErrorEdgePayload) ✅ 2 suites (+ the details arm via ConfirmationGateRuntimeTest)
ERR-8 No error edge → NodeProcessingException, job FAILED without error_routed, run fails :249-251,317-330
ERR-9 A pipeline whose only FAILED jobs carry error_routed finishes with status completed. The verdict is computed from hasUnhandledFailedJobs() — TRUE iff some FAILED job's metadata lacks error_routed === TRUE — never from hasFailedJobs(); one unrouted failure alongside any number of routed ones still fails the run. A routed-failed job also dispatches JobCompletedEvent (the generic "job finished" channel), so per-node subscribers see it and discriminate on getStatus(). Failed tool-invocation jobs always carry the flag: a tool failure is delivered to the consumer as a model-recoverable ToolResult, i.e. handled by construction (see RT-GATE-6) :962-969; routed dispatch :938; tool jobs ScopedToolInvoker::finalizeToolJob() ✅ 2 tests (routed-only run completes; a routed and an unrouted failure together still fail the run) + the tool-job arm via ConfirmationGateToolPlaneTest
ERR-10 A routed-failed node satisfies only error edges (evaluated before trigger/data, OR semantics), and two staleness rules apply to them, not one: the older newest-job check — a routed failure superseded by a newer job for the same node no longer activates its handler — and the round comparison of SG-19, which stops a failure routed in round N−1 re-activating a handler that already handled it while the wavefront has not reached the source. Success-path successors are never made ready by a routed failure map JobGenerationService.php:789-801; arm :878-932 (newest-job :887-889, round :904-913) ✅ 7 tests (JobGenerationServiceTest: routed/unrouted, precedence over the trigger rule both ways, OR, newest-job staleness, plus ::testRoundBehindRoutedFailureDoesNotActivateAnInLoopHandler and ::testAnOutOfLoopRoutedFailureStillActivatesAnInLoopHandler for the round arm and its anti-deadlock bound)
ERR-11 Retry: in-place, immediate, no backoff, opt-in (max_retries default 0), only error_retryable, counter persisted; idempotence is the author's contract AbstractOrchestratorBase.php:357-383 ✅ 3 tests
ERR-12 (collapsed into ERR-13) The direct sync record-and-continue divergence is removed: the orchestrator inspects Output::isError() and applies ERR-13 like every other strategy SynchronousOrchestrator.php:404-411 — (superseded by ERR-13)
ERR-13 All orchestrator strategies route error Outputs identically (← OPEN-1): error edge present → the node's outputs are replaced by the shared {error:{message,code,node_id,retryable}} envelope and the run continues (ERR-7); no error edge → the run fails (ERR-8). Direct sync included SynchronousOrchestrator.php:404-411,850-902 ✅ 2 tests (SynchronousOrchestratorErrorRoutingTest)

RT-ORC — orchestrator strategies

The four execution engines and the behavior they must share. A workflow should mean the same thing no matter which engine runs it.

ID Rule Impl Pinned
ORC-1 Four strategies, each returning its namespaced plugin id verbatim from getType(). The OrchestratorHelperInterface constants are the single source of those strings: SYNCHRONOUS = flowdrop_runtime:synchronous, SYNCHRONOUS_PIPELINE = flowdrop_runtime:synchronous_pipeline, ASYNCHRONOUS = flowdrop_runtime:asynchronous, STATEGRAPH = flowdrop_stategraph:stategraph OrchestratorHelperInterface.php:37,45,53,62 SynchronousOrchestratorTest::testGetType, FlowDropRuntimeIntegrationTest::testOrchestratorType (sync + sync_pipeline), AsynchronousExecutionTest::testAsynchronousOrchestratorService, StateGraphOrchestratorTest — all four ids asserted as literals
ORC-2 Default resolution: presave→SYNC; else trigger settings; else flowdrop settings; else ASYNC. Each configured step must validate() before it is accepted, so an uninstalled engine cannot capture the default. Shipped: flowdrop=sync, trigger=async OrchestratorHelper.php:118-142 OrchestratorDefaultResolutionTest
ORC-3 Unknown type falls back with warning; missing fallback → \RuntimeException :73-100 OrchestratorDefaultResolutionTest
ORC-4 STATEGRAPH is the session/playground default, not global: SessionExecutionService passes it as the caller default to getInstanceFromSettings(), so it applies only when the session/workflow declared no engine, and the global chain (ORC-2) never resolves to it SessionExecutionService.php:693,728 SessionExecutionServiceOrchestratorDefaultTest + OrchestratorDefaultResolutionTest
ORC-5 Capability queries are definition-driven, and ONLY definition-driven: OrchestratorHelper::hasCapability() reads the Orchestrator attribute's capabilities list off the plugin definition — isStateGraph() = stateful, isSynchronous() = synchronous_execution && !stateful — and those verdicts gate real behavior (checkpoint storage selection SessionExecutionService::715, pipeline form branches). The former method API (supportsWorkflow()/getCapabilities()) was decorative — constant returns, zero production callers — and was deleted in 2.x; the attribute list is the single source OrchestratorHelper.php:186-209 OrchestratorHelperCapabilityTest
ORC-6 Common contract in a shared hierarchy: every strategy (direct sync included) extends the job-free AbstractOrchestratorCore (pipeline storage resolution, pipeline creation, interrupt handling, error-edge payload shape); the job strategies additionally share AbstractOrchestratorBase (NodeRuntimeService execution, retry, error routing, stop handling, signal polling, JobCompletedEvent, snapshots) both classes OrchestratorHierarchyContractTest (structural: the shared members are asserted to be inherited, not merely callable)
ORC-7 Direct sync walks ExecutionGraph::getExecutionOrder() once, top to bottom, carrying results in an in-request context, and never re-queues or re-enters a node — so direct sync refuses loopback workflows at launch. After compiling, before any node executes, it checks the compiled DependencyGraph for loop_back-classified edges (EDGE-5's authoritative classification, exactly the set CMP-5 exempts from cycle detection); ANY loopback edge present throws a typed LoopbackWorkflowException naming the offending edge id(s)/node ids and pointing at the pipeline engines that do iterate (ORC-9), with zero nodes executed. Applies INT-15's refuse-don't-degrade principle. (History: before this ruling — OPEN-13, 2026-08-18 — the engine silently ran the loop body once and reported success, an asymmetry SCH-32 made much easier to hit once every re-enterable node type could expose a loopback port.) refusal SynchronousOrchestrator.php:238 (refuseLoopbackWorkflow(), called right after :223-226's compile), typed exception Exception/LoopbackWorkflowException.php SynchronousOrchestratorTest::testLinearWorkflowExecutionOrder, ::testLoopbackWorkflowIsRefusedAtLaunch (typed exception, message names workflow/edge/node ids and the pipeline engines, zero nodes executed); engine-scoped, not workflow-scoped: SynchronousEngineConformanceTest/FixtureConformanceTest (topology-loopback-excluded.yml still launches and completes under both the SYNCHRONOUS setting — which a pipeline-entity launch coerces to the pipeline executor, PipelineExecutorResolver — and SYNCHRONOUS_PIPELINE proper); TriggerExecutorTest::testExecuteTriggerLoopbackRefusalIsCaughtAndLoggedLikeAnyFailure (the ORC-2 presave door swallows and logs the typed refusal like any other failure, no crash)
ORC-8 Direct sync trigger filtering: initialData['trigger_node_id'] selects one trigger; other trigger nodes dropped. Opt-in: without the key the compiled order is used verbatim (a manual/REST launch has no firing trigger to privilege), and non-trigger nodes are never filtered :242-271 ✅ 2 tests (SynchronousOrchestratorTest)
ORC-9 Pipeline engines are ready-job loops. getReadyJobs() flips every IDLE job whose dependencies are met to PENDING and persists it (markAsPending() + save()), then returns all PENDING jobs — including ones already pending from an earlier pass — sorted ascending by getPriority() (lower number runs first). A job with unmet dependencies is left IDLE, untouched. The engines call it repeatedly and stop when it returns an empty array: that empty return is what "quiescent" means, and re-entering a node across passes is what makes loops possible JobGenerationService.php:672-736 (promote+save :716-725, sort :730-733) JobGenerationServiceTest::testGetReadyJobs (ascending order + the promotion is reloaded from storage as pending + an unmet job stays idle) + fixtures
ORC-10 Budget: maxIterations (default 100) + maxExecutionTime break the loop → PAUSED + paused_reason → pending system Pause signal → resumable with fresh budget; re-entry clears stale reason. Three counters, deliberately distinct (cf. OPEN-9): this one counts scheduler passes and is shared by every loop in the workflow; SG-7/SG-16 count rounds per loop; SG-14 is the hard safety valve on the state's own iterationCount. All three read maxIterations; none of them is the others, and unifying them would collapse a resumable pause, a per-loop bound and a terminal failure into one. They also differ on resume, which is the point: the scheduler-pass budget resets (that is what makes a paused run resumable), while the per-loop round count is restored from job stamps and keeps accumulating — otherwise an exit-less loop could be resumed forever and no counter would ever stop it 2 orchestrators + ExecutionConfig StateGraphBudgetPauseResumeTest (3)
ORC-11 Terminal status precedence: unhandled-failure→FAILED(+SKIPPED rest); interrupted→PAUSED(no reason); ready-remaining→PAUSED(+reason); else COMPLETED. CANCELLED never reaches this chain by design: a cancel signal exits through the signal path before it, announced there (ORC-15). The two ladders are not identical: only the stategraph ladder has an explicit hasInterruptedJobs() arm — on the sync-pipeline engine an interrupt leaves the ladder entirely through the InterruptExceptionInterface catch (which pauses the pipeline and returns INTERRUPTED), which is why that ladder has three arms and stamps a reason only on the budget pause 2 sites SynchronousPipelineOrchestratorTerminalPrecedenceTest (5) + StateGraphTerminalPrecedenceTest (3, incl. the interrupted arm and failure-outranks-interrupt)
ORC-12 Async returns "queued" immediately; no in-request execution; optional snapshot seeding of pre-completed jobs AsynchronousOrchestrator.php:95-180 AsynchronousOrchestratorQueuedResponseTest (queued response + no in-request node execution; the snapshot seeding leg is INT-10)
ORC-13 Interrupt resume re-enters through the pipeline's declared orchestrator, never the plain sync loop InterruptResolvedSubscriber.php:128-140 ✅ parity test
INT-11 A terminal pipeline (COMPLETED/FAILED/CANCELLED, PipelineStatus::terminalStatuses()) refuses re-entry: the sync-pipeline orchestrator throws OrchestrationException before touching it (async worker + the signal API already refuse), so cancel is durable — a rerun/requeue can never resurrect a dead run and re-fire side-effecting nodes. Re-running always means a NEW pipeline seeded with the source run's input and stamped rerun_of (PipelineRerunService), used by both operator rerun paths (rerun confirm form; interaction form's re-run buttons) refuse SynchronousPipelineOrchestrator.php:149-162; new run PipelineRerunService.php:50-84; AsynchronousOrchestrator.php:296-306, PipelineSignalController.php:131-136,238-240 ✅ 5 tests (SynchronousPipelineOrchestratorTerminalRefusalTest, PipelineRerunServiceTest)
INT-12 Pause is NOT unconditionally held: auto-resume is gated on PausedReason::budgetReasons() (time_budget/max_iterations — async chunking depends on budget-pause→auto-resume→re-queue, maxIter 50 / maxTime 30.0); a HITL/operator pause carries no reason and holds until explicit human resume — absence-of-reason IS the HITL marker, do not add one. The async queue worker enforces both halves: it resumes and re-queues ONLY budget pauses; a reason-less pause is left paused. (REVERSES the decision-round T1-4 "pause holds identically on every engine" — that would stall every async run over 30s forever) AsynchronousOrchestrator.php:358-381,404-421; BudgetPauseSignalSubscriber.php:71-78; PausedReason.php docblock ✅ 4 tests (AsynchronousOrchestratorPauseHoldTest)
ORC-14 Job claim/promotion is atomic under concurrent workers. Today: getReadyJobs() promote+save() is an unlocked, untransacted read-modify-write and queuePipelineExecution() enqueues with no dedup — two workers can execute the same job twice (side-effecting nodes fire twice). Rule recorded, fix deliberately deferred — FlowDrop currently documents a single-worker constraint rather than claiming safe concurrent execution. The cheap :285 log-string-arg call hoist (state machine advanced to format a warning, redundant with :259) can land independently JobGenerationService.php:672-736 (save :722-723); AsynchronousOrchestrator.php:339-346; SynchronousPipelineOrchestrator.php:285 vs :259 DECIDED — the rule as written is not true today; the sentence is the target, not a description, so this row belongs in the denominator rather than excluded from it: it was marked N/A, which hid an unmet commitment behind the same glyph as a cross-reference. What a test can reach in one process is pinned — AsynchronousOrchestratorQueuedResponseTest::testEnqueueIsNotDeduplicated fixes the absence of enqueue dedup, so the day it gains one that test must change — but the concurrent double-claim through getReadyJobs() is not reproducible single-process and stays unasserted
ORC-15 A CANCELLED run is announced like every other terminal outcome: every cancel path dispatches PipelineCompletedEvent carrying CANCELLED, exactly once per cancellation. The in-loop signal path (AbstractOrchestratorBase::applyCancelSignal, shared by all three loop engines) dispatches it after ExecutionCancelledEvent with the live execution id and stamped execution_time_us; the entity-level cancel paths (pipeline interaction form, trigger overlap Cancel/Terminate, session stop) announce via PipelineCancellationAnnouncer (execution id = pipeline id, duration from entity timestamps). PipelineStatusSubscriber handles CANCELLED explicitly (warning card, "cancelled" tag) instead of falling into the green-"completed" default =>; the sub-workflow completion sentinel resolves with status cancelled and empty outputs (WorkflowNode::resume() documents that as the empty-branch case) AbstractOrchestratorBase.php:454-512; PipelineCancellationAnnouncer.php; PipelineStatusSubscriber.php:61-68 ✅ 4 tests (SynchronousPipelineOrchestratorCancelAnnounceTest, PipelineCancellationAnnouncerTest, PipelineStatusSubscriberTest)

RT-BR — branching / gateways

How a gateway node decides which outgoing paths stay alive, and how everything downstream honors that decision.

ID Rule Impl Pinned
BR-1 A branch edge is gated at all only when all three preconditions hold: the edge leaves a NAMED source port, the source emitted a non-empty active_branches, and that port is a branch port rather than a value port (BR-2/BR-4). Given those, the edge is followed iff trim(strtolower($portName)) is in the parsed active_branches list — both sides trimmed and lower-cased — and otherwise not followed. In every other case the edge is followed, which is why the bare "iff" reading is false on its own: BR-2's three arms all return TRUE before membership is ever consulted gate BranchActivation.php:61-83; parse :169-189 LoopbackBranchGatingTest (3)
BR-2 Three deliberate TRUE cases, checked in this order and each returning before the membership test: an unnamed port ($branchName === ''); a source that emitted no (or an empty) active_branches; and a port that is a value port. Value-port authority is the source's configured branches, matched case-insensitively (strcasecmp); when the source declares no branches at all there is no authority and the fallback is the payload — a port whose name is a key in the source's output (array_key_exists, so a falsy or NULL value still counts) is taken for a value port. ⚠ That fallback is wrong in both directions: it cannot see a configured branch the run emitted no key for, and it misreads a branch that shares a name with an emitted output key. It survives only for sources that declare nothing better arms :66-76; isValuePort() :115-131 ✅ (2)
BR-3 active_branches = list of strings; bare string is ONE name verbatim (no comma split); non-strings dropped; survivors trimmed + lower-cased :169-189 BranchActivationTest::testParse + 2 isActive rows
BR-4 Branch-port authority = the node's configured branches [{name,value}]; entries without a non-empty string name are dropped by normalize() before anything else. Matching an outcome to a branch is type-aware, never a loose cast: a boolean outcome matches only a branch whose configured value denotes a boolean — a real bool, int 0/1, or the strings true/false/1/0 (case-insensitive) — a string-vs-string pair compares with strcasecmp, and anything else is identity (===). When no branch matches, nameForValue() returns the caller's fallback literal ('true'/'false' for BooleanGateway/IfElse, the picked value for AorB). ⚠ So a gateway whose branches are configured to values that denote no boolean still emits a branch name matching no configured port, and every downstream edge is gated off — silently. TRANSITIONAL (OPEN-15): silent whole-subtree gate-off is a transitional pin — the target makes an unmatched outcome LOUD: the gateway either declares a default branch port that receives it, or the node fails with an error Output (error-edge routable, ERR-7); it never emits a branch name no port carries drop GatewayBranches.php:41-58; fallback :92-99; match :117-131; bool denotation :146-164; caller BooleanGateway.php:60-64
BR-5 Branch gating applies regardless of target port kind (trigger or data) JobGenerationService.php:1077+ ✅ 2 stategraph suites
BR-6 Staleness gates delivery, not only branch decisions, and the bound is shared-loop membership. Two independent rules now sit side by side on every edge class, and a source gated by either does not satisfy. The older one asks whether a NEWER job for the source node already exists (isNewestJobForNode(), >=): a branch-emitting source vouches only for its own iteration, so with a newer job in flight its decision cannot activate a trigger, an error edge, or a data edge leaving a named port of a source that emitted active_branches — an unnamed port, or any source that emitted no active_branches, returns TRUE from isEdgeBranchActive() before that check is ever reached. The second (SG-19) asks whether the completed source is from a round BEHIND the consumer's on a loop they are both inside, and it applies to trigger, error and ordinary data edges alike, named port or not. ⚠ That second half is new and it withdraws this rule's former deadlock clause rather than reasoning around it. The clause was right when it was written — a universal staleness gate "would deadlock every loop" because nothing could tell an input that will never re-fire from one that has not re-fired yet — but that was downstream of a missing definition, not a property of the problem. Loop extent (SG-16) makes the distinction expressible, and SG-19's shared-loop intersection is it: an ordinary data edge from an in-loop source is now gated, and the same edge from an out-of-loop source is still never gated, so no loop is deadlocked. Consequence: a consumer with two or more re-executing inputs no longer runs on a MIX of rounds, and a consumer whose round-behind trigger used to fire it now does not run at all (SG-19, BR-7 — a visible behaviour change with no error attached). logCrossIterationDataRead() stays, rescoped: it is no longer the reason a crossed read is tolerated but the observability floor under the three cases the barrier cannot see — (1) the data ports of a trigger-driven consumer, which readiness never evaluates at all (DATA-4), (2) a pipeline whose jobs were generated before the loops stamp existed, since an unreadable stamp is deliberately never behind, and (3) a source that shares no loop with the consumer, where the older value is delivered on purpose. It emits one warning-level flowdrop_pipeline line per (job, source-job) pair when the consumer is loop-lineage-aware (is_loop_iteration TRUE) and the completed source job's loop_iteration (absent treated as 0) is strictly lower — pure observability, no change to readiness or promotion branch arm isEdgeBranchActive() JobGenerationService.php:1672-1704 (its three early TRUEs :1674-1689, newest-job gate :1695); round arm on data dataEdgeIsSatisfied() :1241-1253, on trigger :987-996 (beside the newest-job check at :965-971), on error :904-913 (beside :887-889); newest-id map latestJobIdsByNode() :817-824; isNewestJobForNode() :1742-1745; logCrossIterationDataRead() :1783, called from the OR-within-port loop at :1120 after a data edge is deemed satisfied ✅ the round arm on all three classes — JobGenerationServiceTest::testRoundBehindSourceDoesNotSatisfyMergePortInsideLoop (a Merge inside a loop, the shape B1 names as most exposed), ::testRoundBehindTriggerDoesNotFireAnInLoopConsumer, ::testRoundBehindRoutedFailureDoesNotActivateAnInLoopHandler — and the anti-deadlock arm on each: ::testAnOutOfLoopSourceStillSatisfiesAnInLoopConsumer, ::testAnOutOfLoopTriggerStillFiresAnInLoopConsumer, ::testAnOutOfLoopRoutedFailureStillActivatesAnInLoopHandler + ::testCrossIterationDataReadIsLoggedButNotGated (the logging floor) + LoopRoundTest::testIsBehind (the comparison, 18 rows) + StateGraphBranchSemanticsTest. ⚠ ::testStalenessDoesNotGateAnOrdinaryDataEdge is re-aimed and no longer evidence for the clause it was named after: its fixture is loop-free, so the shared-loop bound exempts it and it never failed while the barrier was being built. It now pins the empty-intersection arm and the surviving newest-job gate on the paired branch edge — not "an ordinary data edge is never gated", which is false inside a loop
BR-7 Direct sync: a node with ≥1 incoming trigger edge and no satisfied trigger is skipped, never executed, and the skip is announced three ways carrying the same fixed reason branch_not_active — a NodeSkippedEvent (reason:), a real-time ExecutionStatus::SKIPPED update carrying node_type_id + reason, and withNodeSkipped($nodeId, 'branch_not_active') on the snapshot (landing as metadata.skip_reason). The node id also lands in the response's metadata.skipped_node_ids and is counted in metadata.nodes_skipped. ⚠ The reason is fixed even when the real cause is an unexecuted non-gateway source — only the debug log spells it "branch not active" — so a consumer cannot tell the two apart. A node with zero trigger edges always executes, whatever its data sources did. This is also the line the staleness barrier refuses to cross, and the reason its two clauses have different verdicts (SG-19/SG-20). A trigger or error edge that stops being satisfied means the node was not meant to run, which already terminates correctly — the consumer stays IDLE and the end-of-run sweep collects it, exactly as an untaken branch always has — so gating those classes by round adds no failure mode and needs no liveness test. A data port that cannot be filled is the opposite: the node was meant to run and its declared input does not exist, so SG-20 fails it. The sweep is what makes the first half sufficient rather than merely convenient, and it collects IDLE and PENDING both (markRemainingJobsAsSkipped(), Trait/JobSkippingTrait.php:40-70), so a consumer gated after promotion is skipped too skip block SynchronousOrchestrator.php:326-359; decision :771-784 (zero-trigger arm :782-784); metadata :576-578 (interrupt path :510); snapshot :981-983 ✅ gateway tests + SynchronousOrchestratorTest::testSkipIsAnnouncedThreeWaysWithTheFixedReason (id in skipped_node_ids, event reason, snapshot skip_reason, real-time reason)

RT-DATA — data flow (cross-ref execution-dependency Rules 1–4)

How values travel across wires, when a node counts as ready to run, and who wins when several wires feed the same port.

ID Rule Impl Pinned
DATA-1 Delivery is port-to-port: both port names come from splitting the handle on -output- / -input- ({nodeId}-{direction}-{port}), and if either handle lacks its marker the extracted name is NULL and the edge delivers nothing — silently, with no warning. Presence of the value is array_key_exists($sourcePort, $sourceOutput), never isset(), on every builder — the StateGraph input builder included — so an explicitly emitted NULL IS delivered and reaches the resolver as Priority 1 (CFG-7). A source key that is absent delivers nothing at all and the target port falls through the config cascade to its schema default delivery AbstractOrchestratorBase.php:774; extraction Trait/PortNameExtractionTrait.php:31-43 MultiSourcePortResolutionTest (single-source delivery, wire NULL, absent source key, malformed handle both directions)
DATA-2 Last-executor-wins, one value per port, never an array; ordering by execution_order desc, then a source with an order ahead of one without, then node-id strcmp; collision logs warning. ⚠ the strcmp fallback is reachable only when neither source carries a positive order — equal positive orders make the comparator return 0 and PHP's sort is stable, so that tie resolves to edge/parent declaration order, not alphabetically. ⚠ the sort comparator AND the array_key_exists() delivery gate are now identical across the 3 input builders; the surrounding rules still drift (sync lacks error-edge handling and adds a whole-output merge fallback; StateGraph picks highest job id per node, unlike the others). 3 sites ✅ all 3 builders pin the same 4 comparator cases (MultiSourcePortResolutionTest, SynchronousOrchestratorMultiSourceInputTest, StateGraphMultiSourcePortResolutionTest) + topology-same-port-latest-wins fixture
DATA-3 Trigger edges carry no data 2 sites ✅ fixture
DATA-4 Readiness is evaluated in getReadyJobs(), which promotes a qualifying IDLE job to PENDING and saves it. Data-port readiness applies only to a job with zero incoming trigger edges (and only after the error-edge arm): with any trigger edge present, readiness is OR over triggers and data ports are ignored entirely, so a node can legitimately run with an unfilled data port. Otherwise: data edges are grouped by the extracted target port name (falling back to the raw target handle when it has no -input- marker), each group needs ≥1 source that is completed, branch-active and not a round behind the consumer on a loop they share (OR within a port), and every group must be satisfied (AND across ports). That third conjunct is the staleness barrier (BR-6, SG-19) and it is the only part of this rule that changed: a completed, branch-active in-loop source whose round is behind no longer fills the port, and if no other source on the port can, SG-20 eventually fails the job rather than letting it wait forever. A job that has dependent_jobs but no incoming_edges metadata degrades to AND over all dependent_jobs. ⚠ The trigger-exclusivity arm is unchanged, and that is where the barrier's one remaining hole lives. Covering trigger edges by round fixes when a trigger-driven consumer fires, not what it reads: its data ports are never evaluated here at all, and at execution the StateGraph input builder takes the highest job id per source node (DATA-2), which can be a previous round's job. So such a node now fires on the right round and still reads the wrong one. Closing it means either reversing this arm — far beyond the barrier — or making delivery round-aware in the input builder, a different plane; it is filed separately and logCrossIterationDataRead() is its observability floor (BR-6). Recorded here, on the rule that causes it, so the gap is discoverable from the trigger-exclusivity clause rather than only from the barrier promote+save JobGenerationService.php:731-740; error arm :878-932; trigger exclusivity :936-1041; no-data-edge fallback :1059-1081; grouping :1083/groupDataEdgesByPort() :1200-1208; OR/AND :1092-1133; the staleness conjunct dataEdgeIsSatisfied() :1241-1253 ✅ 4 fixtures (incl. topology-trigger-priority-over-data for the scope clause) + JobGenerationServiceTest::testRoundBehindSourceDoesNotSatisfyMergePortInsideLoop (the conjunct) + ::testGatedTriggerConsumerIsNeverReportedUnsatisfiable (spec-registry: BR-7, BR-6, DATA-4 — the trigger arm keeps a consumer out of SG-20 entirely)
DATA-5 Loopback edges (*-input-loop_back) and tool edges are dropped at two points. buildDependencyGraph() skips both, so they create no dependent_jobs reference and validateDependencyGraph() never raises "Circular dependency detected in workflow" for them; and areJobDependenciesMet() buckets loopback edges out of the checks and continues past tool edges (is_tool flag OR an -input-tool target handle — ReservedName::PORT_TOOL — for jobs generated before the flag). Consequence: a node whose only incoming edges are loopback/tool has an empty dependent_jobs list and is promoted idle→PENDING on the first generation pass. That is required: a tool node never becomes a job (CMP-6), so it can never appear in the completed map and a gating tool edge would starve its consumer forever graph :445-456; empty-deps short-circuit :752-755; buckets :776-801 topology-loopback-excluded fixture (loopback) + JobGenerationServiceTest::testToolEdgeDoesNotGateConsumerReadiness (tool edge: no dependency recorded, consumer ready on pass 1)
DATA-6 initialData[nodeId] merges as fallback UNDER edge-delivered inputs (array_merge, so an edge-delivered NULL still wins); a non-array entry, or one keyed for another node, is ignored NodeRuntimeService.php:160-166 NodeRuntimeServiceInitialDataMergeTest (5)
DATA-7 In-node precedence = CFG-4..6 chain — (cross-reference; GR-CFG carries the tests)
DATA-8 Output stripping at the production point (= EXPO-11..14) — (cross-reference; GR-EXPO carries the tests)
DATA-9 Unified I/O output port composes exposed outputs only, excluding _-prefixed and trigger; an output key the node type does not configure defaults to exposed (kept) NodeRuntimeService.php:823-851 NodeRuntimeServiceUnifiedOutputCompositionTest (9 rows) + EXPO-14 ordering test
DATA-10 tool_availability projects into ToolBindings (flattened through ToolBox), not a data delivery; passthrough cycle → ToolWiringException ToolProjector.php:65-122 ✅ (4 + flattener)
DATA-11 Model-facing tool schema omits pinned-in-config and edge-fed params :126-135 ✅ (3)
DATA-12 NodeRuntimeService calls setTools() only when the projection is non-NULL and the processor instance is ToolsAwareInterface — a non-consumer with tools wired receives none at runtime (at compile time that wiring is already a validation error, ToolConsumerValidator; a ToolPassthroughInterface box is accepted there and forwards instead). Under StateGraph the projection is rebuilt per job from the workflow's stored graph data — re-enriched via NodeMetadataResolver, with the derived DependencyGraph memoised per workflow id — rather than read from a compiled pipeline, which is what lets a ToolsAware node reached again inside a loop get its tools on every iteration; with no projector wired the tools argument is NULL and nothing is injected inject NodeRuntimeService.php:151-153; project per job StateGraphOrchestrator.php:1350-1363; graph rebuild + cache :1677-1698; validator ToolConsumerValidator.php:55-61 ToolBoxTest (straight-line sharing + ::testToolsAreInjectedOnEveryLoopIteration, a ForEach cycle where every iteration's consumer job invokes its tool)

RT-INT — pause / resume / interrupt / snapshots

Stopping in the middle and picking up where you left off: pausing, asking a human, resuming, and saved progress.

ID Rule Impl Pinned
INT-1 Node interrupt → job INTERRUPTED + interrupt_id, pipeline PAUSED, response carries interrupt.* + partials. The response shape is part of the rule: status interrupted, metadata.interrupt.interrupt = the persisted interrupt's API array (id/nodeId/status), metadata.pipeline_id, and results holding every node that completed before the interrupt AbstractOrchestratorBase.php:274-303; pause + response SynchronousPipelineOrchestrator.php:356-374; payload InterruptHandlingTrait::handleInterruptException() ✅ 1 test asserting all four together (InterruptPauseAndResolutionContractTest::testNodeInterruptMarksJobPipelineAndResponseTogether)
INT-2 Interrupt entity persisted by subscriber; orchestrator reads from event/metadata, never the immutable exception 2 sites
INT-3 Resolution: find job by job_id, reset PENDING, resume only if pipeline PAUSED, via declared orchestrator; stateless run → no resume. The reset and the resume are separate steps in that order — the job goes back to PENDING unconditionally, and a non-PAUSED pipeline is then left strictly alone (never re-entered, so two executors cannot race). The stamped job_id is the primary lookup; the node-id pipeline scan is only the legacy fallback InterruptResolvedSubscriber.php:72-140 ✅ 3 tests (InterruptPauseAndResolutionContractTest: job_id lookup + resume, non-PAUSED skip with the job still reset, stateless no-op) + InterruptUpgradePathTest (the backfill that stamps job_id on pre-column rows: matching job only, uuid-referenced pipeline, unlinkable rows kept NULL, existing stamp never recomputed) + the declared-orchestrator half as ORC-13
INT-4 Resume ownership gate (all 4): bag has __interrupt_id__, processor Resumable, interrupt resolved, job_id matches; else fall through to process() (safe re-ask) NodeRuntimeService.php:185-191,462-496 ✅ 5 + 2 contract suites
INT-5 pollPipelineSignal() runs between job iterations — the in-flight job always finishes first — and dispatches PipelineSignalPollEvent; a NULL signal continues the loop. Cancel: markAsCancelled() + execution_time_us stamped on the execution context, then BOTH ExecutionCancelledEvent and a PipelineCompletedEvent carrying PipelineStatus::Cancelled — cancellation is announced like any other terminal outcome, so session notices and sub-workflow sentinels fire (= ORC-15) — returning a response with status cancelled and metadata orchestrator_type/signal_id/reason; the flowdrop_interrupt subscriber has already cancelled the pipeline's pending OUTWARD interrupts before it attached the signal. Pause: pause() + ExecutionPausedEvent only (no completion announcement), response status paused with the same metadata keys, and the signal row is left pending so the standard InterruptResolved path resumes it poll :404-437; cancel :453-511 (the PipelineCompletedEvent at :489-498); pause :527-566 ✅ 3 suites + 2 event-channel tests (StateGraphCancelCheckpointTest: cancel announces completion, pause does not)
INT-6 generateSnapshotFromPipeline() builds a WorkflowSnapshot carrying workflowId ("unknown", and an empty workflowVersion, when the pipeline has no workflow), the structural workflowVersion, the executionId, the caller-supplied status, one NodeSnapshot per job keyed by node id, initialInput = the pipeline input, and metadata {orchestrator_type, pipeline_id}. Per job, createNodeSnapshotFromJob() maps JobStatus→NodeSnapshot status one-to-one except skipped and cancelled, which BOTH become skipped, and any unrecognised status, which becomes idle; output is carried only for a COMPLETED job and error only for a FAILED one, alongside injected / executionOrder read off the job metadata and metadata.job_id. A missing workflowStateManager yields NULL before anything is read, and any \Exception during generation is caught, logged at error level and also yields NULL — snapshot generation never fails the run. (Cross-ref INT-13: the snapshot entity has always carried status, and the StateGraph state now persists its own outcome too, so the conversion between them no longer re-derives and no longer loses CANCELLED) :1053-1101, :1112-1136 ✅ 4 tests + SnapshotFromPipelineTest (status mapping incl. cancelled→skipped and unknown→idle, output/error gating, no-workflow fallback, throwing state manager → NULL)
INT-7 workflowVersion = 16-hex sha256 over structural data only (labels/positions don't change it) WorkflowStateManager.php:256-284
INT-8 validateSnapshot() returns a ValidationResult whose codes are the contract: ERROR_VERSION_MISMATCH — only when the snapshot's workflowVersion is non-empty, an empty version skips the check and is not an error; ERROR_UNKNOWN_NODE per snapshot node absent from the definition; ERROR_DEPENDENCY_NOT_MET per completed node whose dependency has no state or is neither completed nor skipped; and — warnings only, which never make the result invalid — WARNING_ORPHAN_NODE per definition node missing from the snapshot and WARNING_INJECTED_WITHOUT_DEPENDENCY per completed injected node that has dependencies. NB no engine calls it today — the resume gates that ship are INT-14/INT-15 (terminal/unconsumable snapshots refused). The pair survives the T3-4 dead-surface trim as a deliberately kept, not-yet-wired safety gate; wiring it into the snapshot-consuming engines is an open feature, not a bug :127-188, :193-250 ✅ 6 tests (version mismatch, unknown node, skipped-dependency valid, unmet dependency, orphan node is a warning that keeps the result valid, empty version bypasses the version check)
INT-9 EntitySnapshotStorage::save() is an upsert keyed by executionId: an existing entity with the same execution_id is loaded and overwritten in place via fromWorkflowSnapshot(), so one execution never accumulates two snapshot rows; otherwise a new entity is created from the DTO. Every query in this service deliberately runs with accessCheck(FALSE) — snapshot access is NOT enforced here, it is enforced at the API layer (see SnapshotAccessTest), and cron cleanup runs with no user context. cleanup($olderThan, $statuses) deletes entities with created < $olderThan AND, when $statuses is non-empty, snapshot_status IN $statuses, returning the number deleted (0 when nothing matches) upsert EntitySnapshotStorage.php:63-89; accessCheck(FALSE) at :158-159,211-212,247-248,281-282,317-318; cleanup :278-302 ✅ 16 tests (incl. both save() branches: same-execution overwrite with no second entity created, and the create branch)
INT-10 Async handover: queued run seeded from snapshot as pre-completed injected jobs. Each seeded job is COMPLETED carrying the snapshot's output plus the marker triple injected / injected_from_snapshot (source executionId) / injected_at, so the queue worker schedules only genuinely-remaining work and nothing mistakes a seeded row for work this run performed. A snapshot node with no matching job warns and is skipped; the count is reported as results.injected_jobs / metadata.injected_node_count AsynchronousOrchestrator.php:136-144, ::initializeJobsFromSnapshot() ✅ 2 tests (AsynchronousSnapshotHandoverTest)
INT-13 The checkpoint round-trip preserves the terminal-status distinction — CANCELLED included. The state carries its outcome as a status string (completed/failed/cancelled; NULL while the run is live) and the checkpoint payload persists it, because isComplete alone cannot tell cancellation from completion. A Cancel signal marks the state cancelled and writes the final workflow_end checkpoint, and the persisted status — never a re-derivation — becomes the WorkflowSnapshot status. Dual read (mandatory, no data migration): a payload written before the field carries no status key and keeps the legacy derivation byte-for-byte (error → failed, else isComplete → completed, else running), which is exactly the derivation that could not express CANCELLED. Thread continuity is unaffected: a terminal state seeding a NEW turn has its run state cleared (SG-12 — outcome and execution position), so a persisted cancellation never closes a thread — only explicit run resume is gated (INT-14) GraphState status + resolveSnapshotStatus()/withCancelled()/withRunStateCleared() (withOutcomeCleared() kept as a deprecated delegating alias — GraphState is @api, so the old name survives to 3.0.0); Checkpoint::create() metadata mirror; StateGraphOrchestrator::finalizeSignalledRun(), ::convertToWorkflowSnapshot(), ::initializeState() ✅ 14 tests
INT-14 A terminal snapshot (COMPLETED/FAILED/CANCELLED) is non-resumable (OPEN-10 part a): the snapshot-consuming engines throw OrchestrationException naming the snapshot and its status before any pipeline is created. The same gate covers SG-12's explicit checkpoint-resume arm (a checkpoint capturing a terminal GraphState refuses to restore, naming the checkpoint). Thread continuity is deliberately NOT gated: restoring a thread's latest checkpoint (typically the previous turn's workflow_end state) seeds a NEW turn with accumulated conversation state — sessions depend on it — and is not a resume of the finished run AsynchronousOrchestrator::orchestrate; StateGraphOrchestrator::orchestrate + ::initializeState ✅ terminal refused (3 statuses × 2 engines) + paused-passes + mid-run-checkpoint-passes + thread-continuity-ungated
INT-15 An engine that cannot consume a given snapshot MUST throw OrchestrationException naming the snapshot, never silently discard-and-restart (restart re-fires side-effecting nodes). The two sync engines (SynchronousOrchestrator, SynchronousPipelineOrchestrator) have no snapshot-seeding mechanism, so ANY attached snapshot is refused before a pipeline or job exists; "no snapshot present" stays a plain fresh start (OPEN-10 part b; narrows an earlier "replay completed nodes on every engine" conclusion, which is unachievable: toSnapshotData() emits no completion set and both loop engines deliberately re-run per iteration by newest-job-id) SynchronousOrchestrator.php / SynchronousPipelineOrchestrator.php orchestrate() entry guards ✅ refusal (both engines, nothing created/saved) + fresh-start-unaffected
INT-16 The signal API's refusal wording is contract, not prose. Clients cannot distinguish the three 409s by status code, so they classify by substring — @flowdrop/flowdrop's classifyRefusal() matches terminal → already-finished, already pending → duplicate, no active pause → nothing-to-resume, and maps everything else to a generic rejection. The three strings are therefore load-bearing and MUST keep their discriminating substrings: terminal → "Pipeline is in a terminal state (<status>); cannot signal." / "…cannot resume."; duplicate → "An inward signal is already pending for this pipeline"; no-pending-pause → "No active pause signal for this pipeline". Also fixed: the envelope is {success: false, error: <string>} (the error KEY is read, cross-ref API-1), success is 202 for a newly created Cancel/Pause signal and 200 for a Resume that resolved one. A reword that drops a substring silently reclassifies the refusal — the operator gets the wrong explanation and nothing errors. Absence is permission-scoped, not hidden outright: a missing pipeline answers 404 to a caller holding the blanket permission (cancel any/pause any/either admin) — who may already act on any pipeline, so existence is no secret from them and an honest 404 beats a 403 that reads as a permissions fault — and an opaque 403 to everyone else, indistinguishable from "exists but is not yours". The split is deliberately gated because these routes carry NO _permission requirement (any authenticated user reaches the access callback) and loadPipeline() accepts the sequential entity id as well as the UUID, so an ungated 404/403 difference would be an enumerable existence oracle over the pipeline table — the one API door where this matters, since the others are UUID-addressed. The access callback returns allowedIf(blanket) rather than neutral() when the pipeline is NULL, which is what makes the action's own 'Pipeline not found' 404 reachable. TRANSITIONAL (OPEN-18): substring classification is a transitional pin — the refusal envelope now also carries a stable machine-readable error_code beside error (API-8's first retrofit: PIPELINE_TERMINAL, INWARD_SIGNAL_ALREADY_PENDING, NO_ACTIVE_PAUSE), so the way out exists; classifyRefusal() migrates to it, and only then does the wording stop being load-bearing. Until fdnpm reads the code, all three substrings stay contract — the codes shipping does not retire them, and the substring assertions are deleted in the commit that says the consumer has moved, not before messages PipelineSignalController.php:82-91,129-145; envelope ApiResponseTrait::errorResponse(); 202 ::createInwardSignal() tail, 200 ::resume() ✅ 4 tests (PipelineSignalRoutedPathTest: each 409's discriminating substring + the error envelope key, 202-on-create / 200-on-resume, privileged-404-vs-unprivileged-403 on an absent pipeline, and absent-vs-unowned answering the SAME status for an unprivileged caller — asserting only the absent leg would pass while an unowned pipeline leaked a 404) + 2 unit tests (PipelineSignalControllerAccessTest: the access callback admits a blanket-permission holder on a missing pipeline so the action's 404 is reachable, and stays opaque otherwise)

| INT-17 | A terminal outcome reaps inward signals it never observed. Acceptance is not observation: the signal API accepts a Cancel/Pause whenever the pipeline is non-terminal at that instant, but the engine only sees it at a poll boundary between job iterations, so a run that completes — or fails before creating a single job — leaves the row pending against a dead pipeline. TerminalPipelineSignalReaper subscribes to PipelineCompletedEvent at priority -100 (after the outcome's own subscribers) and calls cancelInwardSignalsForPipeline() for PipelineStatus::terminalStatuses() only. The PAUSED exclusion is the rule's substance, not an optimisation: the same event announces paused, and a pending inward Pause signal IS that run's resume key (INT-5 leaves it pending deliberately), so reaping there would strand the run with no way back. Also skipped: a stateless run (getPipelineId() NULL) has nothing targeting it. Failures are logged, never thrown — the outcome is already recorded and hygiene must not corrupt it. Without this the rows accumulate in pending-interrupt listings for long-dead runs, and the controller's terminal-check-before-duplicate-check ordering becomes the only thing preventing a permanent 409 duplicate on that pipeline | TerminalPipelineSignalReaper.php; InterruptManager::cancelInwardSignalsForPipeline() | ✅ 6 tests (TerminalPipelineSignalReaperTest: all three terminal statuses reap, paused never reaps, stateless skipped, manager failure swallowed) | | INT-18 | An expired outward interrupt ends the run it was holding open. An outward interrupt is the only thing that can resume the pipeline that created it (INT-3 resumes on resolution and on nothing else), so once expireOverdueInterrupts() expires it the resume key is gone: nothing resolves it, nothing calls the resumer, and the pipeline sits paused forever — never terminal, never announced, invisible to every consumer that reports on finished runs including the orchestration poll (OCX-8). ExpiredInterruptPipelineReaper subscribes to InterruptExpiredEvent, cancels the owning pipeline's still-active jobs, marks the pipeline cancelled and hands it to PipelineCancellationAnnouncer — so expiry is announced as the terminal outcome it is (= ORC-15). Two exclusions carry the rule. Outward only: an inward signal acts on a pipeline it does not own (see InterruptDirection), and a lapsed Cancel/Pause request is a request that went unanswered, not a run that ended — expiring one must not kill a healthy pipeline. Non-terminal only: a run that already recorded an outcome keeps it; a late-expiring interrupt never overwrites it, and no second announcement fires. Failures are logged, never thrown — the interrupt is already expired and saved, and one bad pipeline must not abort the sweeper mid-backlog. NB interrupts default to expires = NULL and are then never swept, so an open-ended HITL prompt never reaches this path (INT-12 preserved) — with one deliberate exception: confirmation gate interrupts always carry the gate_expiry TTL (RT-GATE-1), so an abandoned gated run ends here, cancelled and announced, instead of holding an immortal inbox entry | ExpiredInterruptPipelineReaper.php; dispatch site InterruptManager::expireOverdueInterrupts() | ✅ 4 tests (ExpiredInterruptPipelineReaperTest: outward expiry cancels the paused pipeline and announces it, a gate-flavored outward interrupt takes the same path, inward expiry leaves the target running with no announcement, an already-terminal pipeline keeps its outcome) | | INT-19 | A machine caller answers an outbound wait through a route built for its threat model, not the human one. POST /api/flowdrop/interrupts/{uuid}/callback resolves an interrupt with body {"value": …} — the same key the human resolve route takes, same manager call, one name across the API. The human route requires _csrf_request_header_token, which is session-bound and unobtainable by a machine (a basic-auth POST there is a flat 403); the callback route drops it, and options._auth: ['basic_auth'] is what makes dropping it safe — no cookie-authenticated browser can authenticate here, so there is no session to ride and nothing for a CSRF token to protect. Authorisation is the dedicated resolve flowdrop interrupts via callback permission plus the unguessable UUID; it is deliberately NOT satisfied by resolve own/any flowdrop interrupts and does not grant them, so a service account answering an outbound call gains no inbox. The route is scoped by interrupt TYPE, not by direction: only InterruptType::ExternalCall — the one shape a remote system was actually invited to answer — is resolvable here, and anything else is 409. Direction cannot do this job, because every HITL shape (confirmation/choice/text/form/schema_form) is stamped outward exactly like the external call, so a direction-only guard hands the callback credential a human's approval prompt and resumes the run as though a person had answered — the precise grant the permission promises not to include. The direction guard is kept behind the type guard as defence in depth (nothing at the storage layer forbids an inward-stamped external_call row) and keeps its own wording. Everything else is delegated: an unknown UUID is 404, and pending-ness and expiry are the manager's own checks surfacing as 409, so a replayed callback never resolves twice. The missing-value 400 is checked before the lookup, so a malformed request reveals nothing about which UUIDs exist | route flowdrop_interrupt.routing.yml flowdrop_interrupt.api.callback; action InterruptApiController::callbackResolveInterrupt(); permission flowdrop_interrupt.permissions.yml | ✅ 7 tests (InterruptCallbackRoutedPathTest: token-free resolve of an external_call resumes the run, human resolve permission denied 403, an outward HITL prompt refused 409, left pending, run still paused, inward refused 409 and left pending, replay 409, 400-before-404, and the route shape itself — POST-only, no CSRF requirement, _auth exactly ['basic_auth'], sole permission) | | INT-20 | Outbound call-and-wait is an ordering, not just an outcome. CallAndWaitNode creates the interrupt FIRST, then calls the remote, then pauses — the callback URL is built from the interrupt's UUID, so there is nothing to tell the remote until the interrupt exists, and the eager creation also closes the race where a fast remote answers before the local request returns. The outbound body is {callback_url, interrupt_id, payload} and the answer comes back through INT-19. A call that fails to go out cancels the interrupt before rethrowing as \RuntimeException (the error-edge shape per the runtime's routing contract) — a pause waiting on a message nobody was asked to send is worse than a failed node. A URL that is refused creates nothing: scheme/SSRF validation (OutboundUrlSafetyTrait, shared with HttpRequest — see RT-NET) runs before the interrupt, so a rejected target leaves no pending row. A target refused only mid-flight, because a redirect tried to walk the call into a private range (NET-2), cannot be caught that early — it is a failed outbound call and takes that path: the interrupt is cancelled, nothing pauses. The wait is bounded by construction: expires_in has no "wait forever" value and a non-positive one is refused, ExternalCallMessage carries a mandatory TTL, and ExternalCallMessageHandler always stamps expires — which, with INT-18, means a remote that goes silent yields a cancelled run rather than a permanently paused one. The external_call handler is operational: it does not implement HitlInterruptHandlerInterface, so the wait never surfaces in the human inbox where an operator could hand the workflow a fabricated response as though the remote had sent it. resume() returns the callback body verbatim — only the workflow author knows the remote's contract | CallAndWaitNode.php; Message/ExternalCallMessage.php; Handler/ExternalCallMessageHandler.php; Plugin/InterruptHandler/ExternalCallHandler.php | ✅ 6 tests (CallAndWaitNodeTest: create-then-call-then-pause with the UUID in the callback URL and a stamped expiry, failed call cancels and does not pause, non-positive expires_in creates and sends nothing, refused scheme creates and sends nothing, a redirect into a private range is refused and cancels, resume passes the body through unmodified) | | INT-21 | The callback route's authentication provider is an operator prerequisite, and the site says so before the silence does. flowdrop_interrupt.api.callback lists basic_auth as its only _auth provider, but the module does not depend on drupal:basic_auth — the route is optional capability, and a hard dependency would switch an authentication provider on for the human-in-the-loop majority that never calls it. Without the module no provider resolves, the request stays anonymous, INT-19's permission check fails, and every callback is a 403: the node paused normally, the remote's answer was refused with no log line naming the cause, and INT-18 cancels the run at expires_in. So the prerequisite is reported, not assumed — hook_runtime_requirements() raises a RequirementSeverity::Warning on the status report naming the module and the consequence. It is gated on the grant, not on the install: the warning appears only once some role explicitly holds resolve flowdrop interrupts via callback, which is the deliberate act of provisioning the service account and therefore the first moment the missing provider can bite; an admin role's implicit hold does not count, or the check would fire everywhere and mean nothing. Runtime phase only — a missing optional provider must never block an install or an update | flowdrop_interrupt.routing.yml flowdrop_interrupt.api.callback options._auth; flowdrop_interrupt.info.yml (no basic_auth dependency); Hook/RequirementsHooks::runtimeRequirements() | ✅ 3 tests (CallbackAuthRequirementTest: warns when the permission is granted and basic_auth is absent, silent while the permission is ungranted, silent once basic_auth is installed) | | INT-22 | The interrupt entry is 24 keys, uniformly camelCase, present-and-NULL. FlowDropInterrupt::toApiArray() is the single producer behind every interrupt the API publishes — getInterrupt, both resolve routes, cancelInterrupt and the three list endpoints — and it emits, in order: id, type, status, message, nodeId, workflowId, pipelineId, sessionId, schema, options, context, defaultValue, responseData, createdAt, expiresAt, scheduledAt, resolvedAt, resolvedBy, direction, jobId, targetPipelineId, initiatorUid, reason, linkedInterruptId. id is the UUID, type/status/direction are the backed enums' ->value (STORE-11), and all four timestamps are ISO-8601 (date('c')) or NULL — never unix ints. Every optional member is present and NULL rather than omitted, so a consumer reads the key instead of testing for it; that is the opposite of the playground message row's append-when-present convention (PLAY-4), and the two live in the same API family. The three list endpoints answer a JSON array: listSessionInterrupts and listPipelineInterrupts array_filter by per-entity access('view') and then array_values(), because array_filter preserves keys, and a filtered list that is never reindexed serialises as a JSON object once its first row is dropped | producer FlowDropInterrupt::toApiArray() :584-611; consumers InterruptApiController.php:69,115,202,257,289,312,330 | ✅ InterruptApiPayloadContractTest::testGetInterruptReturnsContractedPayload (the 24 keys in order, ISO-8601, present-and-NULL), ::testListSessionInterruptsIsFilteredAndReindexed (asserts the wire really is "data":[{), ::testListPipelineInterrupts, plus the refusal arms | | INT-23 | expires 0/NULL is the no-expiry sentinel, and the two expiry paths differ in exactly one observable. An interrupt waits indefinitely unless it carries a positive expires: isExpired() requires $expires !== NULL && $expires > 0, and the sweeper's query carries both expires < now and expires > 0, so a sentinel row is never matched and stays answerable — without that guard every open-ended HITL prompt would be reaped on the first cron tick. Beyond that, an overdue row is expired by two paths that are deliberately not the same: the cron sweep (expireOverdueInterrupts()) expires, saves and dispatches InterruptExpiredEvent, which is what INT-18's reaper turns into a cancelled, announced pipeline; the inline path in resolveInterrupt() expires and saves the row as a side effect of refusing the answer with \InvalidArgumentException('… has expired') and dispatches nothing. That asymmetry is the rule, not an oversight: the inline path is a plain HTTP request answering one interrupt, and dispatching there would have a caller's late click reap the whole pipeline. "Fixing the inconsistency" by adding the dispatch is therefore a behaviour change with a blast radius, not a tidy-up | sentinel FlowDropInterrupt::isExpired() :403-415; sweep InterruptManager::expireOverdueInterrupts() :568-611; inline ::resolveInterrupt() :121-127 | ✅ InterruptManagerLifecycleTest::testExpiryArmSweepExpiresOverdueAndAnnounces, ::testExpiryArmInlineResolveExpiresAndRefuses (asserts the event list stays empty), ::testExpiryArmSentinelNeverExpires (0 and unset, still answerable) |

A node marked as requiring confirmation never executes without an operator approving that exact call. The gate lives at the single node-execution chokepoint (NodeRuntimeService::executeNode(), after parameter resolution, before process()), so it covers both the graph plane and the tool plane — the dominant use case being a side-effecting node invoked as a tool by an agent loop. Requiring confirmation is a governance decision, not a processor property: every node type carries ConfirmationSettings (a policy plus per-mode allowed-control lists), and the plugin's HasSideEffectsInterface is only the fail-safe fallback when governance has not spoken. Design records: .claude/plans/confirmation-gate-tool-hitl.md (the gate) and .claude/plans/confirmation-governance-settings.md (the governance model); upstream issues #3592383 and #3592384. Trust boundary: the governance settings are gated by the dedicated administer flowdrop confirmation policy permission (RT-GATE-14), not by node type administration, so the gate is exactly as strong as who holds that grant and who can edit workflows.

ID Rule Impl Pinned
RT-GATE-1 A node whose effective flag is TRUE never reaches process() without a consumed, hash-matching confirmed consent — the gate fires after resolveParameters() and before process() (pre-side-effect, so the operator approves the resolved arguments), and only on the process() direction: a resume() invocation continues a side effect that already passed the gate. The pause is an ordinary InterruptRequiredException carrying ConfirmationNode's exact SchemaForm boolean presentation: confirmation shape (zero new UI); the prompt shows only model-fillable args per ToolParameterScope, so config-only values (credentials, endpoints) never reach the inbox. Gate interrupts are a distinct flavor (the confirmation_gate context marker): resolveResumeInterrupt() skips them, so a node that is both resumable and side-effecting (call-and-wait) never receives the operator's consent as its own resume answer. Every raised gate interrupt carries the configured gate_expiry TTL (default 72h — deliberately below cleanup_max_age, so the cancellation is processed before cleanup could delete the row), and expiry is fail-closed on both paths, differing only in blast radius: the cron sweeper expiring the interrupt on a persisted run → the pipeline is cancelled by the reaper (INT-18 — terminal, announced; no operator was attending); an interrupt found already expired on re-entry (stateless/tool re-entry) → the decline path, consumed, reason expired gate slot NodeRuntimeService::executeNode(); ConfirmationGate::enforce()/raise()/gateExpiry(); disjointness NodeRuntimeService::resolveResumeInterrupt() ConfirmationGateRuntimeTest::testGateAsksBeforeExecutionAndApprovalRunsTheNode (+ persisted TTL) + ConfirmationGateProtocolTest (configured TTL on the raise, expired-on-re-entry declines) + ExpiredInterruptPipelineReaperTest (gate flavor cancels the pipeline)
RT-GATE-2 Consent is consumed exactly once: the first entry with a confirmed, hash-matching resolution stamps consumed_at and proceeds; a second entry for the same call re-asks with a fresh interrupt (an agent loop re-issuing the same call re-asks every iteration). Declines are consumed too — a decline never re-fires. The check-consume sequence is lock-guarded per call site (lock flowdrop_interrupt_gate_{jobId}_{nodeId}, one wait() + retry) and the interrupt is re-loaded fresh — static cache reset — inside the lock, so a copy read before acquisition never authorizes a second execution; a contended entry pauses on the winner's interrupt entity-shape instead of raising a duplicate question (contended and unconsented are distinct pause causes). The guarantee is as strong as the deployed lock backend ConfirmationGate::enforce()/checkConsume()/markConsumed()/pauseContended() ConfirmationGateProtocolTest (consume-once + terminal-verdict consumption + stale-read race + contended entry)
RT-GATE-3 Consent binds to hash(node_id, canonicalized resolved args) — sorted keys, stable scalar encoding, internal (__-prefixed) parameters excluded. Any hash mismatch is treated as no consent: the gate re-asks, never reuses (the operator approved one exact call, not "this node from now on"); a still-pending question for drifted args is withdrawn (cancelled) before the fresh one is asked, and a pending question for the SAME call is re-thrown entity-shape rather than duplicated ConfirmationGate::hashArgs()/canonicalize(); consent-args filter NodeRuntimeService::buildGateRequest() ConfirmationGateProtocolTest (mismatch re-asks, stale pending withdrawn, same-call pending re-entered)
RT-GATE-4 A declined graph-scheduled node routes the structured verdict through the node's reserved error port and the processor is never invoked: ConfirmationDeclinedException (a \RuntimeException) becomes an error Output with code confirmation_declined and error_details metadata {type, reason, declined_by, declined_at, interrupt_id}, which buildErrorEdgePayload() forwards as the payload's optional details key — error edge wired → handled failure (ERR-7), else the run's default failure behavior (ERR-8). Deliberately NOT dynamic Confirm/Reject branch ports: a port surface depending on a config flag would break the canvas contract; an author who wants first-class branching on a human decision uses the explicit ConfirmationNode decline catch NodeRuntimeService::executeNode(); payload AbstractOrchestratorCore::buildErrorEdgePayload() ConfirmationGateRuntimeTest::testDeclineRoutesTheErrorEdgeWithoutExecuting
RT-GATE-5 A declined tool call is model-recoverable, never run-fatal: the consumer receives a structured denial ToolResult::error naming the decline and the run continues to its own completion — failing a whole agent run because a human said "no" to one tool call would be wrong. On approval the orchestrator re-fires the consumer and the SAME call is re-issued (args are the consumer's graph inputs — checkpointed output, not a fresh model sample), so the consent matches by hash and the tool executes ScopedToolInvoker::invokeTool() interrupt rethrow + error result path ConfirmationGateToolPlaneTest (approve re-issues and executes; decline returns denial, run completes)
RT-GATE-6 A gate interrupt escaping invokeTool() leaves the tool job trail with no phantom failure: the interrupt path finalizes the recorded tool job as INTERRUPTED via a narrow catch (InterruptExceptionInterface) placed before the generic catch; the resumed call records itself as a new job and closes the superseded interrupted attempt out as CANCELLED (left interrupted it would hold the finished run in paused — the stategraph pauses on any interrupted job); and a FAILED tool job carries error_routed, because a tool failure is a handled failure by construction — delivered to the consumer as a model-recoverable result — so it must not trip hasUnhandledFailedJobs() (ERR-9). The catch-side subscriber memoises persisted entities per exception instance, because a tool-plane interrupt crosses two executeNode() catch sites and each dispatches the same exception — one interrupt, not two ScopedToolInvoker::finalizeToolJobInterrupted()/cancelSupersededInterruptedToolJobs()/finalizeToolJob(); JobInterruptCaughtSubscriber::$persisted ConfirmationGateToolPlaneTest (interrupted-not-failed at pause, single gate interrupt, cancelled+completed trail after resume)
RT-GATE-7 Effective-value resolution is governance-first, in order: (1) an allowed dynamic require whose runtime input is truthy → ask (RT-GATE-9); (2) an allowed instance-level author choice (the reserved requiresConfirmation config key: 2.1.x booleans and the served select's require/waive strings both read) → its value; (3) the stored policy (ask/skip); (4) unset policy derives from the executor plugin's HasSideEffectsInterface at gate time from the plugin class — never baked into stored config (the plugin_version staleness trap), so a plugin that adopts the marker later re-gates existing config automatically. The 2.1.x one-way ratchet is the default checkbox state, not an engine law: "Always" is policy ask with the author waive control withheld. A node type that never stored the mapping resolves to ConfirmationSettings::defaults() (authors keep waive+require, no dynamic surface); a stored mapping is taken literally — an emptied control list is a revocation, not a fallback NodeRuntimeService::requiresConfirmation(); ConfirmationSettings; FlowDropNodeType::$confirmation; ReservedName::REQUIRES_CONFIRMATION ConfirmationGateRuntimeTest::testEffectiveValueResolution + NodeTypeConfirmationGovernanceTest (storage half)
RT-GATE-8 Governance can revoke: a stored author choice whose control the admin has since unchecked is ignored at resolution time — the waiver/escalation was legal when stored, but the granted-control check happens at gate time against the CURRENT settings, so unchecking waive re-gates every instance that had waived. The served config select offers exactly the granted controls (plus "Default"), and when nothing is granted no field is served at all — a value that is not allowed has no option to arrive through, and one already stored is inert NodeRuntimeService::requiresConfirmation() (authorMay guard); NodeMetadataResolver::injectReservedConfigProperties() ConfirmationGateRuntimeTest::testEffectiveValueResolution (revocation arm)
RT-GATE-9 Dynamic escalation is arm-only: when governance grants the dynamic require control, the node type's metadata declares the reserved requiresConfirmation input port (hidden by default) and a truthy runtime input gates the execution; a falsy value does not participate, so upstream data — including model-filled tool arguments — can add an approval but never remove one. Unparseable truthiness escalates (over-asking is the fail-safe direction for a value crossing a port). Enforcement lives at the gate, not the canvas: an undeclared port is inherently wireable (R7 scope), so the identical wiring on an ungranted type delivers a value the runtime ignores. The escalation is not a resolved parameter and never reaches the bag; buildGateRequest() binds it into the consent args explicitly, so a policy flip between executions forces a re-ask (RT-GATE-3) NodeRuntimeService::dynamicEscalation()/buildGateRequest(); port NodeMetadataResolver::buildForNodeType() ConfirmationGateRuntimeTest::testDynamicEscalation
RT-GATE-10 Upgrade fidelity (non-breaking from any 2.x), order-independent: two post_updates with disjoint targets converge on the same state whichever runs first. The grandfather stamps policy: skip on exactly the node types whose behavior the fail-closed derivation would have flipped (no legacy tri-state, no policy, plugin side-effecting) — pre-update reality made explicit, not a baked derivation; undecided side-effect-free types keep deriving. The migrate pass carries a stored 2.1.x tri-state over with its meaning intact — TRUE ("Always") → ask with the author waive control withheld (the lock survives as governance), FALSE ("Never") → skip with the default controls — and the legacy key is shed by the save (declared + schema'd for load/import tolerance, no longer exported). Existing instance-level boolean values keep working under the migrated controls (RT-GATE-7's normalization) — including when they arrive via config import instead of the one-shot post_updates: FlowDropNodeType::preSave() folds a legacy value with no confirmation map forward with the same translation (ConfirmationSettings::fromLegacyTriState(), the single source both paths share) and sheds the key, so a 2.1-era export imported onto an already-updated site governs instead of hydrating silently inert (a present map always wins over a stray legacy value); fresh installs get the fail-closed default flowdrop_node_type_post_update_grandfather_requires_confirmation(); flowdrop_node_type_post_update_migrate_confirmation_governance(); FlowDropNodeType::preSave(); ConfirmationSettings::fromLegacyTriState() NodeTypeConfirmationGovernanceTest (grandfather, migrate, order-independence, preSave import fold)
RT-GATE-11 A gated node whose executor plugin definition or class cannot be resolved never executes and never raises a gate prompt — fail-closed means error, not ask. The plugin manager's probe-freely contract (definitionClassImplements() reads an unresolvable plugin as "not implementing", so callers can probe without try/catch) is kept for every other caller, but at the gate derivation it would read a missing plugin as "no side effects" and silently ungate every uninstalled-provider node; and deriving to "ask" instead would spend an operator's approval on an action createInstance() can never execute — which trains rubber-stamping. So the derivation resolves the plugin strictly and throws (PluginNotFoundException for a missing definition, PluginException for an unresolvable class): the node fails like any other unresolvable plugin, no interrupt raised (a missing definition additionally never launches at all — validator R1 blocks it, so the vanished-class shape is the only one that reaches execution). Same principle on the upgrade path: the grandfather post_update never stamps policy: skip on a type whose plugin it cannot resolve — the unset policy keeps deriving, which fails closed if the plugin's module comes back NodeRuntimeService::deriveFromPlugin() (gate call site — strictness lives here, not in the manager); grandfather guard flowdrop_node_type_post_update_grandfather_requires_confirmation() ConfirmationGateRuntimeTest (vanished-class run fails with zero interrupts; derivation throws on both arms) + NodeTypeConfirmationGovernanceTest (ghost type not stamped)
RT-GATE-12 A resolved secret never persists in the gate prompt. ParameterResolver tracks — by TOP-LEVEL parameter name — every parameter a ${{ secrets.* }} reference was substituted into (the substitution may be partial, "Bearer <token>", or nested inside an array value, so the whole value counts; a scalar-exact match would miss both shapes), and buildGateRequest() replaces those values with <secret> in promptArgs only — the string that lands verbatim in the persisted interrupt message. consentArgs keeps the real values: the consent hash must bind to the exact call (consents for different secret values stay distinguishable), and only the sha256 digest is ever persisted. Scope boundary: this closes the config-secret hole only — edge-connected inputs and upstream outputs are shown as-is, deliberately (the operator should see what is being sent) tracking ParameterResolver::resolve()ParameterBag::getSecretParameterNames(); redaction NodeRuntimeService::buildGateRequest() ConfirmationGateSecretRedactionTest (persisted prompt shows <secret>, plaintext nowhere in the row; different secret value → different hash) + NodeRuntimeServiceGateRedactionTest (partial + nested redacted whole, consentArgs untouched) + ParameterResolverSecretProvenanceTest (top-level-name tracking)
RT-GATE-13 A gate question belongs to the run's initiator, and an initiator-less one is still findable. A fresh gate interrupt is assigned to the execution initiator — the job owner, stamped in the launching user's context — never to whoever's context happens to persist it (a queue worker runs as uid 0, and a uid-0-owned interrupt matches no *_own permission). A run with no initiator (cron, webhook, anonymous trigger — the job owner is uid 0) raises its question unassigned, and the interrupt inbox shows unassigned gate rows to holders of resolve any flowdrop interrupts — the one relaxation of the inbox's hard own-uid scope. The relaxation covers unassigned gate rows ONLY: an assigned row stays scoped to its owner whatever the viewer's permissions (resolve-any grants resolution, not a merged inbox), and an unassigned NON-gate interrupt stays invisible (nothing routes it to an operator; widening that is a separate decision). Residual: with no resolve-any holder attending the fallback view, an unassigned gate question waits out its gate_expiry and the run is cancelled by the reaper (RT-GATE-1 / INT-18) — fail closed, never silently approved assignment ConfirmationGate::raise()/initiatorUid(); fallback visibility InterruptInboxForm::baseQuery() ConfirmationGateProtocolTest (job-owner assignment; no-initiator raises unassigned) + InterruptInboxUnassignedGateVisibilityTest (resolve-any sees unassigned gate rows only; hard uid scope for everyone else)
RT-GATE-14 Confirmation governance is its own grant. The node-type form's confirmation fieldset is gated (#access) by the dedicated administer flowdrop confirmation policy permission (restrict access: true), deliberately separate from administer flowdrop_node_type: being able to rename a node type must not imply being able to disarm its gate. And the unseen fieldset never writes — a save by an editor without the grant preserves the stored confirmation mapping byte-identical (the form-state values under an #access-denied element are element defaults, not operator input, and buildEntity() copies that raw shape onto the entity, so the submit path restores the unchanged stored mapping — absent stays absent, keeping a never-stored mapping deriving) flowdrop_node_type.permissions.yml; FlowDropNodeTypeForm::buildConfigurationStep() (#access) / submitForm() (restore) NodeTypeConfirmationPermissionTest (fieldset hidden without the grant and the map survives a save byte-identical; with the grant the fieldset renders and writes)
RT-GATE-15 A shipped side-effecting node type states its policy; it does not derive one. HasSideEffectsInterface means "mutates persistent state" and the undecided gate reads it as "performs an action an operator should approve" — the same marker answering two different questions, which coincide for an outbound call and diverge for a conversation-memory write. Leaving a shipped type undecided therefore delegates a governance decision to a fail-safe, and the delegation is permanent: config/install reaches fresh installs only, and the grandfather (RT-GATE-10) is a one-shot 2.2 sweep over the types that existed when it ran — unreachable for any type added later, and never run at all on a site installed fresh, since core registers an installing extension's post_updates as already invoked. So every node type this project ships whose plugin carries the marker declares confirmation.policy in its config/install file, split on whether the effect leaves the site (http_request, call_and_waitask; logger, messenger, entity_save and the five memory types → skip), and each owning module carries a stamp_shipped_confirmation_policies post_update that delivers its own shipped declarations to existing sites. The pass reads the module's install files rather than a hardcoded list, so the next side-effecting node type is carried by the same code path; it writes a policy ONLY where none is stored and no legacy tri-state is pending, so an admin's choice, a narrowed control list and a value awaiting the migrate pass all survive. Nothing tightens anywhere: ask is what an undecided type already derived to, and skip is what the grandfather stamped on every site that upgraded through it — the sites that change are those installed fresh on 2.1/2.2, which had been gating these types while their upgraded peers did not ShippedConfirmationPolicy::stampFrom(); flowdrop_{memory,node_processor,interrupt}_post_update_stamp_shipped_confirmation_policies(); the ten config/install declarations ShippedConfirmationGovernanceTest (repo-wide guard: a shipped side-effecting type with no declared policy fails the build) + ShippedConfirmationPolicyTest (delivery, idempotence, stored policy / narrowed controls / legacy tri-state all left intact) + InterruptShippedConfirmationPolicyTest (the same three arms for call_and_wait, the one shipped type whose effect leaves the site)

RT-TOOL — typed tool artifacts

A tool result can carry structured {type, payload} artifacts that survive the invoke/agent loop independent of the prose message fed back to the model, get persisted on the session message they belong to, and surface on TurnResult::artifacts.

ID Rule Impl Pinned
RT-TOOL-1 ToolResultInterface::toArray()'s shape is {success, data, error} PLUS an optional artifacts key, present only when non-empty — an artifact-free result (every result before this feature, and every tool call that attaches none) keeps the plain 3-key shape byte-identical. Artifacts are attached additively via ToolResultWithArtifactsInterface::withArtifact()/getArtifacts() (a new interface ToolResult implements, never a breaking addition to the pinned ToolResultInterface) and are never folded into getData() — the two accessors stay disjoint ToolResult::withArtifact()/getArtifacts()/toArray() ToolResultTest (3-key shape preserved artifact-free; artifacts key appears only once non-empty; disjoint from getData())
RT-TOOL-2 A tool node's output may carry the reserved artifacts key (ReservedName::ARTIFACTS, a list of {type, payload} maps) — a system channel exempt from output-port exposure (ReservedName::CONTROL_OUTPUTS), so it survives filterUnexposedOutputs() regardless of whether the author exposed the port. ScopedToolInvoker::invokeTool() lifts it off the executed node's raw output on the success path, validates each entry (string type, array payload; a malformed entry is dropped with a debug log, never fatal), and attaches the valid ones via withArtifact(); the reserved key is always removed from the result's data, so it never leaks into a tool-consuming processor's prose. A declined/failed tool call (RT-GATE-5) carries no artifacts — ToolResult::error() has no output to have lifted them from ScopedToolInvoker::attachArtifacts() ScopedToolInvokerTest (valid entry lifted + key stripped from data; invalid entries dropped; no key → no artifacts; failed call carries none)
RT-TOOL-3 ToolInvoke surfaces every call's artifacts on a dedicated tool_artifacts output port (list of exactly {type, payload, tool_call_id}, built key by key so a tool cannot override the real tool_call_id or smuggle extra keys through, not exposed by default) — strictly separate from resultToString()'s prose, which only ever reads a result's data (the result key or a JSON fallback), never getArtifacts(). Each artifact is also handed to flowdrop_runtime.tool_artifact_collector (keyed by the pipeline/execution id from the injected ExecutionContextDTO, plus the tool's name for drop diagnostics), which the session write-back path drains — the pipeline's own persisted job outputs are exposure-dependent, so this in-memory side channel is what makes artifact delivery independent of whether the author exposed tool_artifacts ToolInvoke::process(); collector ToolArtifactCollector ToolInvokeTest::testArtifactsSurfaceOnDedicatedPortExcludedFromProse (+ testNoArtifactsMeansEmptyPort)
RT-TOOL-4 A turn's collected tool artifacts are persisted onto the assistant message metadata (MessageAnnotation::METADATA_KEY_TOOL_ARTIFACTS) by SessionExecutionService::processExecutionResults() — drained from the collector once per run and attached to the LAST assistant message the run wrote back — and SessionTurnService::buildWaitResult() aggregates every assistant message's persisted (reload-safe) artifacts onto TurnResult::artifacts, additive on the TurnResult DTO and the turn API's JSON response. SessionService::formatMessageForApi() lifts the metadata key to a top-level toolArtifacts wire field and un-sets the raw key, following the same pattern as hierarchy/tags/display SessionExecutionService::processExecutionResults(); SessionTurnService::buildWaitResult(); SessionService::formatMessageForApi()/extractToolArtifacts() SessionTurnTest::testWaitedTurnWithToolArtifactSurfacesOnTurnResultAndPersists (end to end: tool call → collector → persisted metadata → TurnResult::artifacts → API formatter)
RT-TOOL-5 Artifact collection is opt-in per run. ToolArtifactCollectorInterface::collect() retains an artifact only for a pipeline id a drain owner registered first via expect(); anything else is dropped at collect time. The drain owners are SessionExecutionService::executeWorkflowOnSession() (expects right where it registers the job observer, releases in the same finally on every exit — return, pause, throw) and, for a resumed run, SessionInterruptResolvedSubscriber::resumeWorkflowExecution() (same expect/finally-release shape). Every other path that runs tools — trigger-launched pipelines, playground direct runs, drush, AsynchronousOrchestrator jobs run by a queue worker — therefore collects nothing: the container is per process, not per request, so an always-on map would hold payloads for the process's lifetime on exactly the paths that never drain. drain() stays non-terminal (the run remains expected, so a later tool call still collects); only release() ends the expectation, after which a late collect() is ignored ToolArtifactCollector::expect()/collect()/release(); SessionExecutionService::executeWorkflowOnSession(); SessionInterruptResolvedSubscriber::resumeWorkflowExecution() ToolArtifactCollectorTest (un-expected id ignored; expectation idempotent; destructive drain keeps the run expected; per-pipeline isolation; release drops entries + expectation) + SessionTurnTest::testFailedTurnReleasesTheCollectorRegistration (a mid-run throw still releases)
RT-TOOL-6 Artifacts are capped at collection — one enforcement point for the whole lifecycle. ToolArtifactCollector drops an artifact whose payload does not JSON-encode or encodes to more than MAX_PAYLOAD_BYTES (512 KB), drops any artifact past MAX_ARTIFACTS_PER_RUN (50) for the run, and drops any artifact whose payload would push the run's aggregate encoded bytes past MAX_TOTAL_PAYLOAD_BYTES (2 MB, reset at each drain — the drop is per-artifact, so a later, smaller artifact that fits the remaining budget is still kept) — which, since a run's artifacts all land on one assistant message, is equally the per-message cap. A drop is a warning log naming the tool, the pipeline, the artifact type and the tool call; the retained list carries no placeholder entry for it, so a consumer sees the surviving artifacts and nothing else. Nothing downstream re-checks what a single run collected: the persist path (SessionExecutionService, the resume subscriber) trusts what it drains — with one deliberate exception, the pause-chain stash ceiling (RT-TOOL-7), because the parked list is the one place payloads accumulate ACROSS runs. A payload is untrusted tool/model output — see ToolResultWithArtifactsInterface's trust contract: consumers must treat every value as plain text and escape on output. ToolResult::withArtifact() rejects an empty type with \InvalidArgumentException rather than attaching an artifact no read path would deliver ToolArtifactCollector::MAX_PAYLOAD_BYTES/MAX_ARTIFACTS_PER_RUN/MAX_TOTAL_PAYLOAD_BYTES; ToolResult::withArtifact() ToolArtifactCollectorTest (oversized dropped + logged, unencodable dropped, overflow dropped keeping the first 50, aggregate budget dropped + non-sticky, budget reset on drain) + ToolResultTest::testWithArtifactRejectsAnEmptyType
RT-TOOL-7 A pause does not destroy artifacts. The drain in processExecutionResults() happens regardless of terminal status, so a run that stopped at a HITL pause/interrupt has artifacts in hand and no assistant message to attach them to. Those are parked durably on the turn's TRIGGER message under MessageAnnotation::METADATA_KEY_PENDING_TOOL_ARTIFACTS (never lifted to the wire — formatMessageForApi() strips it like the other annotations) and taken back by the next write-back for the same execution id: the resumed run's, in SessionInterruptResolvedSubscriber, which prepends them to what the resumed tool calls collected and writes the merged list onto the resumed assistant message under the real key. Parking and taking both use the caller's own instance of the trigger message where it has one, since a second instance of the same row would lose the metadata write to a later status save. Taking is destructive, so a delivered artifact cannot be re-delivered; a resume that pauses again parks everything for the next one. Because every resume gets a fresh per-run collection allowance, the parked list itself is bounded: stashPendingToolArtifacts() enforces SessionService::MAX_PENDING_TOOL_ARTIFACT_BYTES (2 MB, JSON-encoded) on the whole list, dropping the NEWEST entries with a warning rather than growing one metadata row to the database's packet ceiling. And because taking is destructive, both attach sites guard the window it opens: a save that fails between the take and the attach re-parks everything on the carrier before rethrowing, so a failed write-back postpones delivery instead of destroying it. The resume write-back also stamps the execution id on the assistant message FIELD (not only in metadata), without which the resumed message — and its artifacts — stay invisible to every per-run reader SessionExecutionService::processExecutionResults(); SessionService::stashPendingToolArtifacts()/takePendingToolArtifacts()/getExecutionCarrierMessage(); SessionInterruptResolvedSubscriber::writeBackToolArtifacts() SessionTurnTest::testArtifactProducedBeforePauseSurvivesTheResume (tool runs → pause → parked on the trigger message → interrupt resolved → delivered once on the resumed assistant message, no longer parked)
RT-TOOL-8 A tool call's arguments are normalized before dispatch. ToolInvoke::normalizeArgs() reconciles a model's arguments against the tool's own getInputSchema(): a STRING argument is JSON-decoded only when the schema declares array/object for that named parameter, the string parses as JSON, and the decoded shape matches (a list for array, a map for object) — never for a parameter declared string, and never accepting the wrong JSON shape (an object literal where an array was declared, or vice versa); anything that fails those three narrows a plain, unmodified string, left for validation to report honestly. Independently, every string LEAF (whether just JSON-decoded, already a native array, or a plain string) is HTML-entity decoded (decodeEntities(), named+numeric, HTML5 set, UTF-8), looping until stable (bounded at 5 passes) so a DOUBLY-escaped value resolves fully rather than one level short; array KEYS are never touched. Entity-free text is returned unchanged without ever calling html_entity_decode(). Ported from the reference fork's ToolInvoke::coerceArgs() (JSON coercion) and CapabilityTool::normalizeArg()/decodeEntitiesDeep() (entity decode) ToolInvoke::normalizeArgs()/normalizeArg()/decodeJsonToDeclaredShape()/decodeEntitiesDeep()/decodeEntities() ToolInvokeArgNormalizationTest (11 cases: JSON-string array/object decode, already-array passthrough, non-JSON string left alone, string-declared param never decoded, wrong JSON shape not smuggled in, single- and double-encoded entities, entities on JSON-string leaves only, entities inside an array arg, bare non-entity &)
RT-TOOL-9 Each tool_call_id executes at most once per run. ToolInvoke consults an injected ExecutionLedgerInterface before invoking a call; an id already recorded as executed is NOT re-invoked — no side effect, the id appended to the skipped output — but its STORED first-execution result (the ledger records each claim's tool-role content + success) is re-emitted into tool_messages/tool_results and factored into ok. Re-emitting, not dropping, is load-bearing: a batch that pauses MID-WAY (an earlier ungated call executed and claimed, then a later gated call interrupted the node, discarding the execution's outputs) never delivered the executed call's tool-role message downstream, so on the post-approval re-ask the ledger is the ONLY place its pairing exists — dropping the repeat whole would leave the declared id dangling and tell the model the call never ran, inviting a retry under a fresh, unguardable id. A repeat of a COMPLETED execution re-emits harmlessly: conversation_buffer dedupes appends by tool_call_id (RT-MEM). This exists because the stategraph schedules a fan-in node (like tool_invoke, whose trigger and tool_calls inputs can each independently route to it) once per predecessor, so the SAME batch can reach a fresh processor instance's process() twice for one run. The id is claimed only once a call has actually RETURNED a result (success or a recoverable error) — deliberately not before invoking: a gated tool instead raises InterruptExceptionInterface to pause the whole node for HITL approval (RT-GATE-6), and ToolInvoke is not Resumable, so the post-approval resume is a fresh, safe re-ask of this same process() call (INT-4) — claiming up front would make that resumed, now-approved call look already-executed and silently drop it. An id-less call (a provider that omits one) is unguardable and always runs, unchanged from before this feature. Core registers flowdrop.execution_ledger as NullExecutionLedger (always answers "not executed", never persists — unchanged behaviour, no memory backend required); installing flowdrop_memory overrides the same service id with MemoryExecutionLedger, scoped to the pipeline memory scope on the entity backend (persists across a pause/resume of the same run; a new run's scope id starts empty; one record PER CLAIM — invoked_tool_calls:<id> — so a claim is a single write with no read-modify-write of shared state, race-free without assuming single-threaded scheduling; 30-day TTL, sized to HITL approval latency — expiry would silently reopen duplicate execution on a late approval) ToolInvoke::process(); ExecutionLedgerInterface/NullExecutionLedger (flowdrop core); MemoryExecutionLedger (flowdrop_memory) ToolInvokeExecutionLedgerTest (Unit: repeat not re-invoked and re-emits its stored message, an interrupting call is not claimed, a mid-batch pause/resume re-emits the executed call without re-running it; Kernel, real MemoryExecutionLedger: repeat executed once with stored message re-emitted, scoped per pipeline)
RT-TOOL-10 The ledger's suppression is reported positively, not only as an inventory. ToolInvoke emits executed_any (boolean) and executed (the ids behind it) as the complement of RT-TOOL-9's skipped. It exists because no other port answers "did this pass do any work": an all-repeat batch returns a FULL tool_messages list (each repeat re-emits its stored message, RT-TOOL-9) and ok TRUE (it reports the recorded first-execution outcome), having invoked nothing — so an agent loop gated on "messages non-empty" re-enters forever, and the fan-in RT-TOOL-9 defends against is exactly what produces an all-repeat pass. skipped cannot stand in: the answer is count(tool_messages) - count(skipped) > 0, set arithmetic no boolean_gateway can do, so every consumer would otherwise leave the declarative graph for the same glue node. TRUE means at least one call was handled for the FIRST time this run, which includes a call whose tool turned out to be unwired — nothing was invoked, but the recoverable "not an available tool" message is a new result the model must re-plan against, and a loop that did not re-enter would strand the run. The two lists partition only the calls that REACH the ledger guard: an id-less call (unguardable, always runs) sets executed_any while appearing in neither, and a malformed call (non-array, or blank/non-scalar name) is dropped before the guard so it appears in neither AND leaves executed_any FALSE — a batch of only those ends the turn with any declared tool_call_id unanswered, a pre-existing drop this port neither causes nor fixes. An empty batch reports FALSE. Both are recorded BEFORE the invoke, which is safe precisely because a gated call's InterruptExceptionInterface discards the whole execution's outputs. executed_any is exposed by default (loop control wires it); executed is not. The shared <verb>_any/<verb> shape this and MEM-14 both follow is documented in docs/development/flowdrop-node-processor.md ToolInvoke::process() ToolInvokeExecutionLedgerTest Unit (all-repeat pass reports FALSE while tool_messages/ok say otherwise; mixed batch names only the new id; unwired name counts as work; id-less call; malformed call in neither list and no work; empty batch) + Kernel (same, against the real pipeline-scoped ledger) + ToolInvokeNodeTypeTest (shipped config matches the plugin schema, keys and exposure)

RT-SG — StateGraph loop & state semantics

Loops and shared state: the extra semantics the StateGraph engine adds — per-field state merging, iteration bookkeeping, checkpoints.

ID Rule Impl Pinned
SG-1 Reducers by field: messages=append, data/metadata=merge, rest=replace; absent field keeps value; state immutable GraphState.php:62-79 ✅ 4 + 3 reducer suites
SG-2 Three independent guarantees. (1) The runtime input array handed to executeNode() carries __state__ (the full GraphState::toArray()), __messages__ (message arrays) and __data__, plus __iterator__ and __current_item__ whenever the state has an iterator, plus __interrupt_id__ when the job is a resume. A processor only sees one of these if it declares it as a parameter — an __-prefixed parameter is "internal" and always accepts its runtime input (= CFG-8/CFG-13). (2) Independently, the current GraphState is passed via setGraphState() to processors implementing StateAwareProcessorInterface only; a non-state-aware processor gets no such call. (3) The input PERSISTED on the job is array_filtered to drop every __-prefixed key, so $job->getInputData() never contains state or interrupt internals even though the processor received them injection StateGraphOrchestrator.php:1521-1540; __interrupt_id__ :1311-1314; filter + setInputData :1316-1322; setGraphState gate :1381-1385 StateGraphStateInjectionTest (clause 2, end-to-end) + StateGraphOrchestratorTest::testReservedStateKeysAreInjectedButNeverPersisted (clauses 1 and 3)
SG-3 state_update output applied through reducers post-completion; never exposure-stripped (= EXPO-12) :1347-1351
SG-4 ForEach: init on empty __iterator__ (needs items; non-array wrapped; re-indexed; empty completes immediately); defined output set incl. collected_results on completion ForEachNode.php:101-204+ ✅ 8 cases
SG-5 loop_back input doubles as item_result when absent. Consumption is ForEach-specific: the reserved loop_back injected on every other node type (SCH-32) is a re-entry signal — its value is delivered on the port and read by nobody, since a node without iteration state has nothing to fold it into. A processor wanting the value must declare its own port, which is exactly what suppresses the injection :113-122
SG-6 Routing decides per EDGE, dispatches per TARGET (multiple edges OR'd into one dispatch) StateGraphOrchestrator.php:846-886
SG-7 Loopback re-entry requires active branch AND (iterator.hasMore | the LOOP's round count < max for branch-driven loops). The branch-driven bound is per loop (SG-16), not per node: the two coincide on a body where every node runs every round, but diverge on a body with a conditional arm, where a node that only runs on some rounds has a lagging execution count and used to let the loop keep re-entering after the author's budget was spent. maxIterations has always read as a number of rounds; now it is one. The gate compares rounds RUN = $loopRounds + 1, because $loopRounds counts re-entries and is still 0 during the first round whereas the per-node count it replaced was incremented before the gate ran — without the + 1 every branch-driven loop silently gains a round over the behaviour this replaced. The counters are restored from job stamps on entry (SG-17), so the bound holds across a pause; that is what makes it reachable at all, since within one request a round costs at least two scheduler passes and ORC-10's pass budget always breaks the loop first. A pipeline whose jobs were generated before SG-16 existed has no snapshot for the head and falls back to the per-node bound, so an in-flight run keeps its old behaviour rather than losing its backstop :876-903; gate :2064-2088; restore :1265-1300 ✅ 3 suites + StateGraphOrchestratorTest::testShouldFollowLoopbackEdge (the per-loop bound wins over a lagging per-node count; a cap of 1 permits one round; the no-snapshot fallback still bounds) + StateGraphBranchDrivenLoopTest::testTheRoundCapSurvivesResumeAndBoundsAnExitlessLoop (the only end-to-end observation of the arithmetic: drop the + 1 and the head runs 6 times under a cap of 5; drop the restore and it runs unbounded)
SG-8 Two behaviours. (a) Response keying — the first execution of a node lands in results under the bare nodeId; each later execution lands under "{nodeId}:{n}" with n = that node's zero-based prior-execution count in this run (node, node:1, node:2…), and a WorkflowStopException payload uses the same key. The interrupt/partial-results path re-derives an equivalent scheme from storage rather than sharing the counter: completed jobs are walked in job-id order and keyed by the job's loop_iteration metadata when it carries one, else by the running per-node occurrence count. (b) Input resolution — each completed run stamps a higher execution_order, so a multi-source input port resolves to the newest run of the source node; inside a loop, the current iteration keying :794-816; partials :1077-1108; ordering stamp :1409-1415 StateGraphOrchestratorTest::testRepeatExecutionsAreKeyedWithAnIterationSuffix (bare id + :1 + :2) + StateGraphMultiSourcePortResolutionTest
SG-9 data.condition never routes — not even on a loopback edge, the one place dispatch really decides; warning once per edge per resolution of its source's outgoing edges (= EDGE-8) outgoingEdges() StateGraphRemovedEdgeConditionTest::testConditionDoesNotGateLoopReentry, ::testConditionedEdgeIsStoredWarnedAndFollowed
SG-10 Reserved trigger source port = "no branch" for loopback naming :1684-1691
SG-11 Checkpoint per node (chained parentId) + final workflow_end; memory checkpointer request-scoped; entity checkpointer persists; unknown type → InvalidArgumentException :813-821,947-954; factory ✅ 5 suites
SG-12 State restore precedence: initialSnapshot → resumeFromCheckpoint (missing id warns+fresh; terminal checkpoint throws — INT-14's gate) → latest for threadId (deliberately ungated: thread continuity seeds a new turn, see INT-14; a terminal seed keeps what the THREAD accumulated — messages, data, metadata, thread id — and clears everything the finished RUN owned: its outcome (isComplete, error, status) so the new turn reports its own result (INT-13), and its execution position (iterator, iterationCount, currentNodeId) so the new turn starts its loops from the beginning. ⚠ The iterator was previously classed as accumulated and kept. It is not: a ForEach that ran to completion leaves an EXHAUSTED iterator, so the next turn's loop emitted a NULL item and fell through without iterating — and because that non-iterating turn wrote a fresh iterator back, turns ALTERNATED between working and not. iterationCount is the same bug one step out: it feeds StateManager's maxIterations budget, so carrying it across turns spends a thread's whole loop budget on whichever run exhausts it first. Only the TERMINAL arm clears; a paused run restores untouched through the non-terminal arm, which is what keeps a mid-loop pause resumable and makes clearing safe here) → fresh orchestrate() snapshot arm + initializeState() ✅ 4 tests in StateGraphResumeRefusalTest (snapshot outranks an explicit checkpoint — proven by a TERMINAL checkpoint that is never consulted; missing id warns and continues; terminal checkpoint throws; nothing-to-restore is fresh) + thread-continuity arm + CheckpointStatusRoundTripTest::testRunStateClearedKeepsAccumulatedStateOnly / ::testExhaustedIteratorDoesNotSeedTheNextTurn (the exhausted-iterator regression: keep it and the next turn's loop never runs)
SG-13 The effective ExecutionConfig — including the resolved threadId and checkpointerType — is stamped on $pipeline->getExecutionContext()['stategraph_config'] alongside orchestrator_type, and the re-stamp (plus its save) is skipped when both values are already exactly what would be written. A later resume or deferred launch rebuilds via ExecutionConfig::fromArray() on that key; only when the key is absent does it fall back to the workflow's orchestrator_settings.stategraph (plus a root timeout), then to defaults bounded by the caller's max-iterations stamp :444-458; rebuild precedence :477-522 StateGraphExecutorParityTest
SG-14 StateManager::applyUpdate()/incrementIteration() throw MaxIterationsException once the new iterationCount exceeds maxIterations (strictly greater — the budget's own iteration is allowed); StateGraphOrchestrator::orchestrate() catches it and returns an OrchestrationResponse with status exactly max_iterations_exceeded, empty results, and metadata max_iterations / current_iterations / node_id. It is a terminal verdict, not ORC-10's resumable pause: no checkpoint-resume handle is offered and the status is never paused. NB the loop's own $iteration >= maxIterations guard (ORC-10) breaks into the PAUSED ladder first, so this exception is reached only when the state's counter runs ahead of the loop counter — a restored/seeded state, or a node emitting state_update.iterationCount guard StateManager.php:156-162,195-201; catch + response StateGraphOrchestrator.php:296-315 StateManagerTest + StateGraphOrchestratorTest::testMaxIterationsExceededResponseShape (status, empty results, three metadata keys)
SG-15 StateGraph/sync-pipeline execution id = pipeline entity id (frontend polls pipeline API). Both engines overwrite the caller's OrchestrationRequest::pipelineId with the id of the pipeline they created — a synthetic id (the request's own pipeline_{workflow}_{time}_{rand}, a uuid, the thread id) would 404 every poll and freeze the canvas at idle. Pairs with PIPE-4: the same response id polls a payload whose node_statuses keys are the canvas node ids StateGraphOrchestrator.php:199-201; SynchronousPipelineOrchestrator.php:161 ✅ 2 tests (StateGraphExecutionIdIsPipelineIdTest, both engines, incl. an actual poll through PipelineApiController)
SG-16 Loop extent — what a loop is. For a loopback edge S→H (the loopback handle is on the re-entered node, so H is the edge's target), body = ({H} ∪ descendants(H)) ∩ ({S} ∪ ancestors(S)) over the forward graph only — every edge type EdgeType::isExcludedFromExecution() does not drop. Bodies of loopback edges sharing a head are unioned: a loop is keyed by its head, so two tails re-entering one node are two continuations of one loop with one round counter, not two loops. Membership is topological, not observed — a node on a conditional arm inside the loop is in the body even on rounds it does not run, which is exactly the node whose absent stamp used to be ambiguous; a node on the exit path (downstream of the tail, not upstream of it) and a dead end that cannot reach the tail are both outside, since neither can influence a later round. A loopback edge whose tail is not downstream of its head closes no cycle and yields a head-only body rather than being dropped. The forward graph is a DAG by construction (CMP-5 hard-errors on any forward cycle, and every cycle it tolerates is drawn from one of the three excluded types), which is why this is two reachability sweeps per loop and not an SCC pass — generalising it to Tarjan would quietly legalise the cycles CMP-5 rejects LoopExtent.php:89-128 (algorithm), EdgeType.php (the excluded set), LoopMap.php LoopExtentTest (10 cases: loop-free, entry/exit outside, conditional arm inside, dead end outside, two tails one loop, no-cycle head-only, nested, tangled-merged, excluded edge types, snapshot round trip)
SG-17 Extent is snapshotted, and round-0 is stamped. generateJobs() computes the map once and writes it to $pipeline->getExecutionContext()['loop_map']; the orchestrator reads that snapshot rather than recomputing, so an author editing the workflow mid-run cannot redraw the body of a loop already several rounds deep (same argument as the branch-declaration snapshot, BR-6). The snapshot also carries ancestor sets. LoopMap::ancestorsOf($nodeId) returns every node that can reach $nodeId over the forward graph, the node itself included (exactly as a loop body includes its head — the set answers "what could still produce this node's result", and a job for the node that has not run yet is the first thing that could). SG-20 needs that at readiness time and neither half was available: LoopExtent::reachable() is private, and getReadyJobs() holds per-job incoming_edges metadata but no workflow graph, so recomputing adjacency per readiness pass is both unavailable and the wrong shape. The sets are therefore taken once inside LoopExtent::fromClassified() — the only place that holds the classified forward graph, and where the tail sweeps' memo is already warm, so job generation needed no change at all — and snapshotted, for the same reason the bodies are: the two halves of a round rule must not be able to disagree about the graph. ⚠ The KEYS are bounded to loop-body nodes; the SETS are not, and restricting them would be a bug rather than a saving. Only body nodes are ever asked about, because the question only arises once two jobs are known to share a loop — that is what bounds the storage to Σ over loops \|body\| × \|graph\|, small at workflow-canvas scale, revisit if a real workflow disproves it. But a node outside the loop can legitimately be an ancestor of one inside it (a slow corpus load feeding an in-loop fact check), and a body-restricted set would report "nothing can reach it" while that input was still on its way — SG-20 would then fail the consumer prematurely. A snapshot written before this key existed restores with empty sets and SG-20 declines to reach a verdict on it. Every job's metadata carries loops = array<loop id, round>, outermost loop first — including jobs in no loop, as an empty array, and including round 0. That is the point: absence of the key stops meaning "outside the loop" and "inside it at round 0" at once. createLoopIterationJob() overwrites the stamp on a clone rather than merging, because clone metadata is inherited from the round-0 job (findOriginalJobForNode() returns the node's first job), whose rounds are all zero. is_loop_iteration / loop_iteration / loop_source_node / parent_job_id are unchanged and still written beside it — loop_iteration remains the per-node occurrence count that BR-6's logging and the result-keying in SG-8 read. The stamps are also the durable copy of the round counters: $loopRounds is a local, so the orchestrator rebuilds it on entry by replaying the stamps in job-creation order, newest winsnot by taking the highest round per loop. A round counter is not monotonic, because SG-18 resets nested loops when their outer round advances, so the maximum stamp for an inner loop is the high-water mark of some earlier outer round and restoring it would spend that loop's budget before the current outer round has begun, as well as destroying the cross-round comparability SG-18 exists to give. Ascending job id is creation order (serial entity ids); getJobs() sorts by priority and created date, so the restore orders the jobs itself. A stamp whose round is unreadable is skipped and logged — a silently zeroed counter is the runaway this bound exists to stop — and a snapshot that is present but restores to nothing is logged too, since every loop in that run then falls back to the per-node bound compute+log JobGenerationService.php:171-183; stamp :264; snapshot :346-348; ancestor sets LoopExtent.php:175-189 (::reachable() :213), accessor LoopMap::ancestorsOf() :332-334, stored shape LoopMap::toArray()/::fromArray(); read+restore StateGraphOrchestrator.php:751-762, :1280-1330; clone overwrite :1345 JobGenerationServiceTest::testRoundZeroJobsAreStampedWithTheirLoopMembership (inside stamped at 0, outside present-and-empty, snapshot restored) + StateGraphOrchestratorTest::testLoopRoundsAreRestoredFromJobStampsOnResume + ::testNestedLoopResumesOnTheCurrentOuterRoundNotItsHighWater (restoring the maximum returns the inner loop at 5 instead of 1) + LoopExtentTest::testNumericNodeIdsSurviveTheSnapshotRoundTrip (numeric node ids are cast, not type-checked, or the whole map restores empty) + LoopExtentTest::testAncestorSetsAreKeyedByBodyNodeAndIncludeTheNodeItself, ::testAnOutOfLoopAncestorOfAnInLoopNodeIsStored (the premature-failure trap), ::testFoldedLoopsKeepAncestorSetsForEveryBodyNode, ::testSnapshotWithoutAncestorsRestoresWithEmptySets (spec-registry: SG-17) + JobGenerationServiceTest::testOutOfLoopAncestorStillOnItsWayHoldsTheBarrierOpen (the trap observed end-to-end through the barrier)
SG-18 Nesting and tangles. Containment is nesting: when one loop's body is a strict subset of another's they stay distinct and carry a round each, and loopsForNode() orders them outermost first (descending body size). Advancing an outer round resets every loop nested inside it to 0 — without that an inner loop spends its budget once for the whole run instead of once per outer round, and inner round numbers stop being comparable across outer rounds, which is precisely what a staleness barrier would need them for. Loops that share nodes without one containing the other (including two heads with identical bodies) are irreducible: no assignment of rounds to the shared nodes is consistent, so they are merged into one loop over the union — keyed by the largest body, lexicographic tie-break — and the fold is recorded on the map. Merged, not rejected: a hard error would fail workflows that run today, at launch, on a topology no editor gesture makes obvious, and a coarser loop is still sound because it never claims two nodes are in different rounds when the topology cannot say so. The degradation is reported to the author, not to the log: a tangle is a static property of the graph, so warning about it at job generation repeats once per run forever and addresses whoever reads dblog rather than whoever drew the edges. LoopDiagnostic (a tagged workflow-doctor diagnostic) reports it, along with the other tolerated degeneracy — a loopback edge whose tail cannot reach its head, which closes no cycle, yields a head-only body, and is drawn on the canvas exactly like one that works. Both are informational: untangling two loops means deciding which shared nodes belong to which, which is intent, not mechanism. LoopDiagnostic carries a third case, which is not an extent degeneracy but a wiring one: a data port on an in-loop consumer whose every in-loop source sits downstream of a gateway inside a loop the consumer is in. Such a port can be unfilled in some round, and since SG-20 now FAILS that job, the diagnostic is what moves discovery from "the run after upgrading" to "when you open the workflow" — it ships in the same release as the barrier, not as a follow-up, because with no grandfather it is the only thing between an author and a first-run failure they could not have anticipated. Its four restrictions mirror the engine's so it only warns about a shape that will actually fail: the consumer has no trigger and no error edges (both take it out of SG-20's scope, so a finding would name a failure that never arrives), every source on the port is in-loop, every in-loop source is behind a gateway branch (sources on one port are an OR, so one always-runnable source rescues it), and the gateway is inside a shared loop — one outside decides before the loop is entered and cannot decide differently on a later round. It stays deliberately conservative in the one direction left: a gateway that in practice always takes the branch is flagged anyway, because the rule is static and the decision is not. ⚠ Two things follow from that conservatism and are stated rather than implied. The finding introduces a new locator form, node.{id}.port.{port} — located on node and port because every edge present is fine and the problem is the one that is missing, and because two bad ports on one node would otherwise collapse into a single finding id — and nothing parses it: buildMutations() returns [] for every finding this diagnostic raises, so the locator is an identity, not an addressable target. And the second remedy it names is not statically expressible: "wire it so the consumer is skipped too" cannot be distinguished from the problem shape, because the consumer is always downstream of the gateway branch — the gated source feeds it — so an author who has correctly wired that remedy still sees the warning. Inside B8's declared conservatism, but a real false-positive class, not a theoretical one. Job generation keeps a debug line only fold LoopExtent.php:196-247; order LoopMap::loopsForNode(); degeneracy LoopMap::degenerateHeads(); reset StateGraphOrchestrator::advanceLoopRound() :1243-1256; report LoopDiagnostic.php; port-behind-gateway LoopDiagnostic.php::portsBehindGateways(), finding + locator ::portBehindGatewayFinding(), CODE_PORT_BEHIND_GATEWAY, no remedies ::buildMutations() LoopExtentTest::testNestedLoopsAreKeptApartAndOrderedOutermostFirst, ::testOverlappingLoopsAreMergedAndTheFoldIsRecorded + StateGraphOrchestratorTest::testAdvancingAnOuterRoundResetsInnerRounds + LoopDiagnosticTest (6 cases: well-formed and loop-free silent, no-cycle edge located on the EDGE, tangle reported, nesting not reported, no remedies) + LoopDiagnosticTest::testPortFedOnlyFromBehindOneGatewayIsReported, ::testSecondUngatedSourceOnThePortIsNotReported, ::testAnOutOfLoopSourceOnThePortIsNotReported, ::testTriggerDrivenConsumerIsNotReported, ::testGatewayOutsideTheLoopIsNotReported (spec-registry: SG-18, the four restrictions) + LoopRoundTest::testIsBehind (the reset is what makes inner rounds comparable across outer rounds, which SG-19's lexicographic comparison depends on)
SG-19 The per-iteration staleness barrier, Clause 1 — a round-behind source does not satisfy an edge. A completed source job does not satisfy a trigger, error or data edge when the source and the consumer share a loop and the source's round is behind the consumer's. This is the missing half of the in-flight gate: isNewestJobForNode() only ever asked "does a newer job for this node already exist", never "is it from my round", and it was missing identically on all three classes. Inside a loop the newer-job arm catches only the rarer case, because StateGraph mints jobs on dispatch rather than from a plan for the round (ensureJobExistsForNode()), so the round-N wavefront crosses a body one node at a time and the far commoner shape is the consumer's round-N job already minted while a source has simply not been re-dispatched — its round-(N−1) completion is still its newest job. The comparison is LoopRound::isBehind() over the two jobs' loops stamps (SG-17): intersect the key sets, then compare rounds lexicographically, outermost loop first, iterating the CONSUMER's stamp because its insertion order is already containment order — re-sorting by loop id would order by node id, an arbitrary property of how the author named their nodes, and would let an inner loop out-vote the one containing it. The first shared loop on which the two differ settles it and nothing nested inside can overturn it; that is only meaningful because SG-18 resets inner rounds when an outer round advances, without which an inner round is a run-long high-water mark and not comparable across outer rounds at all. Two tolerant arms carry the rule and both are load-bearing. An empty intersection is never behind: a source outside every loop the consumer is in will never re-fire, its value is the current one forever, and gating it would hang the consumer on a round that cannot arrive — precisely the deadlock BR-6's withdrawn universality clause named. Get that arm wrong and every loop in every workflow stops, which is why it is asserted separately on all three edge classes. An unreadable or absent stamp is never behind: a pipeline generated before the stamp existed, or one whose context was hand-edited, keeps the behaviour it was launched with rather than deadlocking several rounds into a run — the same mid-run tolerance SG-7's counter restore grants a missing snapshot. Rounds are read strictly and a single non-int round voids the WHOLE stamp rather than the offending entry, because a partially-read stamp compares against a different loop set than the job was stamped with; loop-id keys are cast to string rather than type-checked, since PHP coerces numeric-string keys to int and loop ids are head NODE ids, which the editor generates as "1", "2" — an is_string() guard would drop every loop of exactly the workflows least likely to be hand-authored. The gate sits beside the older newest-job check on each class rather than replacing it (BR-6). Verdicts differ by class and that asymmetry is BR-7's: a gated trigger or error edge leaves the consumer IDLE for the end-of-run skip sweep (no new failure mode, terminating by construction), while a gated data port is what SG-20 has to terminate. ⚠ Rules only ever differ under StateGraph — see SG-20 for why LoopRound::isBehind() src/Utility/LoopRound.php:66-94, ::rounds() :117-131; data JobGenerationService.php:1241-1253; trigger :987-996; error :904-913 LoopRoundTest::testIsBehind (18 rows: shared, disjoint, nested outer-wins, equal, unreadable, numeric loop ids) + the six kernel tests on BR-6 (three classes gated, three anti-deadlock arms) + JobGenerationServiceTest::testGatedConsumerIsSkippedNotFailed (spec-registry: BR-7 — the trigger verdict is a skip)
SG-20 Clause 2 — the barrier terminates, and only where Clause 1 put it. SG-19 on its own turns a gateway that routes away from an in-loop source into a hang: the consumer waits for a round-N value that has no producer left, until the run's budget pause and a vague paused_reason. So the gate stops waiting when no job still idle, pending or running can reach the source node over the snapshotted forward graph (LoopMap::ancestorsOf(), SG-17, which includes the source itself). The loop head is an ancestor of everything in its own body, so a live head job correctly holds the barrier up across a re-entry, and an out-of-loop ancestor still on its way holds it up too — that is what the unrestricted sets are for. ⚠ The liveness set excludes jobs whose own loops stamp is behind the consumer's, by the same comparison SG-19 uses: without that, a job minted at generation for a branch the run never took sits IDLE at round 0 forever and satisfies "something can still reach the source" for the rest of the run — the hang this clause exists to remove, reintroduced through the fix for it. Four restrictions keep the verdict off workflows that were doing nothing wrong, and the third is the one that matters most. (1) Data edges only, on a consumer with no trigger and no error edge — a trigger-driven consumer's data ports are never evaluated at readiness at all (DATA-4), and an error edge can still be satisfied by a failure that has not happened yet. (2) Every source on the port shares a loop with the consumer; one out-of-loop source is never gated and keeps the port fillable forever. (3) ⚠ The port must have been ACTUALLY gated by Clause 1 — at least one edge with a completed, branch-active source held back by nothing but the round comparison (dataEdgeIsRoundGated()). Read literally, "the gate stops waiting when no live job can reach the source" also catches a port whose source was simply never dispatched, which is an ordinary BR-7 branch skip that Clause 1 had nothing to do with; the first implementation converted that long-standing skip into a failure. This clause is a termination clause for Clause 1, not a general unfillable-port rule, and restriction (3) is what makes it one. (4) The snapshot must actually carry the ancestor set; a pipeline generated before SG-17's ancestors keeps the behaviour it was launched with. The verdict is the job FAILED, and it is a job failure like any other. With an incoming error edge the failure is routed (FAILED + error_routed) so an author catches an unfillable port exactly as they catch any other node failure; without one it is a plain markAsFailed() and hasUnhandledFailedJobs() fails the run — flagging error_routed unconditionally would make this the one failure class that never fails a run, the inverse of the asymmetry the fail-in-place alternative was rejected for, and it is what makes B3.3's "neither wired → fail" row true. Message contract: name the PORT, the ROUND (the consumer's round on the innermost shared loop — the stamp is outermost-first, so the last shared loop is the one the author is watching turn) and the SOURCE always; name the gateway only when it is determinable from the source's own incoming branch edges, degrading to "can no longer produce a value for this round" rather than sending an author to the wrong node. Reported by pipeline, routed by runtime: getUnsatisfiableJobs() is a sibling of getReadyJobs(), never a change to it — three of that method's seven production call sites are !empty(...) "is there more work" checks a tri-state would silently break and one returns it through a storage interface — and the verdict is taken by routeJobFailure(), the metadata-writing core extracted out of routeNodeError() so failure routing keeps one implementation; flowdrop_pipeline cannot call up into flowdrop_runtime, and error_routed_jobs_map is built inside JobGenerationService itself, so a copy there would read the flag it writes. The scheduler path synthesises the post-execution arguments for a job that never ran (execution time 0.0, the pipeline's execution id). A verdict re-runs readiness in the same pass, because a routed failure can satisfy an error handler that was waiting on it. ⚠ Wired on StateGraphOrchestrator alone, and the reason is not "the synchronous orchestrator has no jobs" — it has jobs and it calls getReadyJobs() three times. Rounds only ever differ because createLoopIterationJob() advances a stamp, and that exists only in StateGraph; everywhere else every job carries the round-0 stamp job generation wrote, so isBehind() is universally FALSE, Clause 1 never gates and there is nothing to terminate. Terminating by construction: the gate only ever waits on ancestors of the consumer and CMP-5 makes the forward graph a DAG (SG-16), so no consumer is its own ancestor and the waits follow a topological order; each verdict also removes a live job, so a pass that reaches one strictly reduces the set the next pass waits on test JobGenerationService::getUnsatisfiableJobs() :1348-1392, per-job ::unsatisfiableDataPortReason() :1413-1481, Clause-1 precondition ::dataEdgeIsRoundGated() :1279-1289, liveness ::anyLiveJobCanReach() :1498-1510, round ::roundOnInnermostSharedLoop() :1528-1537, message ::unsatisfiablePortMessage() :1563-1597 + ::gatewayThatRoutedAway() :1618-1641; verdict AbstractOrchestratorBase::failUnsatisfiableJobs() :1039-1076 and ::routeJobFailure() :970-1007 (extracted from ::routeNodeError() :928), error-edge test ::nodeHasErrorEdge() :890; wiring StateGraphOrchestrator.php:819-822 JobGenerationServiceTest::testUnfillablePortFailsTheConsumerAndItsErrorEdgeCatchesIt (the gateway-skip case fails with the message contract and is caught by an error edge), ::testAbandonedIdleRoundZeroJobDoesNotHoldTheBarrierOpen (the liveness restriction), ::testLiveLoopHeadHoldsTheBarrierOpen, ::testOutOfLoopAncestorStillOnItsWayHoldsTheBarrierOpen (SG-17's trap, end to end), ::testGatedTriggerConsumerIsNeverReportedUnsatisfiable (restriction 1) — all bound spec-registry: BR-6 / BR-7 / DATA-4 / SG-17 as well, since the clause is a consequence of all four

RT-PIPE — pipeline status API (the live polling contract)

This is the surface fdnpm actually polls (see the RT-ST removal note below): GET /api/flowdrop/pipeline/{id} — the FULL payload including node_statuses — plus the lightweight /status. Both read persisted $pipeline->getStatus(); no in-memory tracker is involved. Confirmed fdnpm consumers: enhanced-client.ts:691-706, PipelineStatus.svelte:94, pipelineViewUtils.ts:120, nodeExecutionService.ts:85,136. This live contract was previously unspecified — these rows give it rules.

Key casing is MIXED, and that is the contract. Do not "normalise" it in either direction. The payload deliberately carries camelCase entity-facing keys (createdAt, lastExecuted, executionCount, pendingInterrupt, pausedReason) alongside snake_case run-data keys (node_statuses, job_status_summary, execution_data, jobs[].node_id, and inside each node entry last_executed, execution_time, execution_time_us, status_counts). The envelope itself is {success, data} / {success, error}. fdnpm reads both spellings verbatim, so renaming any key — or adding a key in the "other" style next to an existing one — is a breaking change to the sibling repo.

ID Rule Impl Pinned
PIPE-1 The live run-status contract is the pipeline API reading persisted entity state (= SG-15). Two routes, one envelope: GET /api/flowdrop/pipeline/{pipeline_id} and .../status both answer the standard {success: TRUE, data: …} shape (ApiResponseTrait::successResponse), publish the persisted lifecycle value as the raw PipelineStatus value string under data.status, and answer 404 ({success: FALSE, error}) for a missing pipeline and 403 for a denied one. The full route adds node_statuses, jobs, job_status_summary, execution_data (plus id/name/description/createdAt/lastExecuted/executionCount/timestamp); /status is the lightweight poll — id, status, createdAt, lastExecuted, pendingInterrupt, pausedReason — and deliberately omits the jobs payload. Both read $pipeline->getStatus() and each job's stored status, so a status written out of band is what the next poll returns and a freshly built controller (i.e. a later request) reports the same value. No in-memory tracker participates — that surface no longer exists (see RT-ST) full PipelineApiController.php:102-125 (+ formatPipelineData() :238-271); lightweight :143-194 (payload :174-184); envelope src/Controller/Api/ApiResponseTrait.php:55-77 PipelineNodeStatusKeysTest
PIPE-2 job_status_summary has ONE shape, derived from the JobStatus enum (emptySummary()): the key total followed by one integer counter per case, interrupted included. All three build paths emit those exact keys in that order — the real jobs read, the missing-pipeline fallback and the failure catch (both fallbacks all-zero, via one emptyJobsData() builder). The job API's own status_summary shares the builder JobStatus::emptySummary(); PipelineApiController::getPipelineJobs/::emptyJobsData; PipelineJobApiController::getPipelineJobStatus
PIPE-3 Vocabulary guarding is scoped to job_status_summary / the job API's status_summary: total counts every job, but a bucket is incremented only when JobStatus::tryFrom() resolves the persisted value, so the buckets may sum to less than total, no out-of-enum key is ever added there, and no PHP warning is raised. The distinct unknown values are logged as exactly one warning per read. ⚠ node_statuses[*].status_counts is deliberately NOT guarded — it counts raw persisted values — so an out-of-enum status DOES appear as a key there, and as node_statuses[*].status. The two live in the same payload; do not read the guard as covering both guarded PipelineApiController.php:305-314; single warning :328-333; unguarded :401, :440; PipelineJobApiController::getPipelineJobStatus PipelineJobStatusSummaryTest (unknown excluded from the summary + present in status_counts, one warning naming it)
PIPE-4 node_statuses is keyed by the workflow node id — the id the canvas holds, which is what the editor looks a badge up by. The key is $job->getNodeId() (job metadata node_id, stamped from the stored node's id at job generation), never the job entity id, the node-type id or an iteration-suffixed id: loop clones inherit the plain node id, so all of a node's iterations collapse onto one entry and the per-iteration picture rides inside it (executions, status_counts). jobs[].node_id carries the same ids, which is why the editor's per-job fallback resolves too. Only nodes that became jobs get a key — a node excluded from job generation (tool-only, non-executable, a non-triggering trigger) has no entry and reads as idle. Which job supplies each collapsed field is part of the rule (resolveNodeStatus()): status is the status of the newest job by created (compared with >=, so the later job in pipeline order wins a same-second tie); last_executed / execution_time / execution_time_us come from the most recent job that actually started (all NULL when none did); error from that same started job, else from the newest; executions counts only jobs carrying a started timestamp; and status_counts counts every job in the group. So a never-started iteration appears in status_counts but not in executions PipelineApiController.php group :316-321, resolve :391-442 (raw counts :401, newest :403-408, started :410-417, fields :429-441); JobGenerationService metadata node_id; consumer (fdnpm repo) nodeExecutionService.ts:41-49,296 (executionCount: entry?.executions ?? startedCount) PipelineNodeStatusKeysTest (keys, loop collapse, ::testCollapsedEntryResolvesPerFieldFromItsJobGroup)
PIPE-5 A pipeline's jobs are the ones its own job_id reference field names — the link JobGenerationService and StateGraphOrchestrator write with addJob() as each job is created. Every surface that publishes jobs resolves from that one source, so GET /api/flowdrop/pipeline/{id}, .../jobs and .../job-status agree on which jobs a run has. The two pipeline-scoped endpoints are flowdrop_pipeline.api.pipeline_jobs / flowdrop_pipeline.api.pipeline_job_status, served by PipelineJobApiController in the module that owns the pipeline entity, so the link is read through the typed FlowDropPipelineInterface::getJobs() rather than a string-keyed hasField('job_id') reach-around; their paths are the contract and did not move (/api/flowdrop/pipeline/{pipeline_id}/jobs, .../job-status), only the route names did. The single-job route stays flowdrop_job.api.job at /api/flowdrop/job/{job_id}. Job metadata carries no pipeline_id — nothing in production writes that key — so it is honoured only as a supplementary override of the reported jobs[].pipeline_id, never as the filter: the job API used to scan the whole job table and match solely on it, which matched nothing and reported count: 0 / an all-zero status_summary for every real pipeline. Where no pipeline is in hand (the single-job route) and no stamp exists, pipeline_id is the most recent (highest-id) referencing pipeline the caller may view: candidates are ordered by sort('id', 'DESC') (a bare range(0, 1) returned a storage-order pick that could differ between two identical requests) and walked past denied ones, so an unlucky first candidate no longer withholds an id the caller is entitled to and only "no candidate is viewable" is null — seeing a job is not authority to learn which run it belonged to. The direction is deliberate: the only realistic way a job ends up shared is a rerun path that reuses jobs instead of regenerating them, and in that case the lowest id names the original run — the stalest answer available — while the caller is polling the newest. And because a tiebreak is not an answer, more than one candidate raises a warning naming the job and every candidate id: a shared job means the invariant PipelineRerunService maintains has been broken, which is reported rather than papered over with an arbitrary-but-stable pick. OWNING_PIPELINE_CANDIDATE_LIMIT bounds the walk at 5 (down from 50, which was sized for a world where a second candidate was silent). The lookup is also invoked lazilyJobApiController::getJob() hands it to the formatter as a \Closure, so the entity query plus pipeline load it costs are not paid on this polling endpoint when the job's metadata already carries a usable stamp; see PIPE-7. Each pipeline-scoped surface additionally filters its jobs by per-job access('view'): pipeline permissions never imply job access source FlowDropPipeline::getJobs() :668 / ::addJob() :807; writers JobGenerationService.php:269, StateGraphOrchestrator.php:1269; readers PipelineJobApiController::loadPipelineJobs() :281-290, PipelineApiController::getPipelineJobs() :284-353, JobApiController::resolveOwningPipelineId() :145-185; routes flowdrop_pipeline.routing.yml:177-211, flowdrop_job.routing.yml:64-80 PipelineJobApiPayloadShapeTest (::testRealPipelineJobsAreResolvedFromThePipeline, ::testJobsOfAnotherPipelineAreNotIncluded); JobApiControllerPayloadShapeTest (::testGetJobReturnsTheJobShape, ::testOwningPipelineIdIsWithheldWhenThePipelineIsDenied, ::testOwningPipelineIdIsTheMostRecentReferencingPipeline, ::testDeniedFirstPipelineFallsThroughToTheViewableSecondOne, ::testSharedJobWarnsNamingEveryCandidatePipeline); PipelineJobAccessControlTest::testPipelineAndJobPermissionsAreIndependent
PIPE-6 Access to the job API is decided at the route, and the access result keeps its cacheability. All three routes carry _permission and a _custom_access callback returning an AccessResultInterface, which Drupal ANDs. PipelineJobApiController::accessPipelineJobs() returns $pipeline->access('view', $account, TRUE) verbatim, so cachePerPermissions() / cachePerUser() / the pipeline entity's cache dependency reach the router instead of being thrown away by an if (!$pipeline->access('view')) bool cast; JobApiController::accessJob() does the same for a job, delegating to $job->access('view', $account, TRUE) and returning the handler's result verbatim — the entity access handler is the single authorization model for a job, so administer flowdrop, administer flowdrop_job types, view any and ownership-checked view own all reach this route, along with any hook_flowdrop_job_access(). A controller must not re-implement a subset of those tiers: doing so both locks out roles the route's _permission admits (Drupal ANDs the two) and silently ignores access hooks that the pipeline-scoped list endpoint honours. Cacheability therefore varies by tier — user.permissions throughout, plus user and the job entity on the ownership tier only. view any flowdrop_job is not authority over a pipeline, so a foreign pipeline is refused before the controller runs and nothing about it — not its jobs, not its status_summary, not its label — is ever assembled. No hand-rolled {error: 'Access denied', message} 403 body remains in these controllers; accessDeniedResponse() no longer exists. The body is instead restored for the whole /api/flowdrop/ surface by FlowDropApiExceptionSubscriber, because Drupal's own exception subscribers do not render JSON here: these routes declare no _format, so the request format is html, ExceptionJsonSubscriber never fires, and an unhandled denial reaches a JSON-parsing client as a themed HTML error page. ⚠ Note the sibling /api/flowdrop/pipeline/{id} and .../status routes still spell errors with ApiResponseTrait's {success: FALSE, error} envelope — this surface has two error shapes, deliberately un-unified, and the subscriber emits the {error, message} one. A missing entity returns allowed(), never neutral(): AccessManager::check() seeds the chain with allowed() and andIf()s every check, and AccessAwareRouter throws AccessDeniedHttpException for anything that is not isAllowed(), so a neutral result renders exactly the 403 the branch exists to avoid — the allowed-because-missing result instead carries that entity type's list cache tags so it stops being cached the moment the id exists. The consequence is decided and accepted (F6): 404-for-missing is answered before 403-for-denied, making these endpoints an existence oracle for a caller holding the route permission. That is a trade, not an oversight — the documented 404 body predates the access check and fdnpm parses it, ids are opaque serials that leak nothing beyond existence, and PipelineApiController::getPipeline() has always behaved this way PipelineJobApiController::accessPipelineJobs() :87-100; JobApiController::accessJob() :232; wiring flowdrop_pipeline.routing.yml:188, :206, flowdrop_job.routing.yml:75; the 404s they let through PipelineJobApiController.php:118-123, :166-171, JobApiController::getJob() :65-101; JSON error rendering src/EventSubscriber/FlowDropApiExceptionSubscriber.php PipelineJobApiPayloadShapeTest (::testPipelineJobsRouteIsWiredToTheAccessCallback and ::testPipelineJobStatusRouteIsWiredToTheAccessCallback — assert each route's declared _custom_access/_permission and drive AccessManager::checkNamedRoute(), so deleting the wiring from the YAML goes red; ::testDeniedPipelineIsRefusedOnBothListEndpointsWithoutLeaking, ::testGetPipelineJobsUnknownPipelineIs404); JobApiControllerPayloadShapeTest (::testMissingJobStill404sThroughTheWiredRouteAccessChain — drives the real AccessManager chain over the declared route requirements, so neutral() cannot be reintroduced silently, ::testMissingJobIsAllowedAtTheAccessLayerSoTheControllerCan404, ::testAccessJobDeniedWithoutPermission, ::testAccessJobAllowedForExistingJob, ::testAdministerFlowdropAloneIsAllowedOnTheJobRoute, ::testAccessJobOwnershipTierReachesTheRoute); FlowDropApiExceptionSubscriberTest
PIPE-7 One formatter owns the job entry shape, and every surface publishing a job entry emits exactly its key set in its order. JobPayloadFormatter::format() is that implementation, consumed by JobApiController::getJob() (bare, no envelope), PipelineJobApiController::getPipelineJobs()'s jobs[] and PipelineApiController::getPipeline()'s data.jobs[]. The keys, in order: id, label, status, priority, node_id, pipeline_id, created_at, started, completed, execution_time_us, retry_count, max_retries, error_message, input_data, output_data, metadata, timestamp. Two formatJobData() copies fed this one contract before and had already drifted — one emitted priority/pipeline_id, the other execution_time_us — which is why the order is part of the contract rather than an implementation detail. Consolidating them was additive in key names: the job payload gained execution_time_us, the pipeline payload gained priority and pipeline_id, and no key was removed or renamed, because fdnpm consumes this from another repo on another release cadence. It was not additive in values — jobs[].pipeline_id changed both wire type and content. It used to be the raw mixed metadata read, so an int stamp serialised as 42; it is now always cast to a string ("42") or null. And since nothing writes that metadata key it was in practice always null, where it now resolves from the pipeline side (PIPE-5). Verified across the whole workspace before shipping — no consumer reads jobs[].pipeline_id, so this is recorded as a change, not rolled back. The sweep covered more than fdnpm's own source: both OpenAPI specs (flowdrop-io.docs, demos/fd-node-demo) mention pipeline_id only in the launch-response shape and already type it string; neither documents the job payload at all, so no published reference contradicts this rule; the one genuine external consumer, demos/fd-node-demo, types jobs as Array<Record<string, unknown>> and reads no per-key type; and the demo sites are producers, not consumers. What bounds the residual risk from integrators outside the workspace is that fdnpm's job interfaces never declared the field, so the package never exported a number typing for anyone to have built against. execution_time_us prefers the precise value orchestrators stamp into job metadata and falls back to (completed - started) * 1000000 second granularity, null when the job never completed. That arithmetic is public on the formatter (::executionTimeUs()) and called by PipelineApiController::resolveNodeStatus() for the node_statuses[*].execution_time_us of PIPE-4 — one number published by two payloads that are read side by side, so it has exactly one implementation. A second copy would let the two disagree numerically while every key-set assertion stayed green, which is F3 moved from structure into arithmetic. pipeline_id is narrowed: getMetadataValue() reads off a JSON-decoded blob and is mixed, so only int|string is honoured and an array, object or bool is discarded rather than emitted under a key documented as a string, falling back to the caller-supplied id — and the discard is logged (channel flowdrop_job, naming the job and the received type), because only something outside FlowDrop writes a non-scalar into a persisted metadata blob and a silent discard hides the writer. The caller-supplied id may also be a \Closure(): (int|string|null), invoked at most once and only on the branch that needs it (no usable stamp): that is how JobApiController::getJob() avoids paying an entity query plus a pipeline load on every poll, without copying this narrowing rule into the controller to decide for itself. PipelineJobApiController and PipelineApiController already hold a pipeline id and pass it plainly JobPayloadFormatter::format() :64-96; ::resolvePipelineId() :126-145; ::executionTimeUs() :169-178; ::intValue() :195-200; service wiring flowdrop_job.services.yml; consumers JobApiController::getJob() :65-101, PipelineJobApiController.php:127, PipelineApiController.php:325 and :437 (node_statuses, via ::resolveNodeStatus() :391) JobPayloadFormatterTest (::testFullKeySetAndOrder pins the list and its order; ::testPipelineIdDiscardsArrayMetadataValue / ::testPipelineIdDiscardsObjectMetadataValue / ::testPipelineIdDiscardsBooleanMetadataValueWithNoFallback pin the narrowing; ::testExecutionTimeUsPrefersMetadataStamp / ::testExecutionTimeUsFallsBackToStartedCompleted; ::testAbsentPipelineIdMetadataIsNotLogged / ::testUsablePipelineIdMetadataIsNotLogged keep the log line off the normal paths; ::testPipelineIdAcceptsLazyResolver / ::testLazyResolverMayResolveToNull / ::testLazyResolverRunsWhenTheMetadataStampIsDiscarded and above all ::testLazyResolverIsNotInvokedWhenMetadataCarriesUsableStamp pin the laziness); JobPayloadKeySetContractTest::testExecutionTimeUsAgreesBetweenJobsAndNodeStatuses compares the value jobs[] and node_statuses[*] publish for one job, which is the guard a key-set test structurally cannot give; JobPayloadKeySetContractTest::testEveryJobPublishingSurfaceSharesTheFormattersKeySet — compares all three surfaces against the formatter's live output rather than a written-down list, so a reintroduced local formatter fails even if its author updates the literal
PIPE-8 execution_data.context is FILTERED, not forwarded. It was published verbatim from $pipeline->getExecutionContext(), which makes the contract whatever the engine last parked on the pipeline rather than what was chosen for consumers — a key becomes public the moment some orchestrator writes it, and after one release it cannot be withdrawn without breaking this contract. PipelineApiController::publicExecutionContext() is the choke point; loop_map (SG-17) is removed there. That key is the case that exposed the pattern: a graph-analysis artefact holding every loop's full node membership, meaningless outside the orchestrator, which reached the payload purely as a side effect of being snapshotted. The key has since grown — it now carries per-body-node ancestor sets as well as the bodies (SG-17) — and that is not a contract change precisely because the filter removes the whole key, not selected sub-keys: nothing about the snapshot's internal shape is published, so it can keep changing without a release note. Confirmed against the enlarged snapshot; PipelineExecutionContextPayloadTest passes unchanged. It was withdrawn before ever being released, which is the only moment such a removal is free. This is a deny-list; inverting it into an allow-list needs the live consumers audited first and is tracked separately in #3592400, which also records what is known to be written into the context and why a deny-list does not scale (a new key leaks silently, and trigger-launched runs can park request-shaped data there) PipelineApiController::publicExecutionContext(); formatPipelineData() PipelineExecutionContextPayloadTest::testTheLoopMapSnapshotIsNotServed (asserts the snapshot IS on the entity first, so the test cannot pass on a run that never wrote one)
PIPE-9 Timestamp nullability is not uniform across the payload, and the difference is the contract. lastExecuted is the completed stamp or NULL — a run that has not finished publishes no finish time, on the full route and the lightweight one alike. It used to publish its own creation time on every unfinished run, because completed was declared with the created field TYPE, whose item class stamps REQUEST_TIME in applyDefaultValue() and is not displaced by the definition's ->setDefaultValue(NULL); the field is a plain timestamp now (same int column, no default stamp) and flowdrop_pipeline_post_update_clear_unfinished_completed_stamps() corrects the rows already written. createdAt, by contrast, is never NULL on the full route: a missing created value substitutes date('c'), i.e. now. So one payload carries both conventions in adjacent keys — a fallback for the creation time, a NULL for the finish time — and a consumer must handle NULL for exactly one of them. ⚠ The lightweight /status route spells createdAt as $created ? … : NULL, so the two routes disagree on that one key's fallback; nothing pins that arm. TRANSITIONAL (OPEN-19): the date('c') substitution is a transitional pin — the target publishes NULL for a missing created on BOTH routes; a timestamp is never fabricated full route PipelineApiController::formatPipelineData() :239-246; lightweight :177-178; field FlowDropPipeline::baseFieldDefinitions() (completed, timestamp) PipelineApiPayloadContractTest::testSummaryKeepsItsShapeForPipelineWithoutJobs (an unfinished run reaches the arm without the field being cleared, both routes report NULL, createdAt is still a string, and an explicit stamp is still published); PipelineUpgradePathTest::testOnlyUnfinishedRunsLoseTheirCompletionStamp

RT-PLAY — the playground JSON API (the chat polling contract)

The surface the Svelte playground in the fdnpm monorepo polls: session list/create/read/delete, the message poll, send/stop/reset, and the two single-message routes. Same cross-repo asymmetry as RT-PIPE — a renamed key breaks a consumer in another repo on another release cadence with nothing failing here — but the casing convention is the opposite one, and that difference is itself the contract.

These payloads are uniformly camelCase, and they sit inside a snake_case envelope. workflowId, createdAt, updatedAt, sequenceNumber, nodeId, sessionId, resetCount, hasMore, hasOlder, sessionStatus — every entity-facing key — beside pagination.has_more from the shared trait (API-5). Neither may be normalised toward the other, and no snake_case alias may be added next to a camelCase key.

ID Rule Impl Pinned
PLAY-1 The playground is addressed and published by UUID, with exactly one deliberate exception. Every route parameter (session_id, message_id) is a UUID resolved by loadSessionByUuid() / loadMessageByUuid(), and every published id — the session row's, the message row's, and the message row's sessionId — is the UUID, never the entity serial. The exception is the list filter ?ids=, which takes comma-separated entity serials, intval-parsed with non-positive values dropped: it is an internal narrowing the playground page builds from ids it already holds, not a client-facing identifier. So one endpoint accepts serials on the way in and answers UUIDs on the way out, deliberately PlaygroundApiController::loadSessionByUuid()/::loadMessageByUuid(); ?ids= parse :102-113; SessionService::formatSessionForApi()/::formatMessagesForApi() PlaygroundApiPayloadShapeTest::testListSessionsRowAndPaginationShape (id is the session UUID), ::testGetMessagesRowShape (id and sessionId are UUIDs), ::testListSessionsIdsFilterNarrowsToTheNamedSessions (the serial-in / UUID-out split in one call)
PLAY-2 Creation answers the list row. createSession and getSession both return SessionService::formatSessionForApi() under {success, data} (201 on create, 200 on read), which is byte-for-byte the shape the list publishes in data[]: id, workflowId, name, status, createdAt, updatedAt, metadata, executions, owner in that order, with owner exactly {id, name}, createdAt/updatedAt ISO-8601 strings (date('c'), never unix ints), and executions an empty list — not NULL — for a session that never ran. A client may therefore insert a creation response straight into its list without a re-fetch, which is what makes the single formatter load-bearing rather than tidy PlaygroundApiController::createSession() :228-231, ::getSession() :260-262, ::listWorkflowSessions() :150-156; SessionService::formatSessionForApi() PlaygroundApiPayloadShapeTest::testCreateSessionReturns201WithTheSessionRowShape, ::testGetSessionPublishesTheSessionRow, ::testListSessionsRowAndPaginationShape
PLAY-3 The message poll has its own envelope, with the flags at the TOP level. getMessages is the one door on this controller that builds its response by hand rather than through successResponse()/paginatedResponse(): {success, data, hasMore, hasOlder, sessionStatus} in that order, no pagination block, and no has_more. The three flags are siblings of data, and each says something different: hasMore is page fullness (count($data) === $limit) — an inference, and deliberately so, since it is free; hasOlder is an authoritative bounded lookup (hasMessagesBefore()) rather than an inference, so a client scrolling up never pays a speculative empty fetch at the exact-multiple boundary; sessionStatus rides along so a poller needs no second request to learn the turn finished. ?since= and ?before= are the forward and backward cursors and are honoured only when ctype_digit, so a non-numeric cursor is ignored rather than refused. A refactor "unifying" this with the shared envelope would move hasMore under pagination.has_more and break every polling client PlaygroundApiController::getMessages() :324-383 PlaygroundApiPayloadShapeTest::testGetMessagesEnvelopeIsBespokeWithTopLevelFlags (the five keys in order, no pagination, no has_more), ::testGetMessagesHasMoreTracksPageFullness, ::testGetMessagesHasOlderIsAuthoritative, ::testGetMessagesSinceCursorReturnsOnlyNewerMessages, ::testGetMessagesIgnoresNonNumericCursors
PLAY-4 One message shape, three doors, base keys always present. SessionService::formatMessagesForApi() is the single formatter behind the getMessages list, the single-message getMessage route and the sendMessage echo, so all three publish the same row. Its base key set is id, sessionId, role, content, timestamp, status, sequenceNumber, nodeId, metadata in that order, always present; the lineage and presentation keys (hierarchy, tags, display, toolArtifacts, parentMessageId, executionId, rootPipelineId, parentPipelineId) are appended only when the message carries them — the opposite of the interrupt payload's present-and-NULL convention (INT-22), in the same API family, on purpose. The single-message route goes through the batch formatter specifically so its row carries the same resolved lineage as the list's. timestamp is ISO-8601. The lightweight /status companion is a different, four-key shape (id, status, sequenceNumber, timestamp) and is not this row SessionService::formatMessagesForApi(); PlaygroundApiController::getMessage() :747-749, ::getMessageStatus() :794-799, ::sendMessage() :447-451 PlaygroundApiPayloadShapeTest::testGetMessagesRowShape (base keys in order, no snake_case alias), ::testGetMessageReturnsItsOwnSessionsMessage, ::testSendMessageEchoesTheUserMessageInTheListRowShape, ::testGetMessageStatusPublishesTheLightweightPollShape
PLAY-5 The session list is ownership-scoped unless the caller holds view any, and an emptied ?ids= filter means "none", never "all". Without view any flowdrop_session the list — and its count query — carry uid = <current user>, on both the default path and the ?ids= path, so the filter can only ever narrow what the caller could already see. When ?ids= is present but every value parses away (non-numeric, zero, negative), the door short-circuits to an empty page with total: 0 rather than falling through to the unfiltered list, which is the failure mode a naive if (!empty($idsFilter)) produces: an explicit request for no sessions answered with every sibling session in the workflow. Both queries additionally run accessCheck(TRUE) PlaygroundApiController::listWorkflowSessions() :104-113 (short circuit), :115-137 (scope), :159-171 (count) PlaygroundApiPayloadShapeTest::testListSessionsWithoutViewAnyIsScopedToTheOwner, ::testListSessionsIdsFilterIsOwnershipScoped, ::testListSessionsEmptyIdsFilterShortCircuits

RT-SNAP — the snapshot JSON API (wire shape and access)

The HTTP face of the run snapshots RT-INT governs the semantics of. Kept separate from RT-INT deliberately: INT-6..INT-15 are about what a snapshot means to an engine, these rows are about what a consumer reads off the wire and who may read it — the same distinction that keeps RT-PIPE out of RT-ORC.

ID Rule Impl Pinned
SNAP-1 One endpoint, two casings, deliberately. The envelope-level keys this API writes are snake_case — the save acknowledgement {entity_id, execution_id}, the delete acknowledgement {message, execution_id}, and the list row {entity_id, execution_id, workflow_id, status, thread_id, created, changed, node_count} in that order — while data.snapshot is WorkflowSnapshot::toArray(), the DTO's own camelCase (workflowId, executionId, nodeStates, initialInput, iterationCount, threadId, createdAt, updatedAt). So execution_id and executionId are published by the same controller, one nesting level apart, and both are read. The save door accepts either spelling in its body (WorkflowSnapshot::fromArray() normalises) and always answers camelCase on read, so acceptance is lenient and publication is not. node_count is count(getNodeStates()), not the raw blob SnapshotApiController::save() :104-107, ::delete() :146-149, ::list() :200-209, ::get() :83-85; WorkflowSnapshot::toArray()/::fromArray() SnapshotApiPayloadShapeTest::testGetPublishesTheCamelCaseSnapshotBody (full DTO key order, no snake alias), ::testSaveCreatesSnapshotAndAcknowledgesInSnakeCase, ::testSaveAcceptsTheSnakeCaseBodySpelling, ::testDeleteAcknowledgementShape, ::testListRowAndPaginationShape
SNAP-2 Access is enforced in the controller, on the entity, and absence is answered before denial. get() and delete() load the snapshot ENTITY by execution id first and ask it for view / delete (the handler of MEM-9's ladder), answering 404 Snapshot not found for a missing row and 403 Access denied for a denied one, in that order — the same existence-oracle trade PIPE-6 records as decided (F6), reached here through a different mechanism. get() then re-answers 404 if the in-memory state manager and the persistent store both come back empty, so an entity that exists without a readable payload is "not found", never a half-payload. The list door does not delegate to the handler at all: it narrows the QUERY by ownership (applyOwnershipScope()) on top of accessCheck(TRUE), which is why a foreign snapshot is absent from a list rather than 403 on it. Every mutation of the store outside these doors runs accessCheck(FALSE) by design — INT-9 already says access lives at the API layer, and this row is that layer SnapshotApiController::get() :61-85, ::delete() :126-149, ::list() :176-196 (applyOwnershipScope); handler FlowDropWorkflowSnapshotAccessControlHandler SnapshotApiPayloadShapeTest::testGetUnknownExecutionIdIs404, ::testGetForeignSnapshotIsForbidden, ::testDeleteUnknownExecutionIdIs404, ::testDeleteForeignSnapshotIsForbiddenAndLeavesItIntact, ::testGetFallsBackToPersistentStorage; SnapshotAccessTest::testListApiScopedToOwner (the list's ownership scope)

RT-META — the editor metadata doors (categories, workflow schema)

Two routed GET doors the editor reads before it can draw anything: the category list that fills the node sidebar, and the schema snapshot that describes a workflow's input and output ports. Both shipped without a rule in any section. They are grouped because they share what makes them different from RT-PIPE / RT-PLAY / RT-SNAP — they are cacheable reads, so their contract includes what a response varies by and what invalidates it, not just its keys. Between them they also hold the two response shapes GR-API does not govern.

ID Rule Impl Pinned
META-1 GET /api/flowdrop/categories serves the standard {success, data} envelope, on a CacheableJsonResponse. The cacheable trait methods (cacheableSuccessResponse() / cacheableErrorResponse()) emit byte-identical keys to successResponse() / errorResponse() — they are API-5's first two envelopes on a different response class, not a fourth and fifth shape, and a door must not hand-build a cacheable body to avoid them. data is a list of rows whose five keys are name, label, icon, color, description in that order, every value a JSON string. ⚠ name is the entity id, not a machine-name field beside a display name — there is no separate id key, so a consumer treating name as human-readable renders a machine name in the sidebar. Only categories whose status() is TRUE are published; a disabled category is absent, not flagged, so a consumer cannot tell "disabled" from "deleted" CategoriesController::getCategories() modules/flowdrop_node_category/src/Controller/Api/CategoriesController.php:40-59; envelope src/Controller/Api/ApiResponseTrait.php:119-127 CategoriesControllerTest::testCategoryPayloadShape (the five keys, their order, name as the entity id, and every value a string); ::testNoCategoriesReturnsEmptySuccessEnvelope (the envelope keys with an empty list); ::testDisabledCategoryExcluded
META-2 A cacheable door's declarations are its access control, not decoration. The categories route sets no_cache: TRUE, which opts it out of the internal page cache so that the dynamic page cache serves it from the response's own cacheability metadata instead — which makes a missing cache context a data leak, not a performance note: without user.permissions one caller's permission-scoped list is served to the next. The response therefore declares contexts user.permissions and languages:language_interface, the list tag config:flowdrop_node_category_list (covering additions and deletions), and every served entity as a cacheable dependency (so editing one category invalidates the list). The per-entity tag reads config:flowdrop_node_category.flowdrop_node_category.<id> — the prefix appears twice, which is what Drupal produces for this entity type, and it is pinned verbatim rather than tidied because a purge integration keys on the real string contexts/tags CategoriesController.php:33-38,47; route flowdrop_node_category.routing.yml:15-23 CategoriesControllerTest::testResponseDeclaresItsCacheability
META-3 API-7's backstop obligation binds the cacheable arm too, and this door was the counter-example. getCategories()'s blanket catch (\Exception) returned $e->getMessage() straight into the response body and logged nothing, so whatever the storage layer said — a class name, a failed query, a filesystem path — was published to any caller holding view flowdrop_node_category, and the test asserted the leaked string as if it were the contract. It now logs at error on the flowdrop_node_category channel and returns the fixed string Failed to fetch categories, exactly as API-3/API-7 require of the non-cacheable doors. The lesson generalises: a rule written against errorResponse() does not automatically reach cacheableErrorResponse(), and a test can pin a defect as firmly as it pins a promise CategoriesController.php:61-70 CategoriesControllerTest::testStorageExceptionYieldsCacheableErrorEnvelope (the fixed string, and the caught message absent from the body)
META-4 GET /api/flowdrop/workflows/{id}/schema publishes a bare document — a fourth shape beside API-5's three. The 200 body is exactly {schema_version, parameter_schema, output_schema} in that order, snake_case, with no success key and no data wrapper; the controller does not use ApiResponseTrait at all. parameter_schema and output_schema are the stored snapshot verbatim, and when a workflow declares no ports they are JSON null, not {} and not omitted — an fdnpm consumer distinguishing "no ports" from "key missing" depends on that. The snake_case here is the contract in the same sense API-5's has_more is: it sits beside the playground door's camelCase rows, so normalising either toward the other breaks a consumer WorkflowSchemaController::getSchema() modules/flowdrop_workflow/src/Controller/Api/WorkflowSchemaController.php:88-92,101 WorkflowSchemaControllerTest::testDefaultResponseShape (the three keys, order and casing, snapshots verbatim); ::testNullSchemasRoundTripAsNull
META-5 Its 404 is a fifth shape: a bare {error: 'Workflow not found.'} — hand-built, no success: false, not errorResponse(), and the trailing full stop is part of the literal. Absence is answered before anything else is read, so an unknown id never reaches the schema-version or ETag logic. This is recorded as the contract rather than corrected because the editor already reads this key; aligning it with {success, error} is a consumer-visible change needing the cross-repo conversation, not a tidy-up WorkflowSchemaController.php:58-59 WorkflowSchemaControllerTest::testWorkflowNotFound
META-6 The ETag is the quoted schema version, and it carries the body variant. "<schema_version>" for the plain document, "<schema_version>+annotated" for ?annotated=1. The variant used to be missing, and that was a live cross-repo bug: ?annotated=1 returns a different document from the same schema version, so both variants shared one tag — a client holding the plain body's ETag asked for the annotated one, was told 304, and went on serving a body with every title and description missing (and the reverse). The Drupal cache contexts below already varied on the query argument; the ETag was the one layer that did not, which is why nothing caught it. Any future query argument that changes the body must join the tag the same way WorkflowSchemaController.php:62-71,103 WorkflowSchemaControllerTest::testEtagCarriesTheAnnotatedVariant (both tags, both variants); ::testEtagIsQuotedSchemaVersion
META-7 A conditional request is answered against its own variant, by exact string equality and nothing more. If-None-Match is compared with === against the variant's tag: a match returns 304 with the ETag header and the body {} (JsonResponse(NULL) coerces the absent data to an empty object, so the body is not truly empty) and short-circuits before the plugin manager is ever consulted; a mismatch falls through to the full 200. Neither the wildcard *, nor a comma-separated tag list, nor weak (W/) comparison — all defined for If-None-Match by RFC 9110 — is implemented, and that is the contract rather than a gap: a caller sending any of them gets a correct-but-unconditional 200, whereas a partial wildcard implementation would 304 a client that holds no copy at all. The annotated variant keeps its own 304, so fixing META-6 did not trade one bug for a permanent cache miss WorkflowSchemaController.php:73-78 WorkflowSchemaControllerTest::testCrossVariantConditionalRequestIsNotShortCircuited (both directions); ::testAnnotatedVariantStillGetsItsOwn304; ::testConditionalRequestIsExactMatchOnly (*, a tag list, W/, and an unquoted tag); ::testIfNoneMatchReturns304 (the tag header and the {} body); ::testIfNoneMatchMismatchFallsThroughTo200
META-8 The two variants are cached on deliberately different terms. Plain: Cache-Control: public, max-age=60. Annotated: Cache-Control: no-cache, because the reattached titles and descriptions come from live plugins and are translated, so a shared 60-second copy would serve one interface language's annotations to another. The cacheable metadata declares the context url.query_args:annotated always — the variant must vary even on the path that adds nothing else — plus languages:language_interface only when annotated, and exactly one tag, config:flowdrop_workflow.flowdrop_workflow.<id> WorkflowSchemaController.php:94-109 WorkflowSchemaControllerTest::testDefaultCacheControlIsPublicSixtySeconds; ::testAnnotatedCacheControlIsNoCache; ::testCacheMetadataContextsAndTags (the always/only split and the single tag)
META-9 NEW RULE, not yet pinned: both doors are GET-only and gate on a named permission — categories on view flowdrop_node_category+administer flowdrop, the schema door on the dedicated access workflow schemas — enforced purely at the route, with no controller-side access check to fall back on (unlike SNAP-2's ladder). That makes the route definition the whole access contract, and a permission silently widened or a method list silently extended would be invisible to RT-META's other bindings: they call the controllers directly, so they pass unchanged if a route loses its _permission entirely. Two further teeth in the rule: the permission string is contract verbatim, + separators included+ is OR, so one more alternative widens who may read the door — and the method list must be non-empty, because an empty one means every method, so dropping methods: opens POST/PUT/DELETE on a controller written for GET. The named permissions must also be declared by some module: a route requiring a permission nobody defines is not a locked door but a typo that 403s the administrator too flowdrop_node_category.routing.yml:15-23; flowdrop_workflow.routing.yml:138-144; permission declared flowdrop_workflow.permissions.yml:25-27 EditorMetadataDoorRouteContractTest::testDoorRouteContract (both doors: path, the verbatim permission string, the method list) + ::testEveryNamedPermissionExists (every alternative resolves to a declared permission)

RT-OCX — external invocation (the orchestration connector's contract)

How an outside automation platform starts a FlowDrop workflow and gets its result back, via contrib orchestration's API. This surface had no rules at all, which is why every rule below describes something that was broken when it was written: the module shipped, was never used end to end, and each defect survived because nothing pinned the behaviour.

This module is an adapter, and the narrowness is deliberate. It translates between the Orchestration API and the trigger/pipeline system and owns no business logic. Where a rule says the connector does not do something — validate, push, retry — that is the contract, not a gap waiting to be filled. Widening it means taking on delivery or validation semantics that belong to the runtime or the calling platform.

ID Rule Impl Pinned
OCX-1 Initial data for an orchestration.invoke run is built by OrchestrationRequestBuilder::buildInvokeRequest() and is flat — extracted trigger data at the top level, plus trigger_config_id and trigger_node_id — exactly as for entity, form and cron triggers. Since AbstractTrigger::process() emits the whole initial-data array as the node's data output, the payload is reachable at data.payload, matching OrchestrationTrigger::getOutputSchema(). No node-id-keyed nesting: the connector used to build [$nodeId => $triggerData, …], so the payload arrived at data.{nodeId}.payload while the advertised schema said data.payload, and the only path that worked embedded the node id in every downstream expression OrchestrationRequestBuilder.php:buildInvokeRequest(); caller InvokeTriggerService.php InvokeRequestEnvelopeTest::testInitialDataIsFlat
OCX-2 Results are retrieved by polling only. The connector dispatches no outbound HTTP and subscribes to no runtime execution event. A run with no pipeline entity is invisible externally, and the in-memory flowdrop_runtime:synchronous engine is therefore refused — a trigger configured for it is logged and run on synchronous_pipeline instead, rather than silently producing a run no caller can ever observe. (Push delivery existed and could never fire: it listened on RealTimeBroadcastEvent, which no entity-backed engine emits. It was removed rather than repaired — see ORC-15 for the event that does announce terminal outcomes, and note that consuming it would mean owning timeouts/retry/dedup) InvokeTriggerService.php orchestrator guard; PollEventSubscriber InvokeToPollRoundTripTest::testUnpollableEngineFallsBackToPollableRun (the guard arm; the no-push arm is a contract of omission)
OCX-3 Poll results are scoped by a positive marker — input_data['source'] === 'orchestration' — never by the presence of trigger_config_id. Cron, entity and form triggers set that key too, so the old test served their runs, output_data included, to any external platform. The marker has exactly one definition, InvokeTriggerService::SOURCE, written by OrchestrationInvoke::extractTriggerData() and read by PollEventSubscriber. It travels in the run's initial data — which is what the orchestrator persists as input_data (OCX-1) — and not in the orchestration options, which configure the orchestrator and reach input_data never. The marker used to be a bare literal in the plugin with an inert second copy in the options, so producer and consumer could drift apart with nothing failing: a poll would just return nothing, forever, with no error The marker cannot be a query condition — input_data is a string_long base field holding a JSON document — so the guard runs in PHP over each loaded page, which is what makes OCX-9's paging rule load-bearing rather than cosmetic OrchestrationInvoke::extractTriggerData() writer; PollEventSubscriber::isExternallyInvoked(), called from both handlers PollScopingTest::testOnlyExternallyInvokedPipelinesAreExposed + InvokeToPollRoundTripTest (3: the invoke→poll join, the writer/reader identity, the fallback engine)
OCX-4 The connector honours orchestrator_settings.pipeline_id / pipeline_mode through determinePipelineId(), like every other trigger. It used to hardcode pipeline_{workflowId}_{invocationId}, so reuse and singleton were silently ignored OrchestrationRequestBuilder::determinePipelineId() InvokeRequestEnvelopeTest::testPipelineModeIsHonoured
OCX-5 Every key the connector writes into conditions is schema-valid. flowdrop_trigger.conditions is a closed mapping whose only extension point is custom (type: ignore, "event-type specific"), so payload_schema and required_fields live there. Writing them alongside entity_types/form_ids/cron_schedule produced four violations and — because strict config schema is on by default in kernel and functional tests — made the module untestable OrchestrationInvoke::getParameterSchema(); reader ServicesProvider::buildService() OrchestrationTriggerConfigSchemaTest (2)
OCX-6 The adapter validates nothing. payload_schema and required_fields are published to the external platform as declarative metadata so it can render and enforce its own form; a payload violating them is passed through unmodified. Enforcement belongs to the platform and to the workflow. Only top-level schema properties become typed ServiceConfig fields — nested structure rides inside payload as an object, and recursive schema→field translation is deliberately not attempted ServicesProvider::buildService() partial, not structural — a contract of omission can be asserted: feed the adapter a payload violating its published payload_schema and require it through unmodified. Only OCX-5's shape test reaches this indirectly today, which is what ◐ means; it had been marked N/A, which claimed the promise was untestable rather than untested
OCX-7 execute() answers immediately with one of exactly three statuses — completed (final), interrupted (paused; metadata.interrupt.interrupt.id identifies the interrupt, per INT-1) or queued — and execution_id is the pipeline entity id in all three cases, which is the identifier the poll response returns as pipeline_id. That single identifier is the correlation contract, and it is what makes an asynchronous round trip work inside contrib's synchronous execute(): array\|string signature. Extends SG-15 to the async engine, which SG-15 does not cover: that engine returns the synthetic request id and puts the entity id in metadata.pipeline_id, so the connector republishes it InvokeTriggerService::normaliseExecutionId() ExecutionIdCorrelationTest (4)
OCX-8 Poll reports every terminal run — PipelineStatus::terminalStatuses(), i.e. cancelled included, not just completed/failed — so a polling caller always reaches an end state. The one hole this used to carry — an interrupt that expires stranding its pipeline paused forever, hence reaching no terminal status and appearing in no poll — is closed outside this module by INT-18, which cancels the stranded run and announces it. A paused run still appears in no poll while it is genuinely paused; that is the rule working, not a gap A backlog may need more than one poll to arrive (OCX-9), but nothing is dropped on the way PollEventSubscriber.php status filter PollScopingTest::testCancelledRunsAreReported (+ INT-18 for the expiry arm)
OCX-9 Both poll handlers page. Each query is range()-bounded to PollEventSubscriber::PAGE_SIZE (50) and only that page is loaded, so a first poll (timestamp: 0, or an empty/non-numeric id) costs the same memory as any other — it used to select every terminal pipeline the site had ever run and loadMultiple() the lot, which is an OOM rather than a slow query. Paging is only sound because the caller can always walk to the end, and two things guarantee that. No split cursor: ids are unique, but completion timestamps are not, so a timestamp page whose last row shares its completed value with rows outside the page is extended to cover the whole group — otherwise the caller's next completed > t skips the remainder for good (the sort is completed ASC then id ASC, which makes the ordering total and the held-back group a clean suffix). No empty answer while rows remain: the externally-invoked filter (OCX-3) runs in PHP, so a page can survive the query and then filter down to nothing; an empty response reads to the caller as "nothing new" and would park its cursor forever, so the handlers keep paging until something is emitted or the result set is genuinely exhausted PollEventSubscriber::loadCompletedPage(), ::loadOrdered(), the paging loop in both handlers PollPaginationTest (5: timestamp and id backlogs drain exactly once, a shared-timestamp group is not split at the boundary, an all-internal page still advances the caller, a non-numeric id cursor starts at page one)

RT-NET — outbound HTTP safety (the shared SSRF guard)

What stops a node whose target URL is workflow-author input from being pointed at the site's own metadata service, an internal admin port or a neighbour on the private network. One trait, Drupal\flowdrop\Utility\OutboundUrlSafetyTrait, is the whole guard; every node that dials a caller-supplied URL uses it, because a second copy is a second thing to forget to fix.

A check is only worth what the request is bound to. Two of the three rules below exist because validation had drifted apart from the request it was supposed to describe — first through re-resolution, then through redirects. Any new outbound-calling node inherits all three or it inherits none of them.

ID Rule Impl Pinned
NET-1 The URL is checked before the request, and the check is pinned to the request. validateUrlSafety() allows only http/https, resolves the host (IPv4 via gethostbyname(), IPv6 fallback via DNS_AAAA) and refuses any address in a private or reserved range, throwing InvalidNodeConfigurationException — which extends \RuntimeException, so a refusal takes the error edge per ERR-1 rather than failing the run. It returns the resolved IP and callers must pass it to dnsPinningOptions(), which pins the request with CURLOPT_RESOLVE (IPv6 bracketed, an explicit port honoured). Validating a name and then handing curl the name is not a check: a hostile resolver answers publicly for the check and privately for the request src/Utility/OutboundUrlSafetyTrait.php; callers HttpRequest.php, CallAndWaitNode.php HttpRequestTest (scheme, unresolvable host, private IPv4, private IPv6, v4/v6/custom-port pinning)
NET-2 The guard survives redirects: every hop is re-validated. Callers also merge redirectSafetyOptions(), whose allow_redirects.on_redirect runs the same validateUrlSafety() against each redirect target and throws before the hop is taken. The pinning in NET-1 binds the original host only, so without this a public host answering 302 Location: http://169.254.169.254/latest/meta-data/ walked straight past a guard that had already reported success, and allow_internal_requests: false was a promise the module could not keep. Hops are re-validated, not refused — a public-to-public redirect (shortener, canonical-host bounce, HTTP→HTTPS) is still followed, so this is not a behaviour change for workflows that were not being attacked. ⚠ The residual window is stated rather than papered over: a hop's hostname is resolved for the check and resolved again by the client for the request, because there is no per-hop CURLOPT_RESOLVE to set — per-hop DNS rebinding therefore remains possible where the initial request's pinning excludes it OutboundUrlSafetyTrait::redirectSafetyOptions(); merged at HttpRequest::process(), CallAndWaitNode::dispatchCall() OutboundUrlSafetyTraitTest (link-local hop, RFC1918 hop, public hop still followed) + HttpRequestTest (2, end to end) + CallAndWaitNodeTest::testRedirectIntoPrivateRangeIsRefusedAndCancelsTheInterrupt
NET-3 The redirect posture is stated, not inherited, and it tracks the node's own permission. max 5, strict FALSE (a 301/302 on a POST degrades to GET, as browsers do), referer FALSE (the origin URL and its query string are never handed to the next host) and hop protocols held to http/https so the scheme check cannot be sidestepped mid-chain. These restate Guzzle's defaults deliberately: the posture is a decision, not whatever the client happens to ship. When the node sets allow_internal_requests: true the per-hop check is dropped exactly as the initial check is — a node told it may talk to the internal network may also be redirected within it — but the hop count stays bounded either way OutboundUrlSafetyTrait::redirectSafetyOptions() OutboundUrlSafetyTraitTest (option shape both ways; a non-http hop refused by the protocols list; a private hop followed under allow_internal_requests)

RT-MD — markdown rendering safety (the markdown_to_html node)

What stops model-authored markdown from putting markup on the page. MarkdownToHtml has two conversion paths, and league/commonmark is only a suggest entry in composer.json — so on a site that has not installed it the regex fallback is not a fallback at all, it is the whole converter, running over text an LLM wrote. Both paths owe the same promise, and only one of them gets it from a library.

The ordering is the contract. Escaping each text node as it is emitted is the arrangement that failed: prose, list items and blockquotes were rebuilt from the buffer without it, and the paragraph arm's "this block already starts with a tag, leave it alone" shortcut then handed markup straight through for any block the author opened with a tag. Escaping the whole input once, before any pattern runs, is what makes that shortcut sound — after it the only < in the buffer is one the converter wrote — and it is why no arm downstream may escape a second time.

ID Rule Impl Pinned
MD-1 Every text node the fallback emits is HTML-encoded, because the whole input is encoded once up front, before any block or inline pattern runs — prose, list items, blockquote lines, headings and code alike. Markup written into the markdown can therefore never reach the page as markup, including in the one case a per-emission-point escape structurally could not cover: a block that opens with a tag, which the paragraph arm skips MarkdownToHtml::regexConvert() (the htmlspecialchars() call preceding every pattern) Unit\Plugin\FlowDropNodeProcessor\MarkdownToHtmlTest::testFallbackEscapesParagraphText, ::testFallbackEscapesListAndQuoteText, ::testFallbackEscapesAmpersandsAndLeavesQuotesInProse
MD-2 Encoded exactly once, and quotes are handled where quotes matter. The up-front pass uses ENT_NOQUOTES, so text nodes keep literal quotes (harmless there, and what CommonMark does) and no entity is double-encoded — &amp;lt; never appears in the output. The three sites that interpolate a value into a double-quoted attribute — link href, image src, image alt — escape the quote characters themselves via escapeAttributeValue() rather than re-running htmlspecialchars(), so an attribute value cannot end its own attribute and open an event handler. URLs additionally pass UrlHelper::stripDangerousProtocols() MarkdownToHtml::regexConvert(), ::escapeAttributeValue() MarkdownToHtmlTest::testFallbackEscapesQuotesInAttributeValues, ::testFallbackEscapesAmpersandsAndLeavesQuotesInProse, ::testFallbackConvertsLink, ::testFallbackStripsDangerousProtocolFromImageSource

RT-CRON — cron trigger scheduling (firing and reporting agree)

Two components read the same stored cron_schedule and must never disagree about it: CronScheduleMatcher decides whether a trigger fires, and TriggerConfigApiController::history() tells an operator when it will fire next. They are the only readers, they are in different modules, and every defect in this area so far has been the two of them describing the same stored value differently.

The endpoint is how an operator learns a trigger is broken. It is the only view of a schedule that is otherwise invisible until it fires or fails to. So a report that cannot distinguish "fine, nothing due" from "will never fire" is not a cosmetic gap — it is the diagnostic being absent exactly when it is needed.

ID Rule Impl Pinned
CRON-1 A cleared cron expression means not scheduled, and both readers say so. In advanced mode, cron_expression === "" makes shouldTriggerRun() return FALSE with a DEBUG log line, and makes the history endpoint report no next run. It used to return TRUE — the emptiest configuration the module can hold produced the most aggressive schedule it can express, firing on every cron pass, while the endpoint reported the same trigger as idle. Blank-means-inactive is the reading every comparable scheduler uses (a blank crontab line is ignored; Kubernetes CronJob and EventBridge make the field required; Jenkins deactivates the timer; Airflow spells it schedule=None), and CronRun declares a default of 0 * * * *, so blank is not a state the form produces. Both sides compare with === "" and not empty(), which is what keeps the malformed expression "0" out of this branch: it reaches the cron library and is reported as invalid rather than as an unfilled field, which would tell an operator to go fill in something already filled in. Reported once, not per pass: the skip is logged at DEBUG rather than WARNING because the branch is reached on every cron pass for as long as the trigger stays blank, and a standing state belongs on the status report — flowdrop_trigger_runtime_requirements() names the affected triggers there until an author fixes them. ⚠️ Sites holding this state on upgrade see those triggers stop firing; there is no post_update, because the config is valid and only its meaning changed CronScheduleMatcher::evaluateAdvancedSchedule(); TriggerConfigApiController::calculateNextRunTime(); flowdrop_trigger.install CronScheduleMatcherTest::testAdvancedScheduleEmptyExpressionDoesNotRun (asserts DEBUG and no WARNING), ::testAdvancedScheduleZeroExpressionIsTreatedAsInvalid + TriggerHistoryStoredScheduleTest::testStoredZeroExpressionReportsAnInvalidExpression
CRON-2 Every reason a schedule has no next run carries a stable code; a bare NULL is reserved for "nothing due". next_run: NULL alone is ambiguous — it is what a perfectly healthy schedule reports between fires — so calculateNextRunTime() returns int and throws UnusableScheduleException instead, carrying the code the endpoint reports as next_run_problem.code: no_expression (advanced mode, expression cleared) or invalid_expression (malformed, or well-formed but unsatisfiable). Severity is not flattened: an unusable timezone does not stop the trigger — the resolver substitutes UTC exactly as CronScheduleMatcher does — so it still reports a real next_run alongside an invalid_timezone code, and an expression problem deliberately overwrites it as the more consequential of the two. A broken stored schedule is not a broken request, so this stays a 200 carrying the diagnosis TriggerConfigApiController::history(), ::calculateNextRunTime(); Exception\UnusableScheduleException; Trait\CronScheduleTimezoneTrait TriggerHistoryStoredScheduleTest (5: empty timezone is no problem, simple mode, invalid timezone still yields a next run, invalid expression, cleared expression) + TriggerConfigApiControllerTest::testHistoryAdvancedModeEmptyExpressionReportsNoExpression

RT-TRIG — cron trigger firing decisions (overlap, jitter, run state)

RT-CRON governs whether the stored schedule says a trigger is due, and that the two readers of it agree. These rules govern what happens once it does: whether an overlapping run is skipped, deferred or kills its predecessor, whether the firing is held back to spread load, and what the trigger's own run history records. All three services decide unattended, and a wrong answer is a duplicated or silently dropped production run — the plan's tier-2 blast radius. ORC-15 already governs the announcement an overlap Cancel emits; these rows govern the decision that emits it.

ID Rule Impl Pinned
TRIG-1 Overlap is decided against the workflow's own non-terminal pipelines, and the four policies are genuinely four behaviours. "Active" means a pipeline of the SAME workflow in pending, running or paused — a terminal one and another workflow's run are both ignored — and with none active every policy proceeds with the caller's trigger data unchanged. With one active: Skip logs and blocks; Buffer stores at most ONE deferred run ({trigger_data, context, blocked_by_pipeline, buffered_at} in state, keyed per trigger config) and blocks — a second firing while one is buffered is dropped, never stacked or overwritten, so the buffer is a one-slot mailbox and not a queue; Cancel cancels and announces every active pipeline (PipelineCancellationAnnouncer, = ORC-15) and proceeds, touching no jobs; Terminate first cancels every job in JobStatus::activeStatuses() so a queue worker cannot pick one up after the pipeline is gone, then does what Cancel does. An unrecognised policy string falls back to Skip — the only fail-safe direction, since the alternatives destroy a running pipeline. A buffered run is released on the next firing once its blocking pipeline is terminal or gone, and when released its OWN payload replaces the current firing's; a buffer whose blocked_by_pipeline is unusable is released immediately rather than stranded OverlapPolicyHandler::resolveExecution() :60-98, ::popReadyBuffer() :109-134, ::loadActivePipelines() :155-170, ::applySkip()/::applyBuffer()/::applyCancel()/::applyTerminate() OverlapPolicyHandlerTest (21 cases: no-active/terminal/other-workflow proceed, Skip, pending+paused block, unknown-policy fallback, one-slot buffer + no-stack + release-with-own-payload + still-blocked + deleted-pipeline + missing-id release, Cancel announces and leaves jobs alone, Terminate cancels active jobs only, per-config scoping)
TRIG-2 Jitter is rolled once and never re-rolled. shouldDelay() returns FALSE immediately, writing no state, when maxSeconds <= 0 — jitter off is off, not a zero-length window. Otherwise the bounds are clamped (min = max(0, $minSeconds), max = max($min, $maxSeconds), so a negative minimum becomes 0 and a minimum above the maximum is used as both bounds) and, on the FIRST pass, one random_int($min, $max) is drawn, the resulting fire time is persisted under flowdrop_trigger.jitter.{configId}, and the trigger is held. Every subsequent pass reads the stored fire time rather than drawing again — a re-roll on each cron pass would let a trigger be deferred indefinitely — and releases the moment now >= fireAt (inclusive), deleting the state so the next due firing rolls afresh. State is scoped per trigger config, so one trigger's window never holds another back JitterHandler::shouldDelay() :47-82 JitterHandlerTest (11 cases: disabled writes no state, first pass schedules and holds, a later pass keeps the same fire time, elapsed fires and clears, the now == fireAt boundary, both bound clamps, equal bounds, per-config scoping, and rolled delays staying inside the window)
TRIG-3 A skip is recorded but is not an execution. CronTriggerStateService keeps one State entry per trigger holding last_run, run_count and history. recordExecution() advances all three; recordSkipped() appends a history entry carrying its reason and leaves last_run and run_count untouched, so "when did this last actually run" survives any number of skips — the whole point of a separate recorder. history is newest-first and hard-capped at 10 entries on both writers (array_slice(…, 0, 10)), so an unattended trigger cannot grow its State row without bound. Stored state is normalised on read rather than trusted: a non-array value, a non-numeric counter and a key-preserving history array all come back as the empty/plain-list shape, so a hand-edited or partially-written row degrades instead of fatally typing CronTriggerStateService::getState() :74-105, ::recordExecution() :158-203, ::recordSkipped() :205-234; cap self::MAX_HISTORY_ENTRIES CronTriggerStateServiceTest (16 cases: unknown trigger's empty shape, execution updates all three, skip leaves the stamp and count alone, both histories trimmed to ten, newest-first ordering, skip-before-any-run, reset/delete, per-trigger scoping, and the three normalisation arms)

RT-ST — real-time status contract (RETIRED — surface removed in 2.x)

Removal (2.x, after the code-verified correction). This section was written believing the frontend polls the StatusController (/api/flowdrop-runtime/status/*) endpoints. It does not. The fdnpm editor polls the pipeline API (/api/flowdrop/pipeline/{id} + /status), which reads persisted entity state — see SG-15 and RT-PIPE. The StatusController read the request-scoped in-memory StatusTracker (only a logger injected, no persistence), so a cross-request poll always returned empty → permanent 404 for any async/queued run. The whole surface was therefore deleted: the /api/flowdrop-runtime/status/* routes (and the /health + /metrics routes that lived on the same controller), StatusController, StatusTracker, the tracker's DTOs, and the pinning tests (StatusControllerTest, RuntimeStatusApiAccessTest) that only ever mocked the manager and never crossed a request boundary. RealTimeManager keeps broadcasting every update (RealTimeBroadcastEvent); it just no longer accumulates anything for polling. The former T1-12 "reporting" items (pending-node arithmetic, 404-vs-empty, auto-register INITIALIZED-vs-RUNNING, INTERRUPTED end_time) were all confined to this dead surface and died with it. The one live status defect lived elsewhere — the PipelineStatusSubscriber default => arm, folded into (and fixed by) ORC-15.

The retired rows are kept for the record — their sentences describe the deleted surface, and they assert nothing anymore.

ID Rule Impl Pinned
ST-1 (retired) Envelope: {success, data} / {success:false, error} + HTTP status removed
ST-2 (retired) Execution status: {execution_id, status}; empty tracker → 404; exception → 500 removed
ST-3 (retired) Node statuses passed through unchanged; untracked → empty map, no 404 removed
ST-4 (retired) Details: metrics {total,completed,failed,pending,total_execution_time} with defined derivations removed
ST-5 (retired) Execution map fields (snake_case): status, workflow_id, node_count, start/end_time, total_execution_time, error (+data/timestamp) removed
ST-6 (retired) Node map fields: status, node_id, node_type, start/end_time, execution_time, error, output removed
ST-7 (retired) Status vocabulary: initialized\|idle\|running\|completed\|failed\|interrupted — the vocabulary itself survives as the broadcast-event status set (ExecutionStatus.php, + skipped) removed
ST-8 (retired) Unknown executions/nodes auto-registered on update (cron/queue), not dropped removed
ST-9 (retired) Transition payloads: running(node_type,start), completed(execution_time,output_size), failed(error), interrupted(interrupt_id,type) — these payloads still ride the broadcast events (NodeRuntimeService), but the polled map they were specified for is gone removed
ST-10 (retired) Security invariant: permission-gated, NO per-execution ownership check — was safe only because the tracker was request-scoped in-memory removed

RT-MIG — deploy-time workflow migration primitives (WorkflowMigrationService)

How a stored workflow instance follows a node-type schema change after the fact. Deliberately the opposite contract from the workflow doctor (WorkflowDoctorInterface): the doctor repairs an already-broken stored workflow and saves a partial fix even when the result is still invalid; this service assumes presumably-valid input and is all-or-nothing — flowdrop_workflow.migrator refuses with a machine-readable report rather than ever save a broken graph. Both sit on the same WorkflowMutatorInterface. See docs/development/workflow-migrations.md for the full primitive reference and a copy-pasteable hook_post_update_NAME.

ID Rule Impl Pinned
MIG-1 Every migration primitive validates the full mutated graph (WorkflowValidator::validate()) before it may save, and refuses — saving nothing — the moment that validation fails WorkflowMigrationService::finish(), ::migrate() Kernel\WorkflowMigrationServiceTest::testValidationFailedRefusalRestoresStoredShape
MIG-2 A refused migration — whether refused at preflight or after a failed post-mutation validation — leaves the stored workflow byte-identical to before the call snapshot + restore, WorkflowMigrationService::fail(), ::finish() Kernel\WorkflowMigrationServiceTest::testReplaceNodeTypeRefusalLeavesStoredWorkflowByteIdentical, ::testValidationFailedRefusalRestoresStoredShape
MIG-3 Every primitive is idempotent: re-running a migration already reflected in the stored workflow is detected as a Noop and neither mutates nor saves anything each primitive's noop branch, WorkflowMigrationService.php Unit\Service\WorkflowMigrationServiceTest (testReplaceNodeTypeNoopWhenAlreadyMigrated, testInsertNodeBetweenNoopWhenAlreadyPresent, testRenamePortNoopWhenFromEqualsTo, testRewireEdgeNoopWhenUnchanged, testRemoveNodeReconnectNoopWhenNodeAbsent, testMigrateNoopWhenAllOpsNoop); Kernel\WorkflowMigrationUpdateHookTest::testHookMigratesEveryInstanceBatchedAndTolerantOfRefusals (a full batched run repeated twice, byte-identical)
MIG-4 replaceNodeType() refuses, before mutating anything, when a wired or exposed port of the node is neither a key of the caller's port map for its direction nor present under the same name on the new node type WorkflowMigrationService::replaceNodeType(), ::collectUnmappedRefusals() Kernel\WorkflowMigrationServiceTest::testReplaceNodeTypeRefusalLeavesStoredWorkflowByteIdentical; Unit\Service\WorkflowMigrationServiceTest::testReplaceNodeTypeRefusesUnmappedWiredPort, ::testReplaceNodeTypeRefusesUnmappedExposedPort
MIG-5 insertNodeBetween() refuses when the caller supplies no id on the new node, and refuses (rather than overwriting) when that id already exists anchored to a different node type WorkflowMigrationService::insertNodeBetween() Unit\Service\WorkflowMigrationServiceTest::testInsertNodeBetweenRefusesMissingNodeId, ::testInsertNodeBetweenRefusesNodeIdConflict
MIG-6 migrate()'s batch is all-or-nothing: a refusal from any op inside the callable aborts the whole batch immediately and restores the pre-batch snapshot — nothing from an earlier op in the same batch survives WorkflowMigrationService::migrate(), ::fail(), MigrationAbortException Kernel\WorkflowMigrationServiceTest::testMigrateBatchAllOrNothing; Unit\Service\WorkflowMigrationServiceTest::testMigrateAbortsBatchAndRestoresSnapshot
MIG-7 The migration service reads and writes the raw stored shape only (WorkflowDefinitionInterface::getNodes()/getEdges()/getInputPorts()/getOutputPorts()) and never routes through WorkflowDTO::fromArray()'s read-path normalization — no config-default merge, no dangling-edge pruning, no edge-id minting WorkflowMigrationService.php (no WorkflowDTO dependency) Kernel\WorkflowMigrationServiceTest::testMigrationOperatesOnRawStoredShapeWithoutMergingConfigDefaults

RT-UPD — sandboxed update hooks (the irreversible tier)

hook_post_update_NAME() runs once per site during drush updb, with no undo and no failing test on the site it breaks. RT-MIG governs the migration primitives a hook may call; these rules govern the hooks themselves — how a batched one terminates, and what the destructive ones are allowed to touch. The first rule exists because its absence shipped a defect: two backfills counted entities processed rather than ids consumed, and any row deleted between the query and the pass that reached it left drush updb looping forever with nothing to do, no error and no output.

ID Rule Impl Pinned
UPD-1 A sandboxed hook's progress counter counts ids CONSUMED, not entities processed. The shape every batched hook here follows: the first pass captures the id list once and sets total to count($ids); each pass array_splices a fixed slice off the front and writes the remainder back; and inside the loop $progress++ is the first statement, ahead of every guard and continue. #finished is then total === 0 ? 1 : progress / total. The counter and the denominator must therefore measure the same thing: total counts ids, so anything that increments progress only for rows that loaded, parsed or matched can skip an id the query returned — the id list empties while #finished is still below 1, and Drupal's update runner, whose only exit is #finished >= 1, re-invokes the hook forever. There is no error, no output and no progress; the site is simply wedged mid-upgrade. This is a property of the counter, not of the workload: a row deleted between the query and the pass that reaches it is the ordinary case, not a pathological one flowdrop_interrupt.post_update.php (backfill_direction_and_job_id, backfill_linked_interrupt_id), flowdrop_pipeline.post_update.php (backfill_session_and_pending_interrupt, clear_unfinished_completed_stamps), flowdrop_session.post_update.php (clean_linked_interrupt_request_messages) ✅ one termination test per hook, each deleting a row mid-run and asserting the hook still converges: InterruptUpgradePathTest::testDirectionBackfillTerminatesWhenRowVanishesMidBatch + ::testLinkedBackfillTerminatesWhenRowVanishesMidBatch, PipelineUpgradePathTest::testDeletedSessionDoesNotStrandTheUpdate + ::testDeletedPipelineDoesNotStrandTheStampClear, SessionUpgradePathTest::testDeletedMessageDoesNotStrandTheUpdate; each file also drives its hook across a real pass boundary (…SpansPasses…) and over an empty site
UPD-2 A backfill writes only where the destination is empty, and re-running it is a no-op. Every promotion pass here reads a legacy source and writes a typed destination only when that destination is unset: linked_interrupt_id is written only when the column is NULL and the context key is a non-empty string; job_id is never recomputed for a row that already carries one; the pipeline lift stamps session_id and the pending-interrupt slots only when they are NULL. So an operator's later correction is never overwritten by a re-run, and a hook interrupted mid-batch may be re-run wholesale. Idempotence here is not a nicety — Drupal re-invokes a sandboxed hook until it converges (UPD-1), so every pass after the first is by construction a re-run over rows already done flowdrop_interrupt.post_update.php (getJobId() === NULL, getLinkedInterruptId() !== NULL skip); flowdrop_pipeline.post_update.php (getSessionId() === NULL, getPendingInterruptId() === NULL) InterruptUpgradePathTest::testAnExistingJobIdIsNeverRecomputed, ::testStoredLinkedIdIsNeverOverwrittenByContext, ::testDirectionBackfillIsIdempotent, ::testLinkedBackfillIsIdempotentAndSurvivesAnEmptySite; PipelineUpgradePathTest::testAlreadyMigratedColumnsAreLeftAlone, ::testStampClearIsIdempotent, ::testEmptySiteAndRerunAreBothNoOps
UPD-3 The two destructive passes delete on a stated predicate and nothing wider. flowdrop_session_post_update_clean_linked_interrupt_request_messages() deletes conversation history, so its predicate is the whole of its safety: a message is deleted only when it is SYSTEM-role AND its metadata type is exactly interrupt_request AND the interrupt its metadata.interrupt_id names still exists AND that interrupt carries a non-empty linked_interrupt_id. Any link in that chain missing leaves the message alone — a dangling interrupt reference is a reason to keep the row, not to delete it. flowdrop_pipeline_post_update_clear_unfinished_completed_stamps() erases a completed value only on a non-terminal pipeline, which is the entire discrimination available: completed is written by markAsCompleted()/markAsFailed()/markAsCancelled(), each of which sets a terminal status in the same call, so a pending/running/paused row carrying a stamp is provably bogus while a terminal row's two possible origins are indistinguishable — those are left exactly as their own lifecycle wrote them rather than guessed at flowdrop_session.post_update.php (the four-condition chain); flowdrop_pipeline.post_update.php (in_array($pipeline->getStatus(), $terminal, TRUE) skip) SessionUpgradePathTest::testLinkedInterruptPlaceholderIsDeleted, ::testEveryUnmatchedMessageSurvives, ::testNonSystemRolesAreOutOfScope; PipelineUpgradePathTest::testOnlyUnfinishedRunsLoseTheirCompletionStamp, ::testStampClearWhenThereIsNothingToCorrect
UPD-4 The session lift's second source is a JSON-substring probe, and it depends on a spelling nothing else pins. Besides the session-metadata path, backfill_session_and_pending_interrupt finds pipelines to stamp by matching the serialised execution_context blob with LIKE '%"session_id":"<id>"%' — a substring match against JSON, not a field query, because the value lives inside a blob column with no schema. It therefore matches only a session id serialised as a JSON string, and a pipeline naming a different session is correctly not claimed. ⚠ Nothing in the module pins that execution_context.playground.session_id is written as a string: SessionExecutionService::buildOrchestrationOptions() stamps $session->id(), whose type depends on whether the entity came from storage, and an int-serialised id ("session_id":3) does not match this probe — such a pipeline keeps a NULL session_id column with no error and no log line. The probe is recorded here because it is the module's one dependency on the internal shape of a serialised blob, and the hook that depends on it runs once with no undo flowdrop_pipeline.post_update.php (the execution_context LIKE candidate query); producer SessionExecutionService::buildOrchestrationOptions() :813-817 PipelineUpgradePathTest::testPlaygroundContextPipelinesAreStamped (two pipelines carrying the string spelling are stamped, one naming another session is not)

Part III — Decisions

Questions the code left genuinely open — where two reasonable behaviours existed and the code had simply picked one by accident — have been settled. Each ruling's "new rule" already appears in the Part I/II tables. A rule a ruling created and that has since shipped carries ← OPEN-n as provenance; a ruling whose target has not been built yet leaves its rule DECIDED / ❌ until its test and fix land; and a ruling whose current answer is deliberately temporary leaves its rule ✅ and TRANSITIONAL. Only API-8 is DECIDED today.

ID Ruling Outcome
OPEN-1 Match pipeline semantics. Error Outputs route identically in every engine. New ERR-13 (shipped); SynchronousOrchestrator fixed, ERR-12's divergence deleted.
OPEN-2 Hidden means hidden. Workflow exposure may only target instance-exposed ports. New R10 (validator + doctor remedies) + MAN-3 (the workflow form's picker filters instance-hidden ports). Both shipped; no release gate outstanding.
OPEN-3 Wire validate() into save-time validation. A broken expression is mis-transcribed intent; reject at save. New R11; resolves LANG-5.
OPEN-4 Reject duplicate node ids at save. Silent last-wins collapse forbidden. New R2.
OPEN-5 Reject duplicate/missing edge ids at save. Read-path minting stays legacy read tolerance only. New R3.
OPEN-6 Unknown node type = save-time error (rule notes config-sync import ordering as the operational caveat). New R9; R1.b's skip stands internally but the workflow is rejected.
OPEN-7 Structural rules now, dataType compatibility deferred to a designed compatibility-matrix pass (see OPEN-7b below). New R12 (self-edges), R13 (duplicate parallel edges).
OPEN-8 One policy: an expression error fails the node (\RuntimeException → error-edge routing). Leniency only ever as explicit config. New LANG-29; fixes DataExtractor's swallow of errors + DataShaper/PromptTemplate leaks. Not in scope, and not changed: DataExtractor's default-on-no-match soft miss (LANG-24).
OPEN-9 Deliberate pair, documented. ORC-10 = resumable run budget (pause); SG-14 = loop safety valve (hard failure). Both already pinned; add cross-ref notes in code docs so nobody "unifies" them. Docs-only.
OPEN-10 Resume is not uniform "replay completed nodes." (Narrowed after code verification — the original "every engine replays completed nodes as done" is unachievable: GraphState::toSnapshotData() emits no completion set, and both engines deliberately re-run nodes per loop iteration by newest-job-id, so a blanket done-set breaks loops.) Ruling: (a) a terminal snapshot (COMPLETED/FAILED/CANCELLED) is non-resumable; (b) an engine that cannot consume a given snapshot MUST throw, never silently discard-and-restart. Per-engine resume semantics otherwise stay as-is. New INT-14/INT-15 (shipped): terminal snapshots refused by the consuming engines (+ SG-12's explicit checkpoint arm); sync engines throw on any attached snapshot.
OPEN-13 Refuse, don't no-op: an engine that cannot iterate refuses a loop. (2026-08-18, from the spec-vs-convention review — process/analysis/spec-registry-conventions/ in the workspace.) ORC-7's inert-loop behaviour breaks RT-ORC's own charter ("a workflow should mean the same thing no matter which engine runs it"); every benchmark engine either executes a defined loop or rejects the definition, and INT-15 already states the principle. Ruling: direct sync refuses at launch (typed error) any workflow whose compiled graph carries a loopback edge. New LoopbackWorkflowException (shipped): SynchronousOrchestrator refuses after compiling, before any node executes; ORC-7 amended with the shipped behaviour.
OPEN-14 One NULL-delivery semantics, every builder. CFG-7/DATA-1/DATA-2 pin three input builders that disagree on whether an explicitly emitted NULL is delivered (array_key_exists vs isset()), so the same workflow resolves different parameter values per engine — a divergence no benchmark tolerates. Ruling: presence is array_key_exists everywhere; the StateGraph isset() gate is a bug, and its divergence-pinning test flips to pin the unified rule. CFG-7 + DATA-1 amended with DECIDED targets; fix tracked as a follow-up issue.
OPEN-15 An unmatched gateway outcome is loud. BR-4's silent whole-subtree gate-off is the canonical gateway anti-pattern (BPMN/Camunda throw; Step Functions fails States.NoChoiceMatched; n8n has a fallback output). Ruling: a gateway either declares a default branch port or the node fails with an error Output (error-edge routable per ERR-7) when no branch matches; it never emits a branch name no port carries. BR-4 amended with a DECIDED target; design + fix tracked as a follow-up issue.
OPEN-16 The published required is enforced. MAN-15: the snapshot advertises required that no launch door reads from it, so via the UI every declared input is optional — a published contract callers build against and the boundary ignores (GH Actions and Argo both enforce theirs). Ruling: the checker enforces required from the snapshot fragment and the authoring form exposes the flag. Shipped: MAN-15's checker now unions manifest-entry required with the snapshot fragment's top-level required list (WorkflowInputChecker.php:48-116); MAN-16 notes the emission side (WorkflowSchemaBuilder.php:72-113); flowdrop_workflow.port_exposure config schema gained required; FlowDropWorkflowForm exposes a "Required" checkbox on each input's metadata panel.
OPEN-17 An unrecognised edgeType string is a save-time error. EDGE-5's silent fall-through turns a typo (loopbak) into an ordering edge; convention validates declared enums at the door. Ruling: a non-empty data.edgeType that EdgeType::isKnown() refuses is a validator error (new R-code, next free number); absent/empty declarations keep the handle fallback. EDGE-5 amended with a DECIDED target; new R-code + fix tracked as a follow-up issue.
OPEN-18 Typed refusal codes; message text is never contract. INT-16 makes clients classify 409s by substring — the textbook stringly-typed API contract; convention everywhere is stable machine-readable codes (RFC 7807, ASL error names, Temporal typed failures). Ruling: new rule API-8 (every refusal carries a stable error_code; codes never renumbered); the signal API is the first retrofit, coordinated with fdnpm's classifyRefusal(), and the substrings stay load-bearing until fdnpm reads the code. New API-8 (DECIDED/❌); INT-16 amended.
OPEN-19 Pinned accidents are transitional, not doctrine. Umbrella ruling for host-quirk behaviours the registry had pinned as-is; each keeps its pin (per Part IV: a pin records what happens) but now also states its target, so fixing one is a planned test change, not a contract break. Members: STORE-2 (empty() refuses the name '0'; byte cap), STORE-9 (empty-details flow-id 422), STORE-15 ('0' search term; array-param 500), PIPE-9 (fabricated createdAt), LANG-28 (lowercased regex pattern), ERR-6 (serializability policed only post-filter), CMP-2 (enrichment conditional on wiring). The seven rows amended with DECIDED targets; no release gate — fixed opportunistically as their surfaces are touched.

Still genuinely open (deferred by design, not forgotten):

ID Question
OPEN-11 Does the interrupt inbox ENDPOINT owe what RT-GATE-13 promises the inbox FORM? InterruptApiController::listUserInterrupts() casts the current user's id and asks the manager for that uid's pending rows — with no gate-only narrowing and no access() filtering, unlike the sibling session and pipeline list endpoints, which filter per entity. RT-GATE-13 says an unassigned NON-gate interrupt "stays invisible" and describes InterruptInboxForm::baseQuery(); the endpoint exposes it, and for an anonymous caller the uid-0 bucket IS the unassigned bucket. Reachable only where a site grants one of the route's four permissions to the anonymous role. Two defensible answers — narrow the endpoint to match the rule, or widen the rule to say the API deliberately exposes the whole uid bucket and relies on route permissions — and picking one silently would either break a consumer or bless a disclosure. Today's behaviour is pinned (InterruptApiPayloadContractTest::testAnonymousInboxReturnsUnassignedRows, ::testListUserInterruptsIsScopedToTheCaller) but deliberately not legislated: a pin records what happens, a rule records what is intended, and C6 declined to convert one into the other
OPEN-12 Does session-message access delegate to the parent session, or only borrow its owner? FlowDropSessionMessageAccessControlHandler's class docblock reads "Message access is determined by access to the parent session", but checkAccess() never calls $session->access(): it loads the session solely to read getOwnerId() and then decides on view own/any flowdrop_session_message (MEM-15). The consequence is real — a hook_flowdrop_session_access() implementation that forbids a session does not protect that session's transcript, and SessionMessageAccessControlTest::testSessionViewPermissionsDoNotGrantMessageAccess pins the converse independence. So either the comment is wrong or the delegation is missing, and the two fixes are not equivalent: adding the delegation changes who can read transcripts on any site running a session-access hook. Recorded as a decision to take, not a cleanup to do
OPEN-7b Port dataType compatibility at save — needs a designed compatibility matrix first (coercions? edge-level type narrowing?). Partly settled: MAN-16 fixes the value-check reading of mixed/any — they are declared no-constraint types that waive the type check, matching what ParameterResolver and ToolParameterScope already do. A save-time matrix must not contradict that; what remains open is whether an edge between two differently-typed ports is refused, and whether anything coerces.
OPEN-20 Which controllers deserve a registry row? RT-META was written after two routed JSON doors turned out to have no rule anywhere, which raised the question for the four controllers that are also unmentioned: CheckpointController, ExecutionController, SnapshotController, PipelineController. Ruled: the boundary is the response type, not the route. All four return Drupal render arrays — they are HTML/UI surfaces whose consumer is a human looking at a page, so a broken one is visible on sight and no cross-repo consumer parses their output. A JSON door is the opposite: its consumer is fdnpm, on its own release cadence, and a renamed key fails silently in another repository. So every routed door returning JsonResponse or CacheableJsonResponse gets a row; a controller returning a render array does not, and this ruling is the reason their absence is a decision rather than an oversight. Recorded so the next audit does not re-derive it — and so that the day one of these four grows a JSON method, the rule says plainly that it needs a row.

Part III-A — Corrections from code verification

Every load-bearing claim behind the rules above was re-checked against the code, and several earlier conclusions did not survive that check. The corrected rules were folded into the Part I/II tables directly (rather than kept as a second list) so this document has exactly one place to look up any rule. What the fold produced:

  • GR-API (API-1..4) — the API-boundary section. API-4 was split: the derived-schema requirement stood, the "return 422" half was dropped (the endpoint received only config, so it could not be edge-aware without a contract change) — and the endpoint was ultimately deleted (see the retired API-4 row).
  • INT-11, INT-12 in RT-ORC and INT-13..15 in RT-INT — pause, resume and snapshot rules, several of which reverse earlier conclusions. INT-12 is the clearest example: "a pause holds identically on every engine" is wrong, because async chunking depends on budget-pause → auto-resume; only a human-initiated pause holds unconditionally.
  • ORC-15 — cancellation must be announced. This replaced a narrower draft rule about one mislabelled event arm: the deeper issue was that no orchestrator dispatched a cancelled outcome at all, which made the mislabel unreachable rather than wrong. Both halves are now shipped (dispatch on every cancel path, explicit CANCELLED subscriber arm).
  • ORC-14 — a rule for atomic job claiming. Recorded as a rule even though the fix is deferred; the current single-worker constraint is documented rather than silently assumed. It is now ❌ and DECIDED rather than N/A: a commitment nobody has met belongs in the denominator, not beside a cross-reference.
  • RT-PIPE — the pipeline status API, the surface the editor actually polls. It had no rules at all before.

Two findings did not survive verification and are recorded here so they do not come back as rules:

  • Import validation. The bundle importer validates a normalised copy and stores the raw entries, which looks like a "validate one thing, store another" defect. It is neither exploitable nor lossy: core's config-schema casting means the validated and stored values agree for declared keys, and a malformed port name is refused by R4 before anything is saved. A real hole does exist nearby, but it is narrower — R4.d is skipped whenever the plugin cannot be resolved, which is the normal import path. That is captured in R4.d's own row and closed by R9.
  • Real-time status reporting. A cluster of reporting defects was traced to StatusController/StatusTracker, which no consumer polled (see the RT-ST removal note). They were cleanup, not live bugs — and the whole surface has since been deleted.

Part IV — Scoreboard

Grammar coverage: 99% of testable rules are pinned (386 of 389). Three are outstanding: API-8 (a stable machine-readable error_code on every refusal), OCX-6 (◐ — the adapter's contract of omission, reached only indirectly) and ORC-14 (atomic job claiming, a target rather than a description). Rules marked "—" assert nothing by design — structural statements, cross-references to another section's rules (MAN-4, DATA-7, DATA-8), rules superseded by another (ERR-12 by ERR-13), and the retired rows of removed surfaces (RT-ST, API-4, R6.k) — so they are excluded from the denominator rather than counted as failures.

The N/A rows have been audited, and "—" now means only those four things. Two rows were sheltering there: SCH-1 was marked structural although the plugin attribute's constructor signature is its whole claim, and reflection reads it (now ✅); ORC-14's sentence is not true today, which makes it a DECIDED target and puts it in the denominator as ❌ rather than beside a cross-reference. OCX-6 is honestly partial (◐) rather than excluded: an adapter that validates nothing can be shown to pass a violating payload through, and today only OCX-5's shape test reaches it indirectly. The other six — API-4, R6.k, MAN-4, ERR-12, DATA-7, DATA-8 — were confirmed genuinely structural, and the ten RT-ST rows describe a deleted surface.

The number fell from 100%, and the fall is the registry working. Writing a row for a promise that has none adds a ❌; re-reading an N/A row and deciding it is merely hard to test, not structurally untestable, moves it into the denominator as a ❌. Both make the document more honest and the percentage lower. RT-META's nine rows were the first instalment: they cover two routed JSON doors that had no rule in any section while they shipped, and writing them cost the headline a point — which the audit of the N/A rows then cost it again, in the other direction, by moving a commitment nobody had met into the denominator where it belongs. Nothing in this registry should ever be changed to protect it.

Reaching 100% is a floor, not a finish line: it means every rule as written has an executable witness. It does not mean the rules are complete. A promise nobody has written down yet is still unpinned, and several rows are pinned at their weakest reading — the honest next move is sharpening rule sentences and adding rows, not defending the number.

These numbers are derived, not maintained. Regenerate them, and check the registry against the tests, with:

php scripts/spec-coverage.php          # scoreboard + disagreements
php scripts/spec-coverage.php --check  # exit 1 if the two disagree

The script reads the rule tables below, reads the spec-registry: <ID> tokens the tests bind themselves with, and reports both. Its second half is the part worth running in anger: it lists every rule marked ✅ that no test binds, and every binding naming a rule that no longer exists. Both drift silently otherwise.

A token is not an assertion, and comparing glyphs against tokens verifies bookkeeping, not strength. Four further drift classes are therefore checked rather than trusted, each of them a real defect found by hand before it was automated:

  • Assertion proximity. A spec-registry token in a test method's docblock claims that method asserts the rule, so the method body must contain something that can fail — an assertion, an expected exception, or a mock expectation. A token attached to nothing fails. (Class-level docblocks bind the file as a whole, and data providers feed a method that asserts, so both are exempt.)
  • Fixture over-claim. A fixture naming N rules in its covers: block must state at least N things in its expect: block (or, for the expression fixtures, its engines: block). Covering four rules and checking one is the cheapest way to inflate this table.
  • Impl citations. Every file:line in an Impl cell must name a file that exists and lines that file actually has. A citation that survived the refactor which moved the code points at nothing, and nothing else notices. TypeScript citations are exempt: the runtime's consumer half lives in the fdnpm repository.
  • DECIDED versus ✅, and TRANSITIONAL versus nothing. A row labelled DECIDED describes the target, not the code, so it cannot also carry a guarantee; a row labelled TRANSITIONAL is shipped behaviour with a named successor, so it must carry one. Both fail with no allowances. That is only exact because the labels are: DECIDED had been doing three jobs at once — the target, a shipped-but-temporary answer, and provenance (← OPEN-n) — which made 18 rows read as contradictions when none of them were. Each is now its own marker, and API-8 is the single DECIDED rule left.

The 1-witness column counts pinned rules whose entire guarantee lives in a single file. That is not automatically weak — one unit test file may hold twenty cases — but it is the population where deleting one file silently unpins a rule while this script still reports agreement, and where a rule pinned only against mocks is pinned against a fiction. 220 of 386 pinned rules sit there. GR-VAL, the worst concentration when this started at 33 of 41, is down to 20; GR-SCHEMA (24), GR-LANG (20) and GR-MAN (13) have had no such pass yet.

Second witnesses are added, never substituted. Thirteen validator rules — six structural (R2, R3, R8.a, R8.b, R12, R13) and seven schema-driven (R6.d to R6.j) — now also run through real flowdrop_node_type entities and real processor plugins, as fixtures under modules/flowdrop_runtime/tests/fixtures/validator/. Building them found a defect in the harness itself: expect.launch.refused was asserted with expectException alone, so the two existing fixtures declaring rule: R7 and a specific port asserted neither — any refusal for any reason passed them. A graph refused by the wrong rule is a different bug wearing the right exception, so the harness now asserts the declared rule code, the locators the fixture names, and — for rules whose content is what they do not refuse — the rules and locators it says must stay quiet. GR-VAL's own preamble lists which 20 rules remain mock-only and which of those are blocked rather than merely unwritten, so the gap is stated rather than implied.

Section Rules Pinned ✅ Partial ◐ Unpinned ❌ N/A — Pinned % 1-witness
GR-API 8 6 0 1 1 86% 0
GR-CFG 18 18 0 0 0 100% 8
GR-DYN 7 7 0 0 0 100% 6
GR-EDGE 9 9 0 0 0 100% 4
GR-EXPO 19 19 0 0 0 100% 12
GR-LANG 29 29 0 0 0 100% 20
GR-MAN 20 19 0 0 1 100% 13
GR-MEM 16 16 0 0 0 100% 8
GR-SCHEMA 41 41 0 0 0 100% 31
GR-STORE 15 15 0 0 0 100% 8
GR-VAL 41 40 0 0 1 100% 20
GR total 223 219 0 1 3 100% 130
RT-BR 7 7 0 0 0 100% 1
RT-CMP 11 11 0 0 0 100% 7
RT-CRON 2 2 0 0 0 100% 0
RT-DATA 12 10 0 0 2 100% 4
RT-ERR 13 12 0 0 1 100% 6
RT-GATE 15 15 0 0 0 100% 8
RT-INT 21 21 0 0 0 100% 9
RT-MD 2 2 0 0 0 100% 2
RT-META 9 9 0 0 0 100% 9
RT-MIG 7 7 0 0 0 100% 4
RT-NET 3 3 0 0 0 100% 2
RT-OCX 9 8 1 0 0 89% 7
RT-ORC 17 16 0 1 0 94% 10
RT-PIPE 9 9 0 0 0 100% 2
RT-PLAY 5 5 0 0 0 100% 5
RT-SG 20 20 0 0 0 100% 9
RT-SNAP 2 2 0 0 0 100% 2
RT-ST 10 0 0 0 10 0
RT-TOOL 10 10 0 0 0 100% 7
RT-TRIG 3 3 0 0 0 100% 3
RT-UPD 4 4 0 0 0 100% 1
RT total 191 176 1 1 13 99% 98
GRAND TOTAL 414 395 1 2 16 99% 228

What the sections are, and what a break there costs. The grammar and the runtime contract are now level. The sections a reader should still be most careful around are the ones whose rules are subtle, not the ones that are thinly covered:

  • GR-SCHEMA (100%) — how a plugin's declared inputs and outputs become the ports, forms and defaults the editor shows. Every testable rule is now pinned; if these rules break, the canvas misrepresents the node.
  • GR-LANG (100%) — the four expression engines. Their core semantics — context handling, empty-expression polarity, each evaluator's error behaviour (including the deliberate four-way disagreement about a missing key: throw vs blank vs NULL vs logged default), and each engine's validate() verdict (LANG-10 to LANG-13: syntax-only for EL and twig, parse-aligned with evaluate() for property_path, unconditional-TRUE for jsonpath's non-$ paths) — are now held still by the expression conformance fixtures (tests/fixtures/expressions/lang-*.yml), and the per-node expression semantics (LANG-21 to LANG-28) by processor unit tests. LANG-29, the uniform expression error policy, is implemented and pinned across all four expression-consuming processors (DataMapper, DataExtractor, DataShaper, PromptTemplate).

Two sections read oddly by design rather than by neglect. RT-PIPE was only written down recently — the live polling contract had no rules at all before — and every rule in it is pinned through the real controller: the payload shapes (PIPE-2, PIPE-3), the read path (PIPE-1's persisted state, PIPE-4's node-id keying, both asserted against a real launch) and the nullability split (PIPE-9). RT-ST is fully retired: it described an unconsumed surface whose endpoints, controller and tracker were deleted in 2.x, so its rows assert nothing anymore (see the removal note in that section).

Six sections are new (2026-08), and all six govern surfaces that had no rules while they were shipping. RT-PLAY and RT-SNAP are the playground and snapshot JSON APIs — sibling cross-repo contracts to RT-PIPE, written after three shipped endpoints turned out never to have worked. RT-META is the two editor metadata doors — the category list and the workflow schema snapshot — and writing it found two defects of exactly the kind an unwritten contract hides: the schema door's ETag ignored the ?annotated variant, so a client holding one variant's tag was told 304 for the other and served a body with every annotation missing; and the categories door published the caught exception's message verbatim while its own test asserted that leak as if it were the contract. RT-TRIG is the unattended half of cron triggering (overlap, jitter, run state) that RT-CRON deliberately does not cover. RT-UPD is the sandboxed post_update tier, whose first rule — a progress counter must count ids consumed — is the sentence whose absence let an infinite drush updb ship. The GR-API additions (API-5 to API-7) name the third response envelope, its paging semantics, and the blanket catch (\Exception) backstop as the defect class it is. None of these were discovered by reading the registry; they were found by writing tests for code the registry did not mention, which is the direction that keeps working.

Finally, a caution about the quiet risks. A partial pin used to be the obvious warning sign, and for a while the ◐ column was empty — which did not mean the risk had gone, only that it had stopped being labelled. OCX-6 is back in that column because "indirectly asserted" is what ◐ is for, and calling it N/A had claimed the promise was untestable rather than untested. Two further things to keep in view:

  • A rule can be pinned at its weakest reading. Several sentences were narrowed during the pinning passes because the code turned out to promise less than the prose did (R6.j skips only the schema-driven checks, not all of R6; R7's skip is per endpoint, not per edge; DATA-2's node-id strcmp fallback only applies when neither source has a positive execution_order). Where a rule reads more cautiously than you expected, that is usually deliberate — the test pins what the code does.
  • Declarative-only constraints are not enforcement. DYN-2's dataType enum is a schema declaration with no runtime validator behind it, so the rule is pinned as "declared and rendered", not "rejected". Read such rows for what they actually guarantee.

The cheapest coverage available is sharpening a vague sentence, writing a row for a promise that has none, or re-reading a row parked at "—" to see whether it is genuinely structural or merely untested. That last pass moved SCH-1 to ✅, ORC-14 to ❌ and OCX-6 to ◐, and "—" now means one of exactly four things: a structural statement, a cross-reference, a rule superseded by another, or a deleted surface.

Part V — How this document is maintained

  1. Rules are bound to tests by id. Conformance fixtures list the ids they pin in their covers: block; plain PHPUnit tests carry a Covers spec-registry: <ID> line in the relevant docblock. Grep the token to find everything a rule is guaranteed by:
grep -rn "spec-registry: CFG-7" modules tests
  1. Changing behaviour means changing a rule. If a change makes a rule's sentence false, update the sentence in the same commit — a registry that drifts from the code is worse than no registry. php scripts/spec-coverage.php --check fails when the registry and the bindings disagree: a rule claiming ✅ that no test binds, a binding naming a rule that no longer exists, a token on a method that asserts nothing, a fixture covering more rules than it checks, an Impl citation pointing at a line the file does not have, or a row claiming DECIDED and ✅ at once (Part IV).

  2. New promises get a row. Give it the next id in its section, cite the implementation, and mark it ❌ until a test asserts it. The scoreboard reading 100% is not a reason to skip the row — it is the reason the new ❌ will be visible.

  3. Fixtures stay the source of truth. Where a rule points at a conformance fixture, the fixture is the executable spec; this document is the index. Same governance as the parameter-resolution truth table and the execution-dependency rules. A fixture's expect block must assert every claim it makes — the harness once accepted rule: and ports: keys it never checked, which made a declared rule decorative.

  4. DECIDED rules are commitments, not descriptions. They record agreed behaviour that is not built yet, so an implementer knows the target and a reviewer can tell "not implemented" from "implemented wrong". Keep the label for exactly that: a DECIDED rule carries no test, and spec-coverage.php --check fails on a row claiming both DECIDED and ✅. For shipped behaviour with a named successor use TRANSITIONAL, which must be pinned — an unpinned transitional pin is not a pin — and for a rule a ruling created and that shipped, use ← OPEN-n provenance. The word did all three jobs at once for a while, which is why 18 rows read as contradictions when none of them were.